Skip to content

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
mainfrom
sarahchitty/prompt-advance-to-next-event
Open

sarah-chitty-1 wants to merge 9 commits into
mainfrom
sarahchitty/prompt-advance-to-next-event

Conversation

@sarah-chitty-1

@sarah-chitty-1 sarah-chitty-1 commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

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:

  • an assistant that watches a person's mail and calendar for a week
  • an operator that works a booking queue over a weekend
  • an on-call or monitoring agent

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:

  • The gateway clock only moves at a rate. Five simulated days cost either real waiting, or a rate so high that the agent's own thinking time burns hours of simulated time.
  • Nothing waits for the next event. Between turns, prompt_agent asks the user side straight away, and it has nothing to say until the next event lands.

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 instant
  • fire_timeout_seconds: default 60

After each agent turn:

  1. Read /triggers/state on every env that offers triggers state and has an armed clock.
  2. Take the earliest next_mark of an armed time trigger. Bring every armed env clock that is behind it up to it, keeping the rate.
  3. Wait until each trigger due at that mark has fired, failed or been cancelled. One that hasn't settled by fire_timeout_seconds is recorded as timeout, and the run goes on.
  4. Wake the agent.
    • wake: "message" sends the wake message.
    • wake: "decide" runs the user simulator's agent-trigger decide step, 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.
  5. Stop when no time trigger is pending, or the next mark is after end_time; in both cases the clocks move to end_time when one is set. max_conversation_turns still 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, or no_armed_env_clock).

PUT /clock/advance {virtual_time} (gateway, clock/v1 method advance): a forward-only jump on the current arm. t0, the rate and the clock generation stay the same, so time triggers keep their marks:

  • a relative at still means "relative to the start of the run"
  • every keeps its phase
  • Clock.elapsed() counts the jumped time, so action delays and executor hours wait it out

It refuses to move backwards, or when the clock isn't armed (HTTP 400).

Simulated time on chat turns: with between_turns set, or with the new record_env_clock: true, every conversation message prompt_agent records carries env_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 carry virtual_time. conversation_store.add_a2a_task and complete_a2a_task take the optional env_clock.

Re-anchoring, documented: set_time is a re-arm. Every resolved time trigger that isn't event-anchored re-anchors to the new t0:

  • a relative at (e.g. PT3H) moves to new t0 + 3h
  • an every restarts from the new t0 and drops its backlog

A run that jumps by re-arming therefore chases a relative mark forever. advance is the jump that doesn't re-anchor. Against a gateway that has no advance (an older image), prompt_agent falls back to set_time at 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:

  • Construction: the new keyword-only parameters default to None and False, and are validated only when set.
  • Stored documents: to_dict() writes between_turns and record_env_clock only when they are set (the same pattern as live_trajectory), so a stored step's document is unchanged.
  • Loop:
    • The new branches run only when between_turns is set.
    • The existing agent-trigger decide path has one added condition, advance is None or wake == "decide", which is always true when unset.
    • The human-default URL is still resolved for multi-turn runs without the option.
  • Conversation store: add_a2a_task and complete_a2a_task are called with exactly the same arguments; env_clock is passed only when recording. With env_clock=None the stored message is unchanged.
    • Consumers, by grep: only prompt_agent calls these two writers in src/. The tests that patch them accept **kw.
  • Clock: _ahead stays 0 unless advance is called, so set_time, clear, read, state and elapsed are unchanged.
  • Env card: clock/v1 gains one method entry, advance. Clients look methods up by name (find_extension_method); nothing in src/ or tst/ lists them exactly, and the card tests pass.
  • plugin-api: no break to the plugin surface (check_plugin_api.py --base origin/main).

Tests

  • Unit, tst/unit/env/gateway/clock_advance_test.py (16):
    • advance keeps t0, rate and generation; holds on a frozen clock; counts in elapsed(); refuses backwards and unarmed
    • a re-arm after an advance banks from the advanced time; clear drops it
    • a relative at comes due after an advance with no reanchored event
    • an action delay counts the jumped time
    • the card method, and the route returning 200 or 400
  • Unit, tst/unit/task_step/test_prompt_agent_between_turns.py (32):
    • the unset step stores nothing new; the option round-trips as given; every bad shape is refused at construction
    • two events days apart wake the agent once each, then the run ends, and the human peer is never resolved
    • every message carries the env clock
    • end_time stops the run and moves the clock there; max_conversation_turns still bounds the run
    • triggers due at one mark share one wake
    • an older gateway is re-armed at its own rate, and refuses to re-arm over a relative mark, including on the jump to end_time
    • failed and stuck triggers are reported
    • wake: "decide" lets agent triggers speak and falls back to the wake message, and is refused without registered agent triggers
    • record_env_clock on and off
    • a run with no armed env clock ends after its first turn
  • Integration, tst/integration/task_step/test_prompt_agent_between_turns_local.py (slow tier, local defaults):
    • Setup: a gateway env whose frozen clock starts on a Monday; two time triggers, one absolute (Tuesday) and one relative (P2DT14H15M, Thursday), that write into the in-repo items server; the echo agent prompted with between_turns {mode: next_env_event, end_time: Friday}.
    • It asserts:
      • the agent gets exactly the prompt plus one wake per event
      • each trigger fired once, at its mark, with no re-anchoring
      • both writes landed in the items server
      • every message carries the env clock (Mon, Mon, Tue, Tue, Thu, Thu)
      • the clock ends at Friday with its t0 still Monday
      • four simulated days took seconds of wall time
  • Ran:
    • ruff check: clean
    • the full unit suite (tst/unit and packages/agentenv-protocol/tests): 9361 passed, 21 skipped, and 1 failed. The failure is smol_vm_provider_test.py::test_vm_host_entry_updates_without_dropping_other_guest_names, which runs GNU sed -i and fails against macOS's BSD sed on this machine
    • check_plugin_api.py: no break
    • uv lock --check: ok
    • the new integration test, plus the existing local trigger, clock and prompt_agent integration tests: test_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.py with test_prompt_agent_file_parts_local.py (4 passed), and in gateway_test.py the clock_extension, clock_rate_accuracy, time_trigger_extension, autonomous_time_trigger and sync_env_clock_step tests (5 passed)
    • This touches the virtual clock. I did not run the external-source clock-consumer tests (AGENT_ENV_TEST_MCP_SERVERS_DIR). The read contract they check (GET /clock/time, /clock/state) is unchanged, and /clock/time follows an advance like any other read; a maintainer with those sources may want to run them

Not in scope, and limits

  • An event that comes due during an agent turn (a running clock) gets no wake of its own. It has fired by the next jump, and the next wake ("check for anything new") covers it.
  • Recurring triggers never run out, so bound such a run with end_time or max_conversation_turns.
  • Several envs are advanced together, as one timeline.
  • There is no agent-requested wake-up yet (an agent asking to be woken at a time it chooses). It would be a natural follow-up: a gateway tool that registers a time trigger for the agent's own role.

Docs (for the docs site)

  • prompt_agent between_turns (the modes, keys, stop conditions and the metadata it writes) and record_env_clock (the message env_clock field)
  • clock/v1 advance
  • the rule that set_time re-anchors relative and recurring time triggers while advance doesn't

🤖 Generated with Claude Code

RetriggerView in GreptileConfidence 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.

  • The agent wakes when the next simulated-time event is due.
  • The gateway clock can jump forward without resetting its start.
  • Conversation messages can include their environment’s virtual time.

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]
Loading

Reviews (5) · Last reviewed commit: "review: the jump to end_time refuses a r..." · Reviewed by Greptile

…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>
@sarah-chitty-1
sarah-chitty-1 requested a review from a team as a code owner October 20, 2026 16:34
Comment thread src/agent_env/task_step/task_steps/prompt_agent.py Outdated
Comment thread src/agent_env/task_step/task_steps/prompt_agent.py Outdated
Comment thread src/agent_env/task_step/task_steps/prompt_agent.py Outdated
Comment thread src/agent_env/task_step/task_steps/prompt_agent.py
Comment thread src/agent_env/env/gateway/gateway.py
sarah-chitty-1 and others added 5 commits October 20, 2026 09:42
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>
Comment thread src/agent_env/task_step/task_steps/prompt_agent.py Outdated
…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>
Comment thread src/agent_env/task_step/task_steps/prompt_agent.py Outdated
…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

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant