Skip to content

Commit c6e22c8

Browse files
committed
[REFACTOR]: Add shared linear trace runner
1 parent ef98095 commit c6e22c8

3 files changed

Lines changed: 523 additions & 0 deletions

File tree

‎rampart/core/__init__.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@
3636
resolve_attack_verdict,
3737
resolve_probe_verdict,
3838
)
39+
from rampart.core.trace import (
40+
EvaluationRecord,
41+
TraceRun,
42+
evaluate_terminal_async,
43+
run_trace_async,
44+
)
3945
from rampart.core.types import (
4046
EvalContext,
4147
EvalOutcome,
@@ -63,6 +69,7 @@
6369
"EvalOutcome",
6470
"EvalResult",
6571
"EvaluationPurpose",
72+
"EvaluationRecord",
6673
"Evaluator",
6774
"ExecutionEvent",
6875
"ExecutionEventData",
@@ -92,11 +99,14 @@
9299
"ToolCall",
93100
"ToolDeclaration",
94101
"TraceEndReason",
102+
"TraceRun",
95103
"Turn",
104+
"evaluate_terminal_async",
96105
"evaluate_turn_async",
97106
"execute_trials_async",
98107
"resolve_as_attack",
99108
"resolve_as_probe",
100109
"resolve_attack_verdict",
101110
"resolve_probe_verdict",
111+
"run_trace_async",
102112
]

‎rampart/core/trace.py‎

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
"""Shared linear trace execution and terminal evaluation helpers."""
5+
6+
from __future__ import annotations
7+
8+
from dataclasses import dataclass, field, replace
9+
from typing import TYPE_CHECKING
10+
11+
from rampart.core.types import (
12+
EvalContext,
13+
EvalResult,
14+
EvaluationPurpose,
15+
ObservabilityLevel,
16+
TraceEndReason,
17+
Turn,
18+
)
19+
20+
if TYPE_CHECKING:
21+
from rampart.core.adapter import Session
22+
from rampart.core.evaluator import Evaluator
23+
from rampart.core.manifest import AppManifest
24+
from rampart.core.prompt_driver import PromptDriver
25+
26+
27+
@dataclass(frozen=True, kw_only=True, eq=False)
28+
class EvaluationRecord:
29+
"""One online evaluation and the exact context it judged.
30+
31+
Args:
32+
evaluator: Evaluator object that produced the result. Identity is the
33+
reuse boundary.
34+
context: Exact raw-trace context passed to the evaluator.
35+
result: Evaluation returned for that context.
36+
"""
37+
38+
evaluator: Evaluator
39+
context: EvalContext
40+
result: EvalResult
41+
42+
43+
@dataclass(kw_only=True)
44+
class TraceRun:
45+
"""A completed linear trace and its latest online evaluation.
46+
47+
``turns`` is the driver/report view and may carry online evidence.
48+
``raw_turns`` is the evaluator view and never carries framework-produced
49+
evaluation annotations.
50+
51+
Args:
52+
trace_end_reason: Why the trace stopped producing turns.
53+
observability_level: What the adapter can observe.
54+
manifest: Agent capabilities used to create evaluator contexts.
55+
turns: Annotated history passed to prompt drivers and results.
56+
raw_turns: Annotation-free history passed to evaluators.
57+
latest_online_evaluation: Most recent stop-condition evaluation.
58+
"""
59+
60+
trace_end_reason: TraceEndReason
61+
observability_level: ObservabilityLevel
62+
manifest: AppManifest | None = None
63+
turns: list[Turn] = field(default_factory=list[Turn])
64+
raw_turns: list[Turn] = field(default_factory=list[Turn])
65+
latest_online_evaluation: EvaluationRecord | None = None
66+
67+
68+
def _evaluation_context(
69+
*,
70+
raw_turns: list[Turn],
71+
observability_level: ObservabilityLevel,
72+
manifest: AppManifest | None,
73+
) -> EvalContext:
74+
"""Build an evaluator context from a snapshot of the raw trace.
75+
76+
Returns:
77+
EvalContext: Context holding a shallow snapshot of raw turns.
78+
"""
79+
return EvalContext(
80+
turns=list(raw_turns),
81+
observability_level=observability_level,
82+
manifest=manifest,
83+
)
84+
85+
86+
async def run_trace_async(
87+
*,
88+
session: Session,
89+
driver: PromptDriver,
90+
max_turns: int,
91+
observability_level: ObservabilityLevel,
92+
stop_when: Evaluator | None = None,
93+
manifest: AppManifest | None = None,
94+
) -> TraceRun:
95+
"""Drive a linear conversation with optional online stopping.
96+
97+
The runner does not own session lifetime or exception conversion. Callers
98+
keep the session context active around this function, and exceptions from
99+
the driver, session, or evaluator propagate unchanged.
100+
101+
Args:
102+
session: Active agent session.
103+
driver: Prompt source for the conversation.
104+
max_turns: Maximum number of requests sent to the agent.
105+
observability_level: What the adapter can observe.
106+
stop_when: Optional evaluator checked after every response. A detected
107+
outcome terminates the trace.
108+
manifest: Agent capabilities exposed to evaluators.
109+
110+
Returns:
111+
TraceRun: Completed turns, termination reason, and online evidence.
112+
113+
Raises:
114+
ValueError: If ``max_turns`` is negative.
115+
"""
116+
if max_turns < 0:
117+
msg = "max_turns must be non-negative."
118+
raise ValueError(msg)
119+
120+
run = TraceRun(
121+
trace_end_reason=TraceEndReason.MAX_TURNS_REACHED,
122+
observability_level=observability_level,
123+
manifest=manifest,
124+
)
125+
126+
for turn_index in range(max_turns):
127+
decision = await driver.next_prompt_async(history=list(run.turns))
128+
if decision is None:
129+
run.trace_end_reason = TraceEndReason.DRIVER_EXHAUSTED
130+
return run
131+
132+
response = await session.send_async(decision.request)
133+
raw_turn = Turn(
134+
request=decision.request,
135+
response=response,
136+
turn_number=turn_index,
137+
driver_reasoning=decision.reasoning,
138+
)
139+
run.raw_turns.append(raw_turn)
140+
141+
if stop_when is None:
142+
run.turns.append(raw_turn)
143+
continue
144+
145+
context = _evaluation_context(
146+
raw_turns=run.raw_turns,
147+
observability_level=observability_level,
148+
manifest=manifest,
149+
)
150+
evaluation = await stop_when.evaluate_async(context=context)
151+
run.latest_online_evaluation = EvaluationRecord(
152+
evaluator=stop_when,
153+
context=context,
154+
result=evaluation,
155+
)
156+
run.turns.append(
157+
replace(
158+
raw_turn,
159+
eval_result=evaluation,
160+
eval_purpose=EvaluationPurpose.STOP_CHECK,
161+
),
162+
)
163+
if evaluation.detected:
164+
run.trace_end_reason = TraceEndReason.STOP_CONDITION_MET
165+
return run
166+
167+
return run
168+
169+
170+
async def evaluate_terminal_async(
171+
*,
172+
evaluator: Evaluator,
173+
run: TraceRun,
174+
) -> EvalResult | None:
175+
"""Evaluate the terminal raw trace, reusing an identical online judgment.
176+
177+
Args:
178+
evaluator: Evaluator responsible for the final verdict.
179+
run: Completed trace from :func:`run_trace_async`.
180+
181+
Returns:
182+
EvalResult | None: Final evaluation, or None when no turns exist.
183+
184+
Call this before leaving any active session or injection context required
185+
by the evaluator. Requests, responses, and their nested values are treated
186+
as immutable after the runner appends them.
187+
"""
188+
if not run.raw_turns:
189+
return None
190+
191+
record = run.latest_online_evaluation
192+
if (
193+
record is not None
194+
and record.evaluator is evaluator
195+
and len(record.context.turns) == len(run.raw_turns)
196+
and all(
197+
evaluated is terminal
198+
for evaluated, terminal in zip(
199+
record.context.turns,
200+
run.raw_turns,
201+
strict=True,
202+
)
203+
)
204+
):
205+
return replace(record.result, evidence=list(record.result.evidence))
206+
207+
context = _evaluation_context(
208+
raw_turns=run.raw_turns,
209+
observability_level=run.observability_level,
210+
manifest=run.manifest,
211+
)
212+
return await evaluator.evaluate_async(context=context)

0 commit comments

Comments
 (0)