Repository navigation
feat(verifiers): give verifiers the run's conversation and trigger firing logs - #241
sarah-chitty-1 wants to merge 12 commits into
Conversation
…ring logs `run_context.load_run_context` reads what a run recorded about the prompt a verifier grades into a `RunContext`: the stored conversation, every user-side turn included (the prompt, then the user-sim's or a human's messages), each user message marked with the agent triggers that wrote it and the extra fields the user-sim returned; each env's gateway firing log, from the envelope prompt_agent captures; and the user agent's own firing log. What the run meant to record but can't be read is named in `unavailable`, so a missing log is not read as an empty one. `RunContext.as_of_turn(k)` cuts it the way `grade_up_to_turn` cuts the trajectory: the messages up to turn k's reply and the events from before turn k+1's message. rubrics_verifier: `run_context_for_judge` (["conversation", "trigger_log"]) appends a Conversation and a Trigger firings section to the judge prompt, cut at `grade_up_to_turn`; messages and logs are bounded. env_outcome_verifier: a script whose `verify` takes two parameters is called `verify(mcp_url, run)`; `prompt_id` picks the prompt, the run's last one by default. A one-parameter `verify` is called as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-line comments - env_outcome_verifier calls `verify(mcp_url, run=...)` only when the script names a `run` parameter; a script with any other second parameter is called exactly as before (new test). - Every added comment and docstring is one line; pre-existing ones are as they were. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… not empty; a turn cut without the conversation withholds the logs - load_run_context marks agent_trigger_events unavailable when the prompt's user agent had triggers (a firing recorded for the step, or a registration for its user agent; any registration when the conversation can't be read) but prompt_agent stored no firing log. The judge's Trigger firings section now lists every unavailable log, not only the env ones. - as_of_turn withholds the firing logs, and marks each unavailable, when it can't find when the next turn began (no conversation was read), instead of returning them uncut. - The 200-character cap on a read error's message is RunContext.ERROR_MAX_CHARS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ass variables RUN_CONTEXT_MESSAGE_MAX_CHARS (4000) and RUN_CONTEXT_EVENTS_MAX (200) move from module level onto RubricsVerifierTaskStep as ClassVars, beside its other limits; render_run_context reads them there. Same values, same rendering. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| if prompt_response is not None: | ||
| conversation = await asyncio.to_thread(_conversation, context, prompt_response, unavailable) | ||
| agent_events = _agent_trigger_events(context, prompt_response.step_id, conversation, unavailable) | ||
| env_events = await asyncio.to_thread(_env_trigger_events, context, unavailable) |
There was a problem hiding this comment.
🤖 Drafted by Claude Code, reviewed by Edgar Arakelyan before posting.
Suggestion: cut an earlier prompt's env firing logs where the next prompt began.
env_trigger_state keeps one entry per env, and each prompt_agent overwrites it, so what this reads is the env's whole log as of the last prompt. Your point 2 notes this, but it goes further than "cut by turn, not by prompt": the cut only happens when grade_up_to_turn is set. Without it, a verifier grading prompt 1 of a two-prompt task sees prompt 2's firings as if they were prompt 1's.
Example: a task books lunch (prompt 1), then dinner (prompt 2). A verifier with prompt_id set to the lunch prompt and run_context_for_judge: ["trigger_log"] would show the judge dinner_confirm firing during the lunch prompt. The test in the diff below reproduces it: on this branch, grading the first prompt returns ["lunch_confirm", "dinner_confirm"].
Proposal. When a later prompt exists, cut the env logs at its first stored message. prompt_agent stores that message when it's sent (record_turn), so it marks when the next prompt began, just as as_of_turn uses the next turn's message. If that start can't be read, withhold the logs and mark them unavailable, the same rule as_of_turn already applies.
The last prompt, which is the default, is unchanged. The user agent's log is already kept per step, so it isn't touched.
Diff (applied to edc94b4 and run locally: test_run_context.py 27 passed, tst/unit/task_step/verifiers 373 passed)
diff --git a/src/agent_env/task_step/task_steps/verifiers/run_context.py b/src/agent_env/task_step/task_steps/verifiers/run_context.py
index f19cfab..ebaa427 100644
--- a/src/agent_env/task_step/task_steps/verifiers/run_context.py
+++ b/src/agent_env/task_step/task_steps/verifiers/run_context.py
@@ -64,8 +64,9 @@ async def load_run_context(context: TaskStepContext, prompt_response: Optional[P
conversation = await asyncio.to_thread(_conversation, context, prompt_response, unavailable)
agent_events = _agent_trigger_events(context, prompt_response.step_id, conversation, unavailable)
env_events = await asyncio.to_thread(_env_trigger_events, context, unavailable)
- return RunContext(conversation=conversation, env_trigger_events=env_events, agent_trigger_events=agent_events,
- unavailable=unavailable)
+ run = RunContext(conversation=conversation, env_trigger_events=env_events, agent_trigger_events=agent_events,
+ unavailable=unavailable)
+ return await asyncio.to_thread(_until_next_prompt, run, context, prompt_response) if prompt_response else run
def _error(e: Exception) -> str:
@@ -144,6 +145,31 @@ def _env_trigger_events(context: TaskStepContext, unavailable: dict[str, str]) -
return events
+def _until_next_prompt(run: RunContext, context: TaskStepContext, prompt_response: PromptResponse) -> RunContext:
+ """The env firing logs are the whole run's (the last prompt's capture), so an earlier prompt's stop where the
+ next prompt began; withheld when that start is unknown."""
+ index = next((i for i, pr in enumerate(context.prompt_responses) if pr is prompt_response), None)
+ if index is None or index + 1 >= len(context.prompt_responses) or not run.env_trigger_events:
+ return run
+ start = _first_message_ts(context, context.prompt_responses[index + 1])
+ if start is None:
+ why = "withheld: when the next prompt began is unknown, so its events can't be told apart"
+ withheld = dict.fromkeys((f"env_trigger_events.{env_id}" for env_id in run.env_trigger_events), why)
+ return replace(run, env_trigger_events={}, unavailable={**run.unavailable, **withheld})
+ return replace(run, env_trigger_events={env_id: [e for e in events if _before(e.get("ts"), start)]
+ for env_id, events in run.env_trigger_events.items()})
+
+
+def _first_message_ts(context: TaskStepContext, prompt_response: PromptResponse) -> Optional[str]:
+ conversation_id = (context.metadata.get("a2a_conversations") or {}).get(prompt_response.step_id)
+ try:
+ doc = conversation_store.get_conversation(conversation_id) if conversation_id else None
+ except Exception:
+ return None
+ messages = (doc or {}).get("messages") or []
+ return messages[0].get("ts") if messages else None
+
+
def _before(ts: Optional[str], cutoff: Optional[str]) -> bool:
if ts is None or cutoff is None:
return True
diff --git a/tst/unit/task_step/verifiers/test_run_context.py b/tst/unit/task_step/verifiers/test_run_context.py
index cbd3374..95a227b 100644
--- a/tst/unit/task_step/verifiers/test_run_context.py
+++ b/tst/unit/task_step/verifiers/test_run_context.py
@@ -376,3 +376,41 @@ def test_prompt_id_round_trips_and_is_omitted_when_unset(monkeypatch):
step = _outcome(RUN_SCRIPT, monkeypatch, prompt_id="p1")
assert EnvOutcomeVerifierTaskStep.from_dict(step.to_dict()).prompt_id == "p1"
assert "prompt_id" not in _outcome(RUN_SCRIPT, monkeypatch).to_dict()
+
+
+def _stored_prompt(step_id: str, user: str, agent: str) -> tuple[str, list[dict]]:
+ conversation_id = f"conv-{uuid.uuid4().hex[:8]}"
+ conversation_store.create_conversation(conversation_id, task_instance_id="run-1", source_agent_name="dana_sim",
+ target_agent_name="solver")
+ conversation_store.add_a2a_task(conversation_id, parts=_text(user), a2a_task_id=uuid.uuid4().hex, role="user")
+ time.sleep(0.005)
+ conversation_store.complete_a2a_task(conversation_id, parts=_text(agent), role="agent")
+ time.sleep(0.005)
+ return conversation_id, conversation_store.get_conversation(conversation_id)["messages"]
+
+
+@pytest.mark.asyncio
+async def test_an_earlier_prompt_sees_only_the_env_events_from_before_the_next_prompt_began():
+ first_id, first = _stored_prompt("prompt-1", "Book lunch with Dana.", "Booked for noon.")
+ second_id, second = _stored_prompt("prompt-2", "Now book dinner.", "Booked for seven.")
+ env_events = [ # the env's whole log, as the second prompt's end-of-prompt capture stored it
+ {"seq": 1, "ts": _between(first[0]["ts"], first[1]["ts"]), "kind": "fired", "trigger_id": "lunch_confirm"},
+ {"seq": 2, "ts": _between(second[0]["ts"], second[1]["ts"]), "kind": "fired", "trigger_id": "dinner_confirm"},
+ ]
+ envelope = {"env_id": "calendar", "state": {"triggers": [], "events": env_events}}
+ object_url = get_config().get_object_store().put(f"env_trigger_state/{second_id}.json",
+ json.dumps(envelope).encode(), content_type="application/json")
+ context = TaskStepContext(metadata={
+ "a2a_conversations": {"prompt-1": first_id, "prompt-2": second_id},
+ "env_trigger_state": {"calendar": {"object_url": object_url, "capture_step_id": "prompt-2"}},
+ })
+ for prompt_id, step_id, text, reply in (("p1", "prompt-1", "Book lunch with Dana.", "Booked for noon."),
+ ("p2", "prompt-2", "Now book dinner.", "Booked for seven.")):
+ context.prompt_responses.append(PromptResponse(prompt_id=prompt_id, response=reply, prompt_text=text,
+ step_id=step_id))
+
+ graded_first = await load_run_context(context, context.prompt_responses[0])
+ graded_last = await load_run_context(context, context.prompt_responses[1])
+
+ assert [e["trigger_id"] for e in graded_first.env_trigger_events["calendar"]] == ["lunch_confirm"]
+ assert [e["trigger_id"] for e in graded_last.env_trigger_events["calendar"]] == ["lunch_confirm", "dinner_confirm"]|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class RunContext: |
There was a problem hiding this comment.
🤖 Drafted by Claude Code, reviewed by Edgar Arakelyan before posting.
Suggestion: hand verifiers a typed, read-only run record instead of raw dicts.
RunContext passes the records as the stores and logs keep them: message dicts, plus each firing log's raw events. Take a verify script that checks "did the agent tell the user about the reschedule the world made mid-run?" It has to know:
- two log formats: the gateway's
detected→ actions →fired/failedsequence, and the user agent's singlefiredentries; - the
unavailablekey naming; - how to place an ISO-string timestamp in a turn.
async def verify(mcp_url, run=None):
key = "env_trigger_events.calendar"
if key in run.unavailable: # forget this and a missing log reads as "never fired"
raise RuntimeError(run.unavailable[key])
fired = next((e for e in run.env_trigger_events["calendar"]
if e["kind"] == "fired" and e.get("trigger_id") == "reschedule"), None)
starts = [m["ts"] for m in run.conversation if m["role"] == "user"]
turn = sum(s <= fired["ts"] for s in starts) if fired else None # string-compare stamps to find the turn
reply = next((m for m in run.conversation if m["role"] == "agent" and m["turn"] == turn), None)
return [{"name": "told_user", "passed": bool(fired and reply and "12:30" in reply["text"])}]Every line of that ties a verifier script to an internal format, so the gateway's log format becomes a public contract by accident.
Proposal. Keep the loader as it is, and have it build a small typed model of what graders reason about (messages, turns and fires) rather than log events:
@dataclass(frozen=True)
class Message:
turn: int
role: Literal["user", "agent"]
text: str
sent_at: datetime
author: str | None = None
written_by_triggers: tuple[str, ...] = () # agent triggers that wrote this user message
user_sim: Mapping[str, Any] | None = None # the user sim's extra fields
@dataclass(frozen=True)
class Firing:
trigger_id: str
source: str # "env:<env_id>" or "user_agent"
turn: int # the turn it fell in; 0 = before the first message
at: datetime
virtual_time: datetime | None
ok: bool # fired, rather than failed
provoked_by: Mapping[str, Any] | None
@dataclass(frozen=True)
class Turn:
number: int
message: Message # the user-side message that opened it
reply: Message | None
firings: tuple[Firing, ...] # what fired during it
class Run:
turns: tuple[Turn, ...]
unavailable: Mapping[str, str]
def turn(self, number: int) -> Turn: ...
def firings(self, trigger_id: str | None = None, *, env: str | None = None) -> tuple[Firing, ...]: ...
def firing(self, trigger_id: str, *, env: str | None = None) -> Firing | None: ... # the first one
def as_of(self, turn: int) -> "Run": ...The same check:
from agent_env.verifiers import Run
async def verify(mcp_url: str, run: Run | None = None):
moved = run.firing("reschedule", env="calendar") # None: it never fired. Raises: its log is missing.
reply = run.turn(moved.turn).reply if moved else None
return [{"name": "told_user", "passed": bool(reply and "12:30" in reply.text)}]Three rules make it more than syntax:
- One record per fire, built in one place. The framework folds the gateway's event sequence and the user agent's entries into
Firing. If a log format changes, one adapter changes and no script breaks. - A missing log raises; it never reads as "false". A query that needs an unavailable log raises, for example
RunRecordUnavailable(source, reason). The idea behind yourunavailablemap, that a missing log isn't an empty one, then holds even for a script author who forgets to check. - Every firing carries its turn. The judge's sections can render from the same model, so the judge sees "fired during turn 2", not a timestamp it can't line up with the conversation.
Timing: nothing depends on the dict shape yet. Once verify scripts are stored as artifacts, they'll freeze whatever shape ships, so this is cheapest before merge. Registering these types on the plugin surface would let the Plugin API check guard the contract from then on.
Your loader keeps doing all the reading and joining, and this is a layer on top of it. Happy to prototype it against your tests if that helps.
There was a problem hiding this comment.
Done in 14b9342: Run/Turn/Message/Firing in agent_env.task_step.task_steps.verifiers (where the verifier types are exported), also on the plugin surface. A missing log raises RunRecordUnavailable, and the judge sections render from the model ("confirm fired during turn 1").
… began Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, and render the judge's sections from it One Firing per fire, folded in one place from the gateway's detected -> fired/failed sequence and the user agent's entries, each placed in its turn. A query that needs a record that couldn't be read raises RunRecordUnavailable. The types are exported from agent_env.task_step.task_steps.verifiers and registered on the plugin surface. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…its env firings withheld, not cut at the shared start Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
🤖 Automated Claude routine — Env Pod PR Context Review. This is a bot, not Edgar Arakelyan; not a human review. Context from a sibling PR rather than this diff: #265 (open, same author) adds a new key to the very conversation records There, This PR's typed Adding the field stays backwards-compatible whichever lands first, so this is only a question of whether to decide it now — Generated by Claude Code |
…ed with Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ages; withheld when the boundary isn't found Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ent's cap is unavailable, not complete Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What
Give verifiers the run's conversation, including the user sim's turns, and the trigger firings, in a general way.
Today
env_outcome_verifiergets only the MCP URL. A task with triggers or a user sim can't grade "noticed the change", "asked before acting" or "didn't leak the answer" without pulling those records itself.run_context.load_run_contextbuilds a typed, read-onlyRunfrom what the run recorded.Run,Turn,Message,FiringandRunRecordUnavailableare exported fromagent_env.task_step.task_steps.verifiersand are on the plugin surface.Turnhas the user-side message that opened it (the prompt, then each user-sim or human message) and the agent's reply. A user message carries the agent triggers that wrote it and the user sim's extra fields.Firingper fire or failure. The gateway'sdetected→fired/failedsequence and the user agent's entries are folded in one adapter. Each firing carries its source (env:<id>oruser_agent), the turn it fell in (0 = before the first message), its time, its virtual time, and the env call that provoked it.RunRecordUnavailable(source, reason), so a missing log never reads as "never fired".run.unavailablenames each one.run.as_of(k)cuts it the way feat(rubrics_verifier): grade a multi-turn run as of turn k #222 grades "as of turn k": turns 1..k, and what fired before turn k+1 began.rubrics_verifier: a newrun_context_for_judge: ["conversation", "trigger_log"]adds Conversation and Trigger firings sections to the judge prompt, cut atgrade_up_to_turn. They render from the sameRun, so the judge reads "confirm fired during turn 1". Size is capped: 4000 characters per message, the latest 200 firings.env_outcome_verifier: a script whoseverifynames arunparameter is called asverify(mcp_url, run=...). Every other script, one with an unrelated second parameter included, is called exactly as before.prompt_idpicks which prompt, defaulting to the run's last one.Worth a look
verifythat namesrun, and the judge only whenrun_context_for_judgeis set. Nothing changes for existing tasks or scripts.prompt_agentstores at the end of a prompt, so the latest capture is the env's whole log. An earlier prompt's env firings are cut where the next prompt's first message was sent; if that can't be read, or the next prompt shares this one's conversation (onecontext_id), they're withheld and marked unavailable. Prompts sharing one conversation each get only their own messages, from their first message to the next sharing prompt's; if either can't be found exactly once, the conversation is withheld.agent_prompt_response_verifierdoesn't get the run. Should it get criteria like "trigger X fired"?grade_up_to_turnwithuse_trajectory=False. With the conversation shown to the judge, that could be allowed; the rule is left unchanged here.prompt_agentonly logs a warning when it can't read the user agent's firing log back, so the loader infers that a log was expected. It was if the step recorded a trigger firing or the prompt's user agent had registered triggers. If the conversation can't be read, any registered agent trigger counts."env_changes"part.Tests
tst/unit/task_step/verifiers/test_run_context.py, 36 tests against the real local conversation and object stores: the loader and the turn placement, each message'senv_clock, the gateway fold (queued detects, a time trigger's several fires, failures), the turn cut and the next-prompt cut (and a shared conversation, cut per prompt or withheld), missing records raising (the conversation, an env log, an unknown env, an unparseable log, a user-agent log that wasn't read back, an env or user-agent log that lost events to its cap), the judge prompt sections, size caps and option validation, and which scripts getrun(arunparameter does; one, two-with-another-name don't).test_verifier_run_context_local.pyruns a real two-turn conversation, with the echo agent as both solver and user sim, and reads it back as aRun.ruff, plugin-api (no break against the merge base) anduv lock --checkpass; the local integration test passes.🤖 Generated with Claude Code


Confidence Score: 5/5
The PR appears safe to merge; both numbered findings are addressed and no new blocking issue was found.Summary
Adds opt-in conversation and trigger evidence for verifiers through a typed
Run.Diagram
%%{init: {'theme': 'neutral'}}%% flowchart LR C[Stored conversation] --> L[load_run_context] E[Environment firing logs] --> L U[User-agent firing log] --> L L --> R[Run] R --> S[Opt-in verifier script] R --> A[as_of selected turn] A --> J[Requested judge sections]Reviews (7) · Last reviewed commit: "review: a firing log that lost events to..." · Reviewed by Greptile