Repository navigation
feat(prompt_agent): post-turn hooks, and an example hook that runs a conversation from env event to env event on simulated time - #265
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>
|
@sarah-chitty-1 thanks for this, the use case is a good one. We went through the PR in detail and would like to reshape it. Keep the clock primitive, and move the event-to-event logic out of Two problems in the current versionThese were reproduced against a real
Target design1.
|
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… stored message carries the env clock Hooks are Python FileArtifacts defining `async def post_turn(turn, params)`, run in order after every agent turn; the first to return a message or an end decides, else the user side is asked as today, which moved into _UserSide unchanged. test_prompt_agent_user_side.py pins that path and passes unchanged. Agent triggers/v1 decide takes record_unfired, so user_triggers() can ask again in the same turn without resetting once-only triggers. The verifier run context reads env_clock as one EnvClock per armed env. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t_event_at and resumes_at; /step tags a hook's calls PUT /clock/advance runs the trigger time check at once and returns `due`, waking actions that wait out a delay. /triggers/state reports next_event_at, the earliest time the engine acts on its own, and each trigger's resumes_at (triggers/v1 feature next_event_at). /step takes source post_turn_hook with hook_artifact_id (step/v1 feature source). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…om env event to env event The hook waits for what the agent's turn set off, lets a firing agent trigger answer, advances each clock to the earliest next_event_at, waits for it to settle, then asks the agent triggers again or wakes the agent. It ends when nothing is pending or past end_time, and on a gateway without advance. It ships as a bundle with an inbox env and a week-long task; the slow test runs it against the echo agent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both sides add a fourth shipped bundle, mail-mcp and next-env-event: the entry points and the shipped-bundle lists keep both, and the installer journey expects 5 bundles. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…so a change to it fails the test Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@earakely-scale thanks, this was a much better shape. I reworked the PR to it, and the description is updated. In your review order:
On your open questions:
Two other things that go beyond the spec are in "Worth a look":
|
…e next opening _await_hours reckoned the opening again only when the clock's generation changed, and advance keeps it, so a jump past the close left a stale opening behind and the executor ran outside its hours. It now reckons the opening on every pass, and clock_advanced counts a wait as ended only while its window is open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolves the bundle-list conflicts in three tests: main added the drivers bundle, this branch next-env-event; keep both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ler expects six bundles - /triggers/state shows each trigger's `evaluating`, and the next-env-event hook waits for a state check the agent's turn set off before it moves the clock - test_container_journey: next-env-event makes six shipped bundles Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…aclasses and postponed annotations work exec_module doesn't put the module in sys.modules, and dataclasses looks it up there. The name gets a random suffix (concurrent runs may share a step id) and close removes it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What
Some evals run an agent through days of simulated time and grade how it reacts to each event. This PR lets one conversation go from event to event: each turn takes real seconds, and the time between events is skipped. It follows the design from review. The gateway owns the clock jump and knows when its triggers next act. The event-to-event logic is a hook script that a task references.
prompt_agent.post_turn_hooks(opt-in) is a list of Python FileArtifacts, each definingasync def post_turn(turn, params).{"message": ...}or{"end": reason}decides the turn. If none does, the user side is asked as today.turncarries the turn's number, whether it is the last, the agent's reply, the seed, a read-only copy of the metadata with secret keys stripped, and the envs. Each env hassupports,invoke(clock/v1 and triggers/v1) andcall_tool.turnalso offersask_user(),user_triggers(),record()andheartbeat().PUT /clock/advancejumps the clock forward on its current arm and runs the trigger time check at once. It returnsdue: the triggers the jump reached, plus those whose wait it ended. Waiting actions wake straight away./triggers/stategainsnext_event_at, the earliest simulated time the engine acts on its own: a time trigger's mark, a delayed action resuming, or an executor's hours opening. Each trigger also gainsresumes_at. Both sit behind the triggers/v1 featurenext_event_at./steptakessource: "post_turn_hook"with ahook_artifact_id, so the trajectory records a hook's tool calls as the hook's (step/v1 featuresource).decide(protocol package) takes an optionalrecord_unfired(defaulttrue). Withfalse, a decision where nothing fires changes no state, souser_triggers()can be asked again later in the same turn.env_clockis on every stored conversation message, hook messages included. It holds one entry per armed env clock (env_id,env_step_id,virtual_time) and is absent when no clock is armed.next-env-eventexample bundle (src/agent_env/examples/next-env-event/) holds the hook, a small inbox env, and a week-long task that uses them. After each turn the hook:next_event_at, and ends atend_timeor when nothing is pending;between_turns,record_env_clockand theset_timefallback are gone.Worth a look
_UserSideunchanged.test_prompt_agent_user_side.py, which lands onmainfirst in test(prompt_agent): pin down a multi-turn conversation's user side as it runs today #331, records every request and store write for each way a turn can end. It passes here unchanged.state, prompt_agent now reads/clock/statewhen it records a message (two GETs a turn, a 5 s timeout each). A failed read leaves that env out of the stamp. A test pins that this, and theenv_clockfield when a clock is armed, are the only differences.decidefor the same turn as a restart: it clears what fired, so once-only triggers fire again, and it logs areset.user_triggers()therefore sendsrecord_unfired: false. A call where nothing fires leaves no trace and can be repeated. Once the user side answers, later calls that turn return the same answer, and a hook that asked must return it, or the step fails.user_triggers(), naming the fix, instead of silently re-firing its once-only triggers.ask_user()and the no-hook path send today'sdecideunchanged.Message.env_clocknow reads the list shape, as a tuple ofEnvClock(env_id, env_step_id, virtual_time), and is stillNonefor a message stored without a clock. Nothing onmainwritesenv_clockyet. plugin-api: no break.next_event_at:everyis read from a copy of its RNG.until, an exhaustedcount, anaftertrigger whose anchor hasn't fired, and the next mark of a trigger still mid-fire don't count. A waiting fire'sresumes_atstands in for that last one.TriggerEngine._nap). So an advance wakes it at once, instead of up to 5 s later.due. It waits until no trigger on the env is still firing, bar one waiting for a later time, both before reading the next event and after the jump. That way a reply the agent's own last turn set off lands before the clock moves.advance, instead of waiting for the 1 s poller.env_outcome_verifierscript. The/stepsourcetag is self-declared, with the same trust as theAgentEnv-Roleheader.turn.lastbefore asking the user anything.main's newmail-mcp. The bundle-count tests and the installer journey's expected row (now "5 bundles") are updated. The example's task needs Docker and a model endpoint. CI checks that the bundle parses and plans, and the slow tier runs its hook against the echo agent. A bundle needs a task to pass the shipped-bundle check, which is why the inbox env and the task are there.advance,next_event_atand the step tag. On an older one the hook logs a warning naming the env and ends without jumping, andcall_toolrefuses, naming the env.Tests
tst/unit/env/gateway/next_event_test.py(20):next_event_atbefore and after evaluation, recurring and stochastic marks, re-arm,until/count,after, a mid-fire recurrence, and fired or unarmed;resumes_atfor a time-trigger delay, an action delay and executor hours; what an advance makes due, and waking a delayed action at once; the/steptag and its refusals; and the card features.tst/unit/task_step/test_prompt_agent_post_turn_hooks.py(38): the field, its validation and entity refs, and a bundle carrying the hook artifact; loading; each decision, including order, the user-side fallback, the lazy human peer, bad returns and the last turn; the ask-once rule, including the real agent trigger engine behind/decide(a once-only trigger fires once however often a hook asks), refusing a user agent withoutrecord_unfired, and two concurrent asks asking once; whatturnholds;call_toolthrough/stepand its refusals; andenv_clock, including that without hooks a clock-capable env only adds clock reads.tst/unit/task_step/test_next_env_event_hook.py(17): the hook turn by turn, against an in-process gateway with a realClockandTriggerEngine. It includes both cases from review: thePT2Haction lands at Tue 11:00 with a wake of its own, even with no later event, and an agent trigger's reply goes first with no jump. It also covers a recurrence whose delayed action outlasts its interval, a fire the agent's turn set off landing before the jump,end_timeas an instant and as a duration, a custom nudge, no armed clock, an older gateway, and bad params.packages/agentenv-protocol/tests/test_a2a_agent.py: a decision where nothing fires can leave no trace.tst/unit/task_step/test_prompt_agent_user_side.py(17), unchanged from test(prompt_agent): pin down a multi-turn conversation's user side as it runs today #331.clock_advance_test.py,test_run_context.py,shipped_bundles_test.py,installed_test.py,test_clean_install_wheel.py, the installer journey and three agents' card-contract tests were updated.tst/integration/task_step/test_next_env_event_hook_local.py(2) runs on a gateway built from this branch, with the items env and the echo agent.PT2Haction lands at its mark plus two hours, with its own wake, before the next event.ruff check: clean.check_plugin_api.py --base origin/main: no break.uv lock --check: ok.tst/unit,packages/agentenv-protocol/tests): 10535 passed, 2 failed. Both failures are intest_mcp_cli_builder.py: the scripts it generates run this machine's systempython3, which has noclick. They fail onmaintoo.test_register_env_triggers_local.py(5: delays and hours, deadlines, named executors, personas, the start-of-run await).test_prompt_agent_live_trajectory_local.py(1) andtest_prompt_agent_file_parts_local.py(3).gateway_test.py(13): triggers, barrier, re-arm after a failed mirror, fail-loud registration, queued-not-settled, website trigger, clock, clock rate, time trigger, autonomous time trigger,sync_env_clock, single MCP server, and changes-name-the-role.[modal]and[modal_vm]variants ran because this machine has Modal credentials, then failed on Modal itself: the environment is over its spend limit, and Modal can't pull images from a local registry.test_gateway_triggers_nl_executorruns only onmodal_vm, so it didn't run.AGENT_ENV_TEST_MCP_SERVERS_DIR). The read contract those check (GET /clock/time,/clock/state) is unchanged.Docs (for the docs site)
post_turn_hooks: the hook contract, theturnAPI, how a turn is decided, the ask-once rule, the trust model, and that in a hook run the LLM user sim sees only the replies sent throughask_user().advanceand itsdue; triggers/v1next_event_atandresumes_at; step/v1source; agent triggers/v1decide'srecord_unfired.env_clockfield on conversation messages.next-env-eventexample bundle and its params.🤖 Generated with Claude Code


Confidence Score: 5/5
The latest changes appear safe to merge.Summary
This PR adds post-turn hooks, clock jumps, and an example that moves a conversation between simulated events.
Reviews (13) · Last reviewed commit: "fix(post-turn-hooks): register a hook's ..." · Reviewed by Greptile