Repository navigation
feat(prompt_agent): run a multi-turn conversation from one env event to the next on simulated time - #265
Open
sarah-chitty-1 wants to merge 9 commits into
Open
feat(prompt_agent): run a multi-turn conversation from one env event to the next on simulated time#265sarah-chitty-1 wants to merge 9 commits into
sarah-chitty-1 wants to merge 9 commits into
Conversation
…to the next on simulated time prompt_agent's opt-in between_turns: next_env_event makes a multi-turn run event-driven, for long-running and background-agent evals whose events are hours or days apart. After each agent turn it reads the envs' trigger state, brings the armed env clocks up to the earliest pending time-trigger mark, waits for the triggers due there to fire, fail or be cancelled (with a timeout), and wakes the agent with a neutral message or the user simulator's agent-trigger decide step. It stops when no time trigger is pending, at end_time, or at max_conversation_turns. The user side is never asked. The gateway clock gains PUT /clock/advance (clock/v1 "advance"): a forward jump on the current arm that keeps t0, rate and generation, so relative and recurring time-trigger marks keep their meaning and delays count the jumped time. set_time is a re-arm that re-anchors them; against a gateway without advance, prompt_agent re-arms at the env's own rate and refuses a run whose pending time triggers are relative or recurring. With between_turns set, or record_env_clock, every conversation message prompt_agent records carries env_clock, each env's virtual time when it was sent or received, so a grader can place the agent's messages on the same axis as the trigger firing log and the gateway trajectory. Both options are off by default; unset, prompt_agent, the conversation store and the clock behave as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two envs can each have a trigger with the same id due at one mark; keyed by id
alone, the second was skipped and the wait ran to its timeout. Outcomes are now
keyed by (env id, trigger id) and recorded as {env_id: {trigger_id: outcome}}.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dvance lands is not an error With a running clock the mark can pass between the clock read and the advance, which the gateway then refuses as backwards. On that 400 the clock is read again, and if it has reached the mark the move is recorded as "passed"; any other refusal still fails the step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…next mark in the schedule A recurring trigger whose fire outlived the wait (an action delay on a frozen clock, say) stays firing with its next mark already stamped; only armed ones were scheduled, so the run could end with "no_pending_time_triggers" despite future marks. Firing and queued triggers with a next mark are now scheduled too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… with carries the env clock too Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ect with 400, not a server error Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gain A recurring trigger whose fire outlives its interval on a frozen clock stays firing with the same next mark, so every later turn chose that mark again, waited out the timeout and woke the agent without moving time. Marks that timed out are now skipped, so the run moves on to the next event or end_time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rring mark, as every other jump does Against a gateway without clock advance, stopping at end_time re-armed the clock unchecked, re-anchoring a pending relative or recurring time trigger past end_time for the steps after it. That jump now runs the same check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Some evals run an agent in the background for a long stretch of simulated time and grade how it reacts as things happen. Examples:
What matters there is whether each event got the right reaction at the right time: too early, too late, or not at all.
Today two things get in the way:
This PR lets one multi-turn run go from event to event. Each turn takes real seconds, while the time between events is skipped. Nothing in it is specific to a domain.
What
prompt_agent.between_turns(opt-in):"next_env_event", or an object with these keys:mode:"next_env_event"wake:"message"(default) or"decide"wake_message: may use{virtual_time}and{trigger_ids}. The default is neutral and names no event.end_time: an RFC 3339 instantfire_timeout_seconds: default 60After each agent turn:
/triggers/stateon every env that offers triggers state and has an armed clock.next_markof an armed time trigger. Bring every armed env clock that is behind it up to it, keeping the rate.fire_timeout_secondsis recorded astimeout, and the run goes on.wake: "message"sends the wake message.wake: "decide"runs the user simulator's agent-triggerdecidestep, which already exists, against the fresh env trigger snapshot. If no agent trigger fires, it falls back to the wake message. If one ends the conversation, the conversation ends.end_time; in both cases the clocks move toend_timewhen one is set.max_conversation_turnsstill bounds the run.The user side is never asked in this mode, and no human peer URL is resolved. Each step records its advances in
context.metadata["env_event_advances"][step_id]: one entry per turn with the mark, the trigger ids, their outcomes and how each clock moved, then the stop reason (no_pending_time_triggers,end_time, orno_armed_env_clock).PUT /clock/advance {virtual_time}(gateway, clock/v1 methodadvance): a forward-only jump on the current arm.t0, the rate and the clock generation stay the same, so time triggers keep their marks:atstill means "relative to the start of the run"everykeeps its phaseClock.elapsed()counts the jumped time, so action delays and executor hours wait it outIt refuses to move backwards, or when the clock isn't armed (HTTP 400).
Simulated time on chat turns: with
between_turnsset, or with the newrecord_env_clock: true, every conversation message prompt_agent records carriesenv_clock: {env_id: virtual_time | null}, the env clocks when it was sent or received. A grader can then line up the agent's messages with the trigger firing log and the gateway trajectory, which already carryvirtual_time.conversation_store.add_a2a_taskandcomplete_a2a_tasktake the optionalenv_clock.Re-anchoring, documented:
set_timeis a re-arm. Every resolved time trigger that isn't event-anchored re-anchors to the newt0:at(e.g.PT3H) moves to new t0 + 3heveryrestarts from the new t0 and drops its backlogA run that jumps by re-arming therefore chases a relative mark forever.
advanceis the jump that doesn't re-anchor. Against a gateway that has noadvance(an older image), prompt_agent falls back toset_timeat the env's own rate. Before that first jump it refuses to start, naming the trigger ids, if any pending time trigger is relative or recurring.Backward compatibility
With both options unset, prompt_agent behaves exactly as today:
NoneandFalse, and are validated only when set.to_dict()writesbetween_turnsandrecord_env_clockonly when they are set (the same pattern aslive_trajectory), so a stored step's document is unchanged.between_turnsis set.decidepath has one added condition,advance is None or wake == "decide", which is always true when unset.add_a2a_taskandcomplete_a2a_taskare called with exactly the same arguments;env_clockis passed only when recording. Withenv_clock=Nonethe stored message is unchanged.src/. The tests that patch them accept**kw._aheadstays 0 unlessadvanceis called, soset_time,clear,read,stateandelapsedare unchanged.advance. Clients look methods up by name (find_extension_method); nothing insrc/ortst/lists them exactly, and the card tests pass.check_plugin_api.py --base origin/main).Tests
tst/unit/env/gateway/clock_advance_test.py(16):elapsed(); refuses backwards and unarmedcleardrops itatcomes due after an advance with noreanchoredeventtst/unit/task_step/test_prompt_agent_between_turns.py(32):end_timestops the run and moves the clock there;max_conversation_turnsstill bounds the runend_timewake: "decide"lets agent triggers speak and falls back to the wake message, and is refused without registered agent triggersrecord_env_clockon and offtst/integration/task_step/test_prompt_agent_between_turns_local.py(slow tier, local defaults):P2DT14H15M, Thursday), that write into the in-repo items server; the echo agent prompted withbetween_turns {mode: next_env_event, end_time: Friday}.ruff check: cleantst/unitandpackages/agentenv-protocol/tests): 9361 passed, 21 skipped, and 1 failed. The failure issmol_vm_provider_test.py::test_vm_host_entry_updates_without_dropping_other_guest_names, which runs GNUsed -iand fails against macOS's BSD sed on this machinecheck_plugin_api.py: no breakuv lock --check: oktest_prompt_agent_between_turns_local.py(1 passed),test_register_env_triggers_local.py(5 passed: delays, executor hours, deadlines and the start-of-run await, on a gateway built from this branch),test_prompt_agent_live_trajectory_local.pywithtest_prompt_agent_file_parts_local.py(4 passed), and ingateway_test.pytheclock_extension,clock_rate_accuracy,time_trigger_extension,autonomous_time_triggerandsync_env_clock_steptests (5 passed)AGENT_ENV_TEST_MCP_SERVERS_DIR). The read contract they check (GET /clock/time,/clock/state) is unchanged, and/clock/timefollows an advance like any other read; a maintainer with those sources may want to run themNot in scope, and limits
end_timeormax_conversation_turns.Docs (for the docs site)
between_turns(the modes, keys, stop conditions and the metadata it writes) andrecord_env_clock(the messageenv_clockfield)advanceset_timere-anchors relative and recurring time triggers whileadvancedoesn't🤖 Generated with Claude Code


Confidence Score: 5/5
The PR appears safe to merge; the previously reported final-jump issue is fixed.Summary
Adds event-to-event conversations on simulated time, forward-only clock jumps, and optional clock timestamps on stored messages.
Diagram
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Agent finishes a turn] --> B[Read clocks and pending triggers] B --> C{Next event within end time?} C -->|Yes| D[Advance clocks and wait for triggers] D --> E[Send wake message or run decide] E --> A C -->|No| F[Safely move clocks to end time if set] F --> G[Close conversation]Reviews (5) · Last reviewed commit: "review: the jump to end_time refuses a r..." · Reviewed by Greptile