From 1ed48117d2a8b7084865b7766cec429edededdbe Mon Sep 17 00:00:00 2001 From: Sarah Chitty Date: Mon, 19 Oct 2026 16:37:51 -0700 Subject: [PATCH 01/15] feat(prompt_agent): run a multi-turn conversation from one env event 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 --- src/agent_env/a2a_agent/conversation_store.py | 14 +- src/agent_env/env/gateway/clock.py | 27 +- src/agent_env/env/gateway/constants.py | 3 +- src/agent_env/env/gateway/gateway.py | 12 + .../task_step/task_steps/prompt_agent.py | 188 ++++++++++- .../test_prompt_agent_between_turns_local.py | 84 +++++ tst/unit/env/gateway/clock_advance_test.py | 164 ++++++++++ .../test_prompt_agent_between_turns.py | 305 ++++++++++++++++++ 8 files changed, 788 insertions(+), 9 deletions(-) create mode 100644 tst/integration/task_step/test_prompt_agent_between_turns_local.py create mode 100644 tst/unit/env/gateway/clock_advance_test.py create mode 100644 tst/unit/task_step/test_prompt_agent_between_turns.py diff --git a/src/agent_env/a2a_agent/conversation_store.py b/src/agent_env/a2a_agent/conversation_store.py index d9ad7afb2..7f4781dfa 100644 --- a/src/agent_env/a2a_agent/conversation_store.py +++ b/src/agent_env/a2a_agent/conversation_store.py @@ -109,11 +109,20 @@ def find_by_a2a_task_id(a2a_task_id: str) -> Optional[dict]: ) +def _message(role: str, parts: list[dict], now: datetime, env_clock: Optional[dict]) -> dict: + """A conversation message; ``env_clock`` maps each env to its virtual time when the message was recorded.""" + message = {"role": role, "parts": parts, "ts": now.isoformat()} + if env_clock is not None: + message["env_clock"] = env_clock + return message + + def add_a2a_task( conversation_id: str, parts: list[dict], a2a_task_id: str, role: str, + env_clock: Optional[dict] = None, ) -> Optional[dict]: """Append a caller-side message and create a new working a2a_task. @@ -133,7 +142,7 @@ def add_a2a_task( doesn't exist. """ now = datetime.now(timezone.utc) - new_message = {"role": role, "parts": parts, "ts": now.isoformat()} + new_message = _message(role, parts, now, env_clock) def _mutate(doc: dict) -> UpdateSpec: idx = len(doc.get("messages", [])) @@ -161,6 +170,7 @@ def complete_a2a_task( conversation_id: str, parts: list[dict], role: str, + env_clock: Optional[dict] = None, ) -> Optional[dict]: """Append a responder-side message and complete the oldest working a2a_task. @@ -185,7 +195,7 @@ def complete_a2a_task( doesn't exist (or the target task lost the state race). """ now = datetime.now(timezone.utc) - new_message = {"role": role, "parts": parts, "ts": now.isoformat()} + new_message = _message(role, parts, now, env_clock) # One backend for the whole retry loop: a reset between the read and the CAS would # decide against one database and write to another. docs = _doc_store() diff --git a/src/agent_env/env/gateway/clock.py b/src/agent_env/env/gateway/clock.py index 44913026f..2b2118adf 100644 --- a/src/agent_env/env/gateway/clock.py +++ b/src/agent_env/env/gateway/clock.py @@ -123,6 +123,7 @@ def __init__(self, now_fn=time.monotonic): self._rate = 1.0 self._generation = 0 self._lived = 0.0 # virtual seconds run before the current arm/clear + self._ahead = 0.0 # virtual seconds the current arm was advanced by @property def armed(self) -> bool: @@ -153,10 +154,27 @@ def set_time(self, virtual_time: str, rate=1.0) -> dict: self._bank(t0) self._t0 = t0 self._rate = r + self._ahead = 0.0 self._armed = True self._generation += 1 return self.state() + def advance(self, virtual_time: str) -> dict: + """Jump forward to virtual_time on the current arm: t0, rate and generation stay, so time triggers keep their marks.""" + target = _parse_rfc3339(virtual_time) + with self._lock: + if not self._armed or self._t0 is None: + raise ClockError("clock is not armed") + if target > _MAX_T0 - timedelta(seconds=_OVERFLOW_HORIZON_S * self._rate): + raise ClockError(f"virtual_time {virtual_time!r} at rate {self._rate:g} would overflow datetime near the max") + current = _advance(self._t0, (self._now() - self._anchor) * self._rate + self._ahead) + jump = (target - current).total_seconds() + if jump < 0: + raise ClockError(f"advance only moves forward: {virtual_time!r} is before the current {_iso(current)}") + self._ahead += jump + self._lived += jump + return self.state() + def clear(self) -> dict: """Disarm (idempotent). Afterwards reads 404.""" with self._lock: @@ -164,12 +182,13 @@ def clear(self) -> dict: self._armed = False self._t0 = None self._rate = 1.0 + self._ahead = 0.0 self._generation += 1 return {"ok": True, "armed": False} def elapsed(self) -> float: """Virtual seconds run so far: real time at each arm's rate (1 while unarmed), plus the jump of each re-arm - forward; one back counts nothing.""" + or advance forward; one back counts nothing.""" with self._lock: return self._lived + (self._now() - self._anchor) * self._rate @@ -178,7 +197,7 @@ def now(self) -> datetime | None: if not self._armed or self._t0 is None: return None elapsed = self._now() - self._anchor - return _advance(self._t0, elapsed * self._rate) + return _advance(self._t0, elapsed * self._rate + self._ahead) def read(self) -> dict: """The frozen consumer contract: {"virtual_time": ""} — pure read, never advances.""" @@ -193,7 +212,7 @@ def state(self) -> dict: return {"armed": False} elapsed = self._now() - self._anchor return {"armed": True, "t0": _iso(self._t0), "virtual_seconds_per_real_second": self._rate, - "virtual_time": _iso(_advance(self._t0, elapsed * self._rate))} + "virtual_time": _iso(_advance(self._t0, elapsed * self._rate + self._ahead))} def _bank(self, t0: datetime | None = None) -> None: """Fold the stretch since the last arm/clear into `_lived` at its rate, and start the next one now.""" @@ -201,5 +220,5 @@ def _bank(self, t0: datetime | None = None) -> None: ran = (now - self._anchor) * self._rate self._lived += ran if t0 is not None and self._armed: # a re-arm forward is time the world lived through; one back adds nothing - self._lived += max(0.0, (t0 - _advance(self._t0, ran)).total_seconds()) + self._lived += max(0.0, (t0 - _advance(self._t0, ran + self._ahead)).total_seconds()) self._anchor = now diff --git a/src/agent_env/env/gateway/constants.py b/src/agent_env/env/gateway/constants.py index 6354dcd15..6c70de345 100644 --- a/src/agent_env/env/gateway/constants.py +++ b/src/agent_env/env/gateway/constants.py @@ -190,10 +190,11 @@ def random_mcp_server_name() -> str: }, { "uri": EXT_CLOCK_URI, - "description": "Virtual clock (urn:agentenv:clock/v1): read the gateway's virtual time (wall-clock-derived, rate-scaled) via get_time; set-time/clear are the harness control path.", + "description": "Virtual clock (urn:agentenv:clock/v1): read the gateway's virtual time (wall-clock-derived, rate-scaled) via get_time; set-time/advance/clear are the harness control path.", "params": {"endpoint": "/clock/time", "methods": { "get_time": {"method": "GET", "endpoint": "/clock/time"}, "set_time": {"method": "PUT", "endpoint": "/clock/set-time", "request": {"type": "object", "properties": {"virtual_time": {"type": "string"}, "virtual_seconds_per_real_second": {"type": "number"}}, "required": ["virtual_time"]}}, + "advance": {"method": "PUT", "endpoint": "/clock/advance", "request": {"type": "object", "properties": {"virtual_time": {"type": "string"}}, "required": ["virtual_time"]}}, "clear": {"method": "POST", "endpoint": "/clock/clear", "request": {"type": "object", "properties": {}}}, "state": {"method": "GET", "endpoint": "/clock/state", "request": {"type": "object", "properties": {}}}}}, "required": False, diff --git a/src/agent_env/env/gateway/gateway.py b/src/agent_env/env/gateway/gateway.py index 43c4410a9..d3c8b19a1 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -269,6 +269,18 @@ async def clock_set_time(request: Request) -> Response: self._register_get_time_tool() return self._json_response(state, 200) + @self._mcp.custom_route("/clock/advance", methods=["PUT"]) + async def clock_advance(request: Request) -> Response: + try: + body = await request.json() + except Exception: + return self._json_response({"ok": False, "error": "Invalid JSON body"}, 400) + try: + state = self._clock.advance(body.get("virtual_time")) + except ClockError as e: + return self._json_response({"ok": False, "error": str(e)}, 400) + return self._json_response(state, 200) + @self._mcp.custom_route("/clock/clear", methods=["POST"]) async def clock_clear(request: Request) -> Response: self._unregister_get_time_tool() diff --git a/src/agent_env/task_step/task_steps/prompt_agent.py b/src/agent_env/task_step/task_steps/prompt_agent.py index 9d732679f..cd06db75c 100644 --- a/src/agent_env/task_step/task_steps/prompt_agent.py +++ b/src/agent_env/task_step/task_steps/prompt_agent.py @@ -5,6 +5,7 @@ import copy import json import logging +import math import time import uuid from contextlib import nullcontext @@ -115,6 +116,66 @@ def _file_uris(parts: list[dict]) -> list[str]: } +NEXT_ENV_EVENT = "next_env_event" +DEFAULT_WAKE_MESSAGE = "[{virtual_time}] Time has passed. Check for anything new and handle it." +_BETWEEN_TURNS_KEYS = ("mode", "wake", "wake_message", "end_time", "fire_timeout_seconds") +_WAKES = ("message", "decide") +_SETTLED_STATUSES = ("fired", "failed", "cancelled") +_FIRE_POLL_SECONDS = 0.5 + + +def _utc(text: str) -> datetime: + """An RFC 3339 instant as an aware UTC datetime; ValueError without an offset.""" + parsed = datetime.fromisoformat(text.replace("Z", "+00:00").replace("z", "+00:00")) + if parsed.tzinfo is None: + raise ValueError(f"{text!r} has no UTC offset") + return parsed.astimezone(timezone.utc) + + +def _iso(value: datetime) -> str: + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _between_turns_spec(raw: Any) -> Optional[dict]: + """``between_turns`` with its defaults filled in, refusing a shape the turn loop can't follow; None when unset.""" + if raw is None: + return None + spec = {"mode": raw} if isinstance(raw, str) else raw + if not isinstance(spec, dict): + raise ValueError(f"between_turns must be {NEXT_ENV_EVENT!r} or an object with a mode") + unknown = sorted(set(spec) - set(_BETWEEN_TURNS_KEYS)) + if unknown: + raise ValueError(f"between_turns has unknown key(s) {unknown}; expected {list(_BETWEEN_TURNS_KEYS)}") + if spec.get("mode") != NEXT_ENV_EVENT: + raise ValueError(f"between_turns.mode must be {NEXT_ENV_EVENT!r}, got {spec.get('mode')!r}") + out = {"mode": NEXT_ENV_EVENT, "wake": spec.get("wake", "message"), + "wake_message": spec.get("wake_message", DEFAULT_WAKE_MESSAGE), "end_time": spec.get("end_time"), + "fire_timeout_seconds": spec.get("fire_timeout_seconds", 60)} + if out["wake"] not in _WAKES: + raise ValueError(f"between_turns.wake must be one of {list(_WAKES)}, got {out['wake']!r}") + if not isinstance(out["wake_message"], str) or not out["wake_message"].strip(): + raise ValueError("between_turns.wake_message must be a non-empty string") + try: + out["wake_message"].format(virtual_time="", trigger_ids="") + except (KeyError, IndexError, ValueError) as e: + raise ValueError(f"between_turns.wake_message may use only {{virtual_time}} and {{trigger_ids}}: {e!r}") from e + if out["end_time"] is not None: + try: + _utc(out["end_time"]) + except (TypeError, AttributeError, ValueError) as e: + raise ValueError(f"between_turns.end_time must be an RFC 3339 instant: {e}") from e + timeout = out["fire_timeout_seconds"] + if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not math.isfinite(timeout) or timeout <= 0: + raise ValueError(f"between_turns.fire_timeout_seconds must be a positive number, got {timeout!r}") + return out + + +def _rearm_safe(when: dict) -> bool: + """Whether a time trigger keeps its mark across a clock re-arm: event-anchored, or one absolute `at`.""" + at = when.get("at") + return "after" in when or ("every" not in when and isinstance(at, str) and not at.startswith("P")) + + class PromptAgentTaskStep(TaskStep): type: ClassVar[str] = "prompt_agent" entity_refs = (EntityRef.env("snapshot_config.env_id"),) @@ -158,6 +219,8 @@ def __init__( *, snapshot_config: Optional[SnapshotConfig] = None, live_trajectory: bool = False, + between_turns: Optional[str | dict] = None, + record_env_clock: bool = False, ): # `prompt` is the legacy text-only field; `parts` is the multimodal # form that's the authoritative A2A wire shape. Exactly one must be set: @@ -171,6 +234,9 @@ def __init__( self._validate_parts(parts) if max_conversation_turns < 1: raise ValueError("max_conversation_turns must be >= 1") + advance = _between_turns_spec(between_turns) + if advance is not None and max_conversation_turns < 2: + raise ValueError("between_turns needs max_conversation_turns >= 2: it acts between turns") if model_params: reserved = set(model_params) & MODEL_PARAMS_RESERVED if reserved: @@ -201,6 +267,9 @@ def __init__( self.user_model = user_model self.snapshot_config = snapshot_config self.live_trajectory = live_trajectory + self.between_turns = between_turns + self.record_env_clock = record_env_clock + self._advance = advance @staticmethod def _validate_parts(parts: list[dict]) -> None: @@ -276,6 +345,10 @@ def to_dict(self) -> dict: base["snapshot_config"] = self.snapshot_config.to_dict() if self.live_trajectory: base["live_trajectory"] = True + if self.between_turns is not None: + base["between_turns"] = self.between_turns + if self.record_env_clock: + base["record_env_clock"] = True return base @classmethod @@ -312,6 +385,8 @@ def from_dict(cls, data: dict) -> PromptAgentTaskStep: else None ), live_trajectory=data.get("live_trajectory", False), + between_turns=data.get("between_turns"), + record_env_clock=data.get("record_env_clock", False), ) def _trajectory_prefix(self) -> str: @@ -418,7 +493,7 @@ async def _execute_conversation( user_url = self.user_a2a_url or ( (user_sim.a2a_url or user_sim.api_url) if user_sim else None ) - if user_url is None and user_sim is None and self.max_conversation_turns > 1: + if user_url is None and user_sim is None and self.max_conversation_turns > 1 and self._advance is None: user_url = get_config().get_default_human_a2a_url() trajectory_output_prefix = self._trajectory_prefix() @@ -481,6 +556,12 @@ async def _execute_conversation( logger.warning( f"user agent '{self.user_agent_name}' advertises {A2AAgent.EXT_TRIGGERS} but " f"max_conversation_turns={self.max_conversation_turns} (<2); the trigger engine is never invoked") + advance = self._advance + if advance is not None and advance["wake"] == "decide" and not (trig_ext and triggers_registered): + raise RuntimeError(f"{self.id}: between_turns wake 'decide' needs user agent '{self.user_agent_name}' to be a " + f"user simulator with agent triggers registered") + clock_envs = ([d for d in context.deployed_envs if d.supports(EXT_CLOCK_URI, "state")] + if advance is not None or self.record_env_clock else None) solver_context_id = a2a_context_id if self.context_id: @@ -529,6 +610,7 @@ async def _execute_conversation( # agent and recorded on the conversation as `a2a_task_id`. # This id is also used as part of the key name for the trajectory S3 object. target_a2a_task_id = uuid.uuid4().hex + sent_clock = await self._env_clock_kwargs(clock_envs) # The turn records the objects' own URLs, and only once the parts are ready to send, so an # object the agent can't be sent leaves no turn waiting. @@ -539,6 +621,7 @@ def record_turn() -> None: parts=current_user_parts, a2a_task_id=target_a2a_task_id, role="user", + **sent_clock, ) # Turn 0 of a prompt-mode step re-sends exactly the text already # persisted as PromptResponse.prompt_text; store a None placeholder @@ -571,6 +654,7 @@ def record_turn() -> None: conversation_id=conversation_id, parts=agent_response_parts, role="agent", + **(await self._env_clock_kwargs(clock_envs)), ) if traj_ext_cached: @@ -604,7 +688,16 @@ def record_turn() -> None: if series is not None: await series.capture_turn(context, turn + 1) - if trig_ext and triggers_registered: + if advance is not None: + event = await self._advance_to_next_env_event(context, advance) + (context.metadata.setdefault("env_event_advances", {}) + .setdefault(self.id, []).append({"turn": turn + 1, **event})) + if "stopped" in event: + logger.info(f"Turn {turn+1}: no further env event ({event['stopped']}); ending multi-turn") + conversation_store.mark_closed(conversation_id) + break + + if trig_ext and triggers_registered and (advance is None or advance["wake"] == "decide"): env_triggers = await self._read_env_triggers(context) (context.metadata.setdefault("env_trigger_snapshots", {}) .setdefault(self.id, []).append({"turn": turn + 1, "envs": env_triggers})) @@ -623,6 +716,12 @@ def record_turn() -> None: continue # no trigger fired -> fall through to the LLM user-sim for this turn (hybrid) + if advance is not None: + text = advance["wake_message"].format(virtual_time=event["mark"], + trigger_ids=", ".join(event["trigger_ids"])) + current_user_parts = [{"kind": "text", "text": text}] + continue + user_a2a_task_id = uuid.uuid4().hex try: # A user-sim runs in a sandbox agent-env deployed; a human peer, registered or named by @@ -845,6 +944,91 @@ async def _decide(self, decide_url: str, turn: int, solver_message: str, context raise RuntimeError(f"agent-trigger /decide failed (HTTP {resp.status_code}): {resp.text}") return resp.json() + async def _env_clock_kwargs(self, envs: Optional[list]) -> dict: + """``{"env_clock": {env_id: virtual_time or None}}`` for a conversation message, or {} when not recording.""" + if envs is None: + return {} + states = await asyncio.gather(*(self._get_clock_state(d) for d in envs)) + return {"env_clock": {d.env_id: (s or {}).get("virtual_time") if (s or {}).get("armed") else None + for d, s in zip(envs, states)}} + + async def _advance_to_next_env_event(self, context: TaskStepContext, advance: dict) -> dict: + """Move armed env clocks to the earliest pending time-trigger mark and await its triggers; ``stopped`` once none is due.""" + envs = [] + for d in context.deployed_envs: + if not (d.supports(EXT_TRIGGERS_URI, "state") and d.supports(EXT_CLOCK_URI, "state")): + continue + clock = await self._get_clock_state(d) + if clock and clock.get("armed"): + envs.append((d, clock, (await self._get_trigger_state(d)).get("triggers", []))) + pending = [(_utc(t["next_mark"]), d, t) for d, _, rows in envs for t in rows + if t.get("type") == "time" and t.get("status") == "armed" and t.get("next_mark")] + if not envs: + return {"stopped": "no_armed_env_clock", "virtual_time": None} + end = _utc(advance["end_time"]) if advance["end_time"] else None + mark = min((m for m, _, _ in pending), default=None) + if mark is None or (end is not None and mark > end): + if end is not None: + await self._move_env_clocks(envs, end, check_marks=False) + return {"stopped": "end_time" if mark is not None else "no_pending_time_triggers", + "virtual_time": _iso(end) if end is not None else None} + due = [(d, t) for m, d, t in pending if m == mark] + moved = await self._move_env_clocks(envs, mark, check_marks=True) + statuses = await self._wait_fired(context, due, advance["fire_timeout_seconds"]) + return {"mark": _iso(mark), "trigger_ids": [t["id"] for _, t in due], "statuses": statuses, "moved": moved} + + async def _move_env_clocks(self, envs: list, target: datetime, *, check_marks: bool) -> dict: + """Bring each env clock behind ``target`` up to it, by advance where offered, else a re-arm only fixed marks survive.""" + moved = {} + for d, clock, rows in envs: + if _utc(clock["virtual_time"]) >= target: + continue + if d.supports(EXT_CLOCK_URI, "advance"): + await d.invoke(EXT_CLOCK_URI, "advance", {"virtual_time": _iso(target)}, timeout=15) + moved[d.env_id] = "advance" + continue + unsafe = [t["id"] for t in rows if check_marks and t.get("type") == "time" + and t.get("status") == "armed" and not _rearm_safe(t.get("when") or {})] + if unsafe: + raise RuntimeError(f"{self.id}: env '{d.env_id}' has no clock advance, and a re-arm would re-anchor the " + f"relative or recurring time trigger(s) {unsafe}; give them absolute marks") + await d.invoke(EXT_CLOCK_URI, "set_time", {"virtual_time": _iso(target), "virtual_seconds_per_real_second": + clock.get("virtual_seconds_per_real_second", 1.0)}, timeout=15) + moved[d.env_id] = "set_time" + return moved + + async def _wait_fired(self, context: TaskStepContext, due: list, timeout_seconds: float) -> dict: + """Each due trigger's outcome once it has fired, failed or been cancelled; "timeout" for one still unsettled.""" + before = {t["id"]: (t.get("fire_count") or 0, t.get("failure_count") or 0) for _, t in due} + by_env: dict[str, tuple[Any, list[str]]] = {} + for d, t in due: + by_env.setdefault(d.env_id, (d, []))[1].append(t["id"]) + outcome: dict[str, str] = {} + deadline = time.monotonic() + timeout_seconds + while True: + for d, ids in by_env.values(): + rows = {t["id"]: t for t in (await self._get_trigger_state(d)).get("triggers", [])} + for tid in (i for i in ids if i not in outcome): + row = rows.get(tid) + fires, failures = before[tid] + if row is None: + outcome[tid] = "removed" + elif row.get("status") in TRIGGER_IN_FLIGHT_STATUSES: + continue + elif (row.get("failure_count") or 0) > failures: + outcome[tid] = "failed" + elif (row.get("fire_count") or 0) > fires or row.get("status") in _SETTLED_STATUSES: + outcome[tid] = "fired" if row.get("status") == "armed" else row["status"] + if len(outcome) == len(due) or time.monotonic() >= deadline: + break + context.heartbeat() + await asyncio.sleep(_FIRE_POLL_SECONDS) + for _, t in due: + if t["id"] not in outcome: + logger.warning(f"{self.id}: time trigger '{t['id']}' had not settled {timeout_seconds}s after its mark") + outcome[t["id"]] = "timeout" + return outcome + _CAPTURE_BUDGET_SECONDS: ClassVar[float] = 90 async def _persist_env_trigger_state(self, context: TaskStepContext) -> None: diff --git a/tst/integration/task_step/test_prompt_agent_between_turns_local.py b/tst/integration/task_step/test_prompt_agent_between_turns_local.py new file mode 100644 index 000000000..f29ce54ce --- /dev/null +++ b/tst/integration/task_step/test_prompt_agent_between_turns_local.py @@ -0,0 +1,84 @@ +"""An event-driven multi-day run on the local defaults: a gateway env whose frozen clock starts on a Monday, two time +triggers days apart that write into its items server, and the echo agent (tst/data/a2a_agent) prompted with +between_turns next_env_event. Between turns prompt_agent advances the gateway clock to each mark, the trigger writes +land, the agent is woken once per event with no user side, and every message carries the env clock, all in seconds of +wall time. Needs Docker and a throwaway local registry.""" + +import json +import time +import uuid +from datetime import datetime + +import httpx +import pytest + +from agent_env.a2a_agent import conversation_store +from agent_env.env.gateway import AGENT_ENV_ROLE_HEADER +from agent_env.task_step.context import TaskStepContext +from agent_env.task_step.task_steps.prompt_agent import DEFAULT_WAKE_MESSAGE, PromptAgentTaskStep +from agent_env.task_step.task_steps.register_env_triggers import RegisterEnvTriggersStep +from agent_env.task_step.task_steps.sync_env_clock import SyncEnvClockTaskStep +from tst.integration.store.local_parallel_deploy_test import _seed_bootstrap_envs +from tst.integration.task_step.test_prompt_agent_live_trajectory_local import _deploy, local_registry as local_registry +from tst.integration.task_step.test_register_env_triggers_local import _deploy_items +from tst.util.a2a_test_agent import put_test_agent + +pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] + +MONDAY = "2026-03-02T13:00:00Z" +TUESDAY = "2026-03-03T16:40:00Z" +THURSDAY = "2026-03-05T03:15:00Z" +FRIDAY = "2026-03-06T12:00:00Z" + + +def _vt(text: str) -> datetime: + return datetime.fromisoformat(text.replace("Z", "+00:00")) + + +@pytest.mark.asyncio +async def test_each_event_days_apart_wakes_the_agent_once_in_one_run(local_registry): + _seed_bootstrap_envs() + env, deployed, _ = await _deploy_items("multiday") + agent = put_test_agent(f"multiday-agent-{uuid.uuid4().hex[:8]}") + triggers = [{"id": "school-email", "when": {"type": "time", "at": TUESDAY}, + "actions": [{"type": "tool", "tool": "items_add_item", "args": {"item": "school email"}}]}, + {"id": "calendar-clash", "when": {"type": "time", "at": "P2DT14H15M"}, + "actions": [{"type": "tool", "tool": "items_add_item", "args": {"item": "calendar clash"}}]}] + context = TaskStepContext(deployed_envs=[deployed], deployed_agents=[], metadata={}) + try: + await SyncEnvClockTaskStep(id="clock", version=None, env_id=env.id, virtual_time=MONDAY, + virtual_seconds_per_real_second=0).execute(context) + await RegisterEnvTriggersStep(id="events", version=None, env_id=env.id, triggers=triggers, + watch_roles=["default"]).execute(context) + await _deploy(agent, "solver").execute(context) + started = time.monotonic() + await PromptAgentTaskStep(id="watch", version=None, prompt="Watch the inbox.", agent_name="solver", + max_conversation_turns=6, poll_interval_seconds=1, + between_turns={"mode": "next_env_event", "end_time": FRIDAY}).execute(context) + wall = time.monotonic() - started + async with httpx.AsyncClient(base_url=deployed.gateway_url, timeout=60) as client: + state = (await client.get("/triggers/state")).raise_for_status().json() + clock = (await client.get("/clock/state")).raise_for_status().json() + stored = await client.post("/step", json={"action": "call_tool", "tool_name": "list_items", "arguments": {}}, + headers={AGENT_ENV_ROLE_HEADER: "harness"}) + finally: + await env.close() + + conversation = conversation_store.get_conversation(context.metadata["a2a_conversations"]["watch"]) + sent = [m["parts"][0]["text"] for m in conversation["messages"] if m["role"] == "user"] + assert sent == ["Watch the inbox.", DEFAULT_WAKE_MESSAGE.format(virtual_time=TUESDAY), + DEFAULT_WAKE_MESSAGE.format(virtual_time=THURSDAY)] + assert [m["env_clock"][env.id] for m in conversation["messages"]] == [MONDAY, MONDAY, TUESDAY, TUESDAY, + THURSDAY, THURSDAY] + assert {t["id"]: (t["status"], t["fire_count"]) for t in state["triggers"]} == { + "school-email": ("fired", 1), "calendar-clash": ("fired", 1)} + fired = {e["trigger_id"]: e["virtual_time"] for e in state["events"] if e["kind"] == "fired"} + assert {k: _vt(v) for k, v in fired.items()} == {"school-email": _vt(TUESDAY), "calendar-clash": _vt(THURSDAY)} + assert "reanchored" not in {e["kind"] for e in state["events"]} + assert json.loads(stored.json()["content"][0]["text"])["items"][-2:] == ["school email", "calendar clash"] + assert _vt(clock["virtual_time"]) == _vt(FRIDAY) and clock["t0"] == MONDAY + advances = context.metadata["env_event_advances"]["watch"] + assert [a.get("trigger_ids") for a in advances[:2]] == [["school-email"], ["calendar-clash"]] + assert all(a["moved"] == {env.id: "advance"} for a in advances[:2]) + assert advances[2] == {"turn": 3, "stopped": "no_pending_time_triggers", "virtual_time": FRIDAY} + assert wall < 90, f"four simulated days took {wall:.0f}s of wall time" diff --git a/tst/unit/env/gateway/clock_advance_test.py b/tst/unit/env/gateway/clock_advance_test.py new file mode 100644 index 000000000..df7e625b1 --- /dev/null +++ b/tst/unit/env/gateway/clock_advance_test.py @@ -0,0 +1,164 @@ +"""Clock.advance: a forward jump on the current arm, so time triggers keep their marks, and its PUT /clock/advance route.""" +from __future__ import annotations + +import asyncio + +import httpx +import pytest + +from agent_env.env.gateway import gateway as gw_mod +from agent_env.env.gateway import triggers as trig_mod +from agent_env.env.gateway.clock import Clock, ClockError +from agent_env.env.gateway.constants import EXT_CLOCK_URI, GATEWAY_EXTENSIONS +from agent_env.env.gateway.gateway import Gateway +from agent_env.env.gateway.triggers import TriggerEngine +from tst.unit.env.gateway.triggers_time_test import FakeNow, _FakeGateway, _perm, _settle, _tick_call + +T0 = "2026-01-01T00:00:00Z" + + +def _armed(rate: float = 1.0) -> tuple[Clock, FakeNow]: + now = FakeNow() + clock = Clock(now_fn=now) + clock.set_time(T0, rate=rate) + return clock, now + + +def test_advance_jumps_forward_and_keeps_t0_rate_and_generation(): + clock, now = _armed(rate=2.0) + gen = clock.generation + state = clock.advance("2026-01-03T00:00:00Z") + assert state["virtual_time"] == "2026-01-03T00:00:00Z" + assert (state["t0"], state["virtual_seconds_per_real_second"], clock.generation) == (T0, 2.0, gen) + now.tick(30) + assert clock.read()["virtual_time"] == "2026-01-03T00:01:00Z" + + +def test_advance_on_a_frozen_clock_holds_there(): + clock, now = _armed(rate=0) + clock.advance("2026-01-02T12:00:00Z") + now.tick(3600) + assert clock.read()["virtual_time"] == "2026-01-02T12:00:00Z" + + +def test_elapsed_counts_the_jump(): + clock, now = _armed() + now.tick(10) + clock.advance("2026-01-01T01:00:10Z") + assert clock.elapsed() == pytest.approx(3610) + + +def test_advance_refuses_backwards_and_unarmed(): + clock, now = _armed() + now.tick(60) + with pytest.raises(ClockError, match="only moves forward"): + clock.advance("2026-01-01T00:00:30Z") + with pytest.raises(ClockError, match="not armed"): + Clock(now_fn=FakeNow()).advance(T0) + with pytest.raises(ClockError, match="RFC3339"): + clock.advance("tomorrow") + + +def test_a_rearm_after_an_advance_starts_from_its_own_t0_and_banks_from_the_advanced_time(): + clock, now = _armed() + clock.advance("2026-01-02T00:00:00Z") + lived = clock.elapsed() + clock.set_time("2026-01-02T06:00:00Z", rate=1.0) + assert clock.read()["virtual_time"] == "2026-01-02T06:00:00Z" + assert clock.elapsed() == pytest.approx(lived + 6 * 3600) + clock.set_time("2026-01-01T00:00:00Z", rate=1.0) + assert clock.read()["virtual_time"] == T0 + + +def test_clear_drops_the_advance(): + clock, _ = _armed() + clock.advance("2026-01-05T00:00:00Z") + clock.clear() + clock.set_time(T0, rate=0) + assert clock.read()["virtual_time"] == T0 + + +@pytest.mark.asyncio +async def test_a_relative_mark_comes_due_after_an_advance_with_no_reanchor(): + now = FakeNow() + clock = Clock(now_fn=now) + engine = TriggerEngine(_FakeGateway(clock)) + clock.set_time(T0, rate=0) + engine.register({"watch_roles": ["default"], "triggers": [ + {"id": "rel", "when": {"type": "time", "at": "P2D"}, "actions": [_perm()]}]}) + _tick_call(engine, now, 1) + clock.advance("2026-01-03T00:00:00Z") + _tick_call(engine, now, 1) + await _settle(engine) + trig = engine.state()["triggers"][0] + assert (trig["status"], trig["fire_count"]) == ("fired", 1) + assert "reanchored" not in {e["kind"] for e in engine.state()["events"]} + + +@pytest.mark.asyncio +async def test_a_delay_counts_the_jumped_time(monkeypatch): + monkeypatch.setattr(trig_mod, "_VIRTUAL_RECHECK_S", 0.01) + now = FakeNow() + clock = Clock(now_fn=now) + engine = TriggerEngine(_FakeGateway(clock)) + clock.set_time(T0, rate=0) + engine.register({"watch_roles": ["default"], "triggers": [ + {"id": "slow", "when": {"type": "time", "at": "PT1M"}, "actions": [{**_perm(), "delay": "PT6H"}]}]}) + clock.advance("2026-01-01T00:01:00Z") + _tick_call(engine, now, 1) + await asyncio.sleep(0.05) + assert engine.state()["triggers"][0]["waiting"] is True + clock.advance("2026-01-01T06:01:00Z") + for _ in range(100): + if engine.state()["triggers"][0]["status"] == "fired": + break + await asyncio.sleep(0.05) + assert engine.state()["triggers"][0]["status"] == "fired" + + +def test_the_card_offers_advance(): + clock_ext = next(e for e in GATEWAY_EXTENSIONS if e["uri"] == EXT_CLOCK_URI) + assert clock_ext["params"]["methods"]["advance"] == { + "method": "PUT", "endpoint": "/clock/advance", + "request": {"type": "object", "properties": {"virtual_time": {"type": "string"}}, "required": ["virtual_time"]}} + + +@pytest.fixture +def gateway(tmp_path, monkeypatch) -> Gateway: + monkeypatch.setattr(gw_mod, "GATEWAY_TRAJECTORY_FILE", str(tmp_path / "trajectory.jsonl")) + gw = Gateway(host="127.0.0.1", port=0, server_name="AgentEnvGateway", internal_mcp_servers=[]) + gw._clock = Clock(now_fn=FakeNow()) + yield gw + gw._close_trajectory_file() + + +def _put(gw: Gateway, path: str, body) -> httpx.Response: + async def _do() -> httpx.Response: + transport = httpx.ASGITransport(app=gw._mcp.streamable_http_app()) + async with httpx.AsyncClient(transport=transport, base_url="http://gateway") as client: + return await client.put(path, content=body if isinstance(body, str) else None, + json=None if isinstance(body, str) else body) + + return asyncio.run(_do()) + + +def test_route_advances_an_armed_clock(gateway): + gateway._clock.set_time(T0, rate=0) + resp = _put(gateway, "/clock/advance", {"virtual_time": "2026-01-04T08:00:00Z"}) + assert resp.status_code == 200 + assert resp.json()["virtual_time"] == "2026-01-04T08:00:00Z" + + +@pytest.mark.parametrize("body,fragment", [ + ({"virtual_time": "2026-01-04T08:00:00Z"}, "not armed"), + ("{not json", "Invalid JSON body"), +]) +def test_route_refuses_with_400(gateway, body, fragment): + resp = _put(gateway, "/clock/advance", body) + assert resp.status_code == 400 and fragment in resp.json()["error"] + + +def test_route_refuses_a_backwards_advance(gateway): + gateway._clock.set_time("2026-01-04T08:00:00Z", rate=0) + resp = _put(gateway, "/clock/advance", {"virtual_time": T0}) + assert resp.status_code == 400 and "only moves forward" in resp.json()["error"] diff --git a/tst/unit/task_step/test_prompt_agent_between_turns.py b/tst/unit/task_step/test_prompt_agent_between_turns.py new file mode 100644 index 000000000..476216708 --- /dev/null +++ b/tst/unit/task_step/test_prompt_agent_between_turns.py @@ -0,0 +1,305 @@ +"""prompt_agent between_turns next_env_event: between turns the env clock jumps to the next pending time-trigger mark, +the run waits for those triggers, and the agent is woken; with the option unset nothing changes.""" +from __future__ import annotations + +import copy +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock + +import httpx +import pytest +from a2a.types import TaskState + +from agent_env.a2a_agent import A2AAgent +from agent_env.env.gateway.constants import EXT_CLOCK_URI, EXT_TRIGGERS_URI +from agent_env.task_step.context import DeployedAgent, TaskStepContext +from agent_env.task_step.task_steps import prompt_agent as pa_mod +from agent_env.task_step.task_steps.prompt_agent import DEFAULT_WAKE_MESSAGE, PromptAgentTaskStep + +START = "2026-03-02T13:00:00Z" +DAY1 = "2026-03-03T09:30:00Z" +DAY2 = "2026-03-04T21:15:00Z" + + +def _dt(text: str) -> datetime: + return datetime.fromisoformat(text.replace("Z", "+00:00")) + + +def _z(value: datetime) -> str: + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +class _Env: + """A deployed env whose clock and time triggers behave as a gateway's: a trigger fires once the clock reaches its mark.""" + + def __init__(self, marks: dict, *, rate: float = 0.0, advance: bool = True, outcome: str = "fired", + env_id: str = "env-1"): + self.env_id, self.rate, self.offers_advance, self.outcome = env_id, rate, advance, outcome + self.now = _dt(START) + self.calls: list[tuple[str, dict | None]] = [] + self.rows = {tid: {"id": tid, "type": "time", "status": "armed", "next_mark": at if not at.startswith("P") else + _z(self.now + timedelta(days=1)), "fire_count": 0, "failure_count": 0, + "when": {"type": "time", "at": at}} for tid, at in marks.items()} + + def supports(self, uri: str, method: str) -> bool: + if uri == EXT_CLOCK_URI: + return method in ("state", "set_time") or (method == "advance" and self.offers_advance) + return uri == EXT_TRIGGERS_URI and method == "state" + + async def invoke(self, uri, method, params=None, *, timeout=30, environment_name=None): + self.calls.append((method, params)) + if uri == EXT_CLOCK_URI and method in ("advance", "set_time"): + self.now = _dt(params["virtual_time"]) + return {} + if uri == EXT_CLOCK_URI: + return {"armed": True, "virtual_time": _z(self.now), "virtual_seconds_per_real_second": self.rate} + for row in self.rows.values(): + if row["status"] == "armed" and row["next_mark"] and _dt(row["next_mark"]) <= self.now: + if self.outcome == "fired": + row.update(status="fired", fire_count=1, next_mark=None) + elif self.outcome == "failed": + row.update(status="failed", failure_count=1, next_mark=None) + return {"triggers": copy.deepcopy(list(self.rows.values()))} + + def moves(self) -> list[tuple[str, dict]]: + return [(m, p) for m, p in self.calls if m in ("advance", "set_time")] + + +def _wire(monkeypatch, *, decide=None): + """Mock the conversation's HTTP and store boundary; returns what the solver was sent and what the store recorded.""" + cfg = MagicMock() + cfg.get_model_params.return_value = {} + cfg.get_default_human_a2a_url.side_effect = AssertionError("no human peer in an event-driven run") + monkeypatch.setattr(pa_mod, "get_config", lambda: cfg) + seen = {"solver": [], "stored": [], "closed": 0} + monkeypatch.setattr(pa_mod.conversation_store, "create_conversation", lambda *a, **kw: None) + monkeypatch.setattr(pa_mod.conversation_store, "get_conversation", lambda *a, **kw: None) + monkeypatch.setattr(pa_mod.conversation_store, "add_a2a_task", lambda *a, **kw: seen["stored"].append(("add", kw))) + monkeypatch.setattr(pa_mod.conversation_store, "complete_a2a_task", + lambda *a, **kw: seen["stored"].append(("complete", kw))) + + def closed(*a, **kw): + seen["closed"] += 1 + + monkeypatch.setattr(pa_mod.conversation_store, "mark_closed", closed) + + async def send(url, parts, message_id, context_id, timeout): + if "user" in url: + raise AssertionError("the user side is never asked in an event-driven run") + seen["solver"].append(parts[0]["text"]) + return f"task-{message_id}", None + + async def poll(url, task_id, timeout, interval, **_): + return {"status": {"state": TaskState.completed, "message": {"parts": [{"kind": "text", "text": "ok"}]}}} + + monkeypatch.setattr(pa_mod.protocol, "send_a2a_message", send) + monkeypatch.setattr(pa_mod.protocol, "poll_a2a_task", poll) + monkeypatch.setattr(pa_mod.asyncio, "sleep", _no_sleep) + if decide is not None: + monkeypatch.setattr(PromptAgentTaskStep, "_decide", decide) + return seen + + +async def _no_sleep(_seconds): + return None + + +def _context(*envs, user_sim_triggers: bool = False) -> TaskStepContext: + ctx = TaskStepContext(instance_id="ti-1", deployed_envs=list(envs)) + ctx.deployed_agents.append(DeployedAgent(agent_name="solver", api_url="http://solver", a2a_url="http://solver", + a2a_card={})) + if user_sim_triggers: + card = {"capabilities": {"extensions": [{"uri": A2AAgent.EXT_TRIGGERS, "params": { + "endpoint": "/ext/triggers", "methods": {"decide": {"method": "POST", "endpoint": "/ext/triggers/decide"}}}}]}} + ctx.deployed_agents.append(DeployedAgent(agent_name="stakeholder", api_url="http://user-sim", + a2a_url="http://user-sim", a2a_card=card)) + ctx.metadata["agent_trigger_registrations"] = [{"agent_name": "stakeholder", "added": ["news"]}] + return ctx + + +def _step(between_turns, turns: int = 6, **kw) -> PromptAgentTaskStep: + return PromptAgentTaskStep(id="watch", version=None, prompt="Watch my day.", agent_name="solver", + max_conversation_turns=turns, between_turns=between_turns, **kw) + + +def test_unset_adds_nothing_to_the_stored_step(): + data = PromptAgentTaskStep(id="s", version=None, prompt="hi").to_dict() + assert "between_turns" not in data and "record_env_clock" not in data + again = PromptAgentTaskStep.from_dict(data) + assert (again.between_turns, again.record_env_clock) == (None, False) + + +@pytest.mark.parametrize("given", ["next_env_event", {"mode": "next_env_event", "end_time": DAY2, "wake": "message"}]) +def test_the_option_round_trips_as_given(given): + step = PromptAgentTaskStep.from_dict(_step(given, record_env_clock=True).to_dict()) + assert (step.between_turns, step.record_env_clock) == (given, True) + + +@pytest.mark.parametrize("given,fragment", [ + ("every_turn", "mode must be"), + ({"mode": "next_env_event", "until": DAY1}, "unknown key"), + ({"mode": "next_env_event", "wake": "user"}, "wake must be one of"), + ({"mode": "next_env_event", "wake_message": "{now}"}, "may use only"), + ({"mode": "next_env_event", "wake_message": " "}, "non-empty"), + ({"mode": "next_env_event", "end_time": "Tuesday"}, "end_time"), + ({"mode": "next_env_event", "end_time": "2026-03-04T21:15:00"}, "end_time"), + ({"mode": "next_env_event", "fire_timeout_seconds": 0}, "positive"), + (["next_env_event"], "or an object with a mode"), +]) +def test_a_bad_option_is_refused_at_construction(given, fragment): + with pytest.raises(ValueError, match=fragment): + _step(given) + + +def test_a_single_turn_step_cannot_act_between_turns(): + with pytest.raises(ValueError, match="max_conversation_turns >= 2"): + _step("next_env_event", turns=1) + + +@pytest.mark.asyncio +async def test_two_events_days_apart_each_wake_the_agent_once_then_the_run_ends(monkeypatch): + seen = _wire(monkeypatch) + env = _Env({"school-email": DAY1, "calendar-clash": DAY2}) + + ctx = await _step("next_env_event").execute(_context(env)) + + assert seen["solver"] == ["Watch my day.", DEFAULT_WAKE_MESSAGE.format(virtual_time=DAY1), + DEFAULT_WAKE_MESSAGE.format(virtual_time=DAY2)] + assert env.moves() == [("advance", {"virtual_time": DAY1}), ("advance", {"virtual_time": DAY2})] + advances = ctx.metadata["env_event_advances"]["watch"] + assert advances[0] == {"turn": 1, "mark": DAY1, "trigger_ids": ["school-email"], "statuses": {"school-email": "fired"}, + "moved": {"env-1": "advance"}} + assert advances[1]["trigger_ids"] == ["calendar-clash"] + assert advances[2] == {"turn": 3, "stopped": "no_pending_time_triggers", "virtual_time": None} + assert seen["closed"] >= 1 + + +@pytest.mark.asyncio +async def test_every_message_carries_the_env_clock_when_the_run_is_event_driven(monkeypatch): + seen = _wire(monkeypatch) + env = _Env({"school-email": DAY1}) + + await _step("next_env_event").execute(_context(env)) + + stamps = [(kind, kw["env_clock"]) for kind, kw in seen["stored"]] + assert stamps == [("add", {"env-1": START}), ("complete", {"env-1": START}), + ("add", {"env-1": DAY1}), ("complete", {"env-1": DAY1})] + + +@pytest.mark.asyncio +async def test_end_time_stops_the_run_and_brings_the_clock_to_it(monkeypatch): + seen = _wire(monkeypatch) + env = _Env({"day-1": DAY1, "day-5": "2026-03-07T09:00:00Z"}) + end = "2026-03-05T07:00:00Z" + + ctx = await _step({"mode": "next_env_event", "end_time": end}).execute(_context(env)) + + assert len(seen["solver"]) == 2 + assert env.moves()[-1] == ("advance", {"virtual_time": end}) + assert ctx.metadata["env_event_advances"]["watch"][-1] == {"turn": 2, "stopped": "end_time", "virtual_time": end} + assert env.rows["day-5"]["status"] == "armed" + + +@pytest.mark.asyncio +async def test_max_conversation_turns_still_bounds_the_run(monkeypatch): + seen = _wire(monkeypatch) + env = _Env({"a": DAY1, "b": DAY2, "c": "2026-03-06T00:00:00Z"}) + + await _step("next_env_event", turns=2).execute(_context(env)) + + assert len(seen["solver"]) == 2 and env.moves() == [("advance", {"virtual_time": DAY1})] + + +@pytest.mark.asyncio +async def test_triggers_due_at_one_mark_share_one_wake(monkeypatch): + seen = _wire(monkeypatch) + env = _Env({"sale": DAY1, "text": DAY1}) + + await _step({"mode": "next_env_event", "wake_message": "ping {virtual_time}: {trigger_ids}"}).execute(_context(env)) + + assert seen["solver"][1:] == [f"ping {DAY1}: sale, text"] + + +@pytest.mark.asyncio +async def test_a_gateway_without_advance_is_rearmed_at_its_own_rate(monkeypatch): + _wire(monkeypatch) + env = _Env({"school-email": DAY1}, rate=60.0, advance=False) + + await _step("next_env_event").execute(_context(env)) + + assert env.moves() == [("set_time", {"virtual_time": DAY1, "virtual_seconds_per_real_second": 60.0})] + + +@pytest.mark.asyncio +async def test_a_gateway_without_advance_refuses_to_rearm_over_a_relative_mark(monkeypatch): + _wire(monkeypatch) + env = _Env({"school-email": DAY1, "reminder": "P1D"}, advance=False) + + with pytest.raises(RuntimeError, match=r"re-anchor .*\['reminder'\]"): + await _step("next_env_event").execute(_context(env)) + assert env.moves() == [] + + +@pytest.mark.asyncio +async def test_a_failed_or_stuck_trigger_is_reported_and_the_agent_still_woken(monkeypatch): + seen = _wire(monkeypatch) + failed = await _step("next_env_event", turns=2).execute(_context(_Env({"e": DAY1}, outcome="failed"))) + stuck = await _step({"mode": "next_env_event", "fire_timeout_seconds": 0.01}, turns=2).execute( + _context(_Env({"e": DAY1}, outcome="stuck"))) + + assert failed.metadata["env_event_advances"]["watch"][0]["statuses"] == {"e": "failed"} + assert stuck.metadata["env_event_advances"]["watch"][0]["statuses"] == {"e": "timeout"} + assert len(seen["solver"]) == 4 + + +@pytest.mark.asyncio +async def test_wake_decide_lets_agent_triggers_speak_and_falls_back_to_the_wake_message(monkeypatch): + calls = [] + + async def decide(self, url, turn, message, context_id, env_triggers): + calls.append((turn, env_triggers)) + return {"fired": ["news"], "done": False, "parts": [{"kind": "text", "text": "[news] the school wrote"}]} \ + if turn == 1 else {"fired": [], "done": False, "parts": []} + + async def readback(self, url, timeout): + return httpx.Response(200, json={"firing_log": []}, request=httpx.Request("GET", url)) + + seen = _wire(monkeypatch, decide=decide) + monkeypatch.setattr(httpx.AsyncClient, "get", readback) + env = _Env({"school-email": DAY1, "calendar-clash": DAY2}) + + await _step({"mode": "next_env_event", "wake": "decide"}, user_agent_name="stakeholder").execute( + _context(env, user_sim_triggers=True)) + + assert seen["solver"][1:] == ["[news] the school wrote", DEFAULT_WAKE_MESSAGE.format(virtual_time=DAY2)] + assert calls[0] == (1, {"env-1": {"school-email": "fired", "calendar-clash": "armed"}}) + + +@pytest.mark.asyncio +async def test_a_run_with_no_armed_env_clock_ends_after_the_first_turn(monkeypatch): + seen = _wire(monkeypatch) + + ctx = await _step("next_env_event").execute(_context()) + + assert len(seen["solver"]) == 1 + assert ctx.metadata["env_event_advances"]["watch"] == [{"turn": 1, "stopped": "no_armed_env_clock", "virtual_time": None}] + + +@pytest.mark.asyncio +async def test_wake_decide_without_agent_triggers_is_refused(monkeypatch): + _wire(monkeypatch) + with pytest.raises(RuntimeError, match="wake 'decide' needs"): + await _step({"mode": "next_env_event", "wake": "decide"}).execute(_context(_Env({"e": DAY1}))) + + +@pytest.mark.asyncio +async def test_record_env_clock_stamps_a_plain_conversation_and_unset_stamps_nothing(monkeypatch): + seen = _wire(monkeypatch) + env = _Env({}) + await PromptAgentTaskStep(id="s", version=None, prompt="hi", agent_name="solver", + record_env_clock=True).execute(_context(env)) + await PromptAgentTaskStep(id="s", version=None, prompt="hi", agent_name="solver").execute(_context(env)) + + assert [kw.get("env_clock", "absent") for _, kw in seen["stored"]] == [{"env-1": START}, {"env-1": START}, + "absent", "absent"] + assert env.calls == [("state", None), ("state", None)] From 9dc9ddf263498cb03bbb16b2c2f806386ab974e7 Mon Sep 17 00:00:00 2001 From: Sarah Chitty Date: Tue, 20 Oct 2026 09:42:13 -0700 Subject: [PATCH 02/15] fix(prompt_agent): await a due trigger on each env by env and trigger id 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 --- .../task_step/task_steps/prompt_agent.py | 34 ++++++++++--------- .../test_prompt_agent_between_turns.py | 22 ++++++++++-- 2 files changed, 37 insertions(+), 19 deletions(-) diff --git a/src/agent_env/task_step/task_steps/prompt_agent.py b/src/agent_env/task_step/task_steps/prompt_agent.py index cd06db75c..87839df60 100644 --- a/src/agent_env/task_step/task_steps/prompt_agent.py +++ b/src/agent_env/task_step/task_steps/prompt_agent.py @@ -975,7 +975,8 @@ async def _advance_to_next_env_event(self, context: TaskStepContext, advance: di due = [(d, t) for m, d, t in pending if m == mark] moved = await self._move_env_clocks(envs, mark, check_marks=True) statuses = await self._wait_fired(context, due, advance["fire_timeout_seconds"]) - return {"mark": _iso(mark), "trigger_ids": [t["id"] for _, t in due], "statuses": statuses, "moved": moved} + trigger_ids = list(dict.fromkeys(t["id"] for _, t in due)) + return {"mark": _iso(mark), "trigger_ids": trigger_ids, "statuses": statuses, "moved": moved} async def _move_env_clocks(self, envs: list, target: datetime, *, check_marks: bool) -> dict: """Bring each env clock behind ``target`` up to it, by advance where offered, else a re-arm only fixed marks survive.""" @@ -998,36 +999,37 @@ async def _move_env_clocks(self, envs: list, target: datetime, *, check_marks: b return moved async def _wait_fired(self, context: TaskStepContext, due: list, timeout_seconds: float) -> dict: - """Each due trigger's outcome once it has fired, failed or been cancelled; "timeout" for one still unsettled.""" - before = {t["id"]: (t.get("fire_count") or 0, t.get("failure_count") or 0) for _, t in due} + """``{env_id: {trigger_id: outcome}}`` once each due trigger has fired, failed or been cancelled; "timeout" if not.""" + before = {(d.env_id, t["id"]): (t.get("fire_count") or 0, t.get("failure_count") or 0) for d, t in due} by_env: dict[str, tuple[Any, list[str]]] = {} for d, t in due: by_env.setdefault(d.env_id, (d, []))[1].append(t["id"]) - outcome: dict[str, str] = {} + outcome: dict[tuple[str, str], str] = {} deadline = time.monotonic() + timeout_seconds while True: - for d, ids in by_env.values(): + for env_id, (d, ids) in by_env.items(): rows = {t["id"]: t for t in (await self._get_trigger_state(d)).get("triggers", [])} - for tid in (i for i in ids if i not in outcome): + for tid in (i for i in ids if (env_id, i) not in outcome): row = rows.get(tid) - fires, failures = before[tid] + fires, failures = before[(env_id, tid)] if row is None: - outcome[tid] = "removed" + outcome[(env_id, tid)] = "removed" elif row.get("status") in TRIGGER_IN_FLIGHT_STATUSES: continue elif (row.get("failure_count") or 0) > failures: - outcome[tid] = "failed" + outcome[(env_id, tid)] = "failed" elif (row.get("fire_count") or 0) > fires or row.get("status") in _SETTLED_STATUSES: - outcome[tid] = "fired" if row.get("status") == "armed" else row["status"] - if len(outcome) == len(due) or time.monotonic() >= deadline: + outcome[(env_id, tid)] = "fired" if row.get("status") == "armed" else row["status"] + if len(outcome) == len(before) or time.monotonic() >= deadline: break context.heartbeat() await asyncio.sleep(_FIRE_POLL_SECONDS) - for _, t in due: - if t["id"] not in outcome: - logger.warning(f"{self.id}: time trigger '{t['id']}' had not settled {timeout_seconds}s after its mark") - outcome[t["id"]] = "timeout" - return outcome + statuses: dict[str, dict[str, str]] = {} + for env_id, tid in before: + if (env_id, tid) not in outcome: + logger.warning(f"{self.id}: time trigger '{tid}' on env '{env_id}' had not settled {timeout_seconds}s after its mark") + statuses.setdefault(env_id, {})[tid] = outcome.get((env_id, tid), "timeout") + return statuses _CAPTURE_BUDGET_SECONDS: ClassVar[float] = 90 diff --git a/tst/unit/task_step/test_prompt_agent_between_turns.py b/tst/unit/task_step/test_prompt_agent_between_turns.py index 476216708..88c191854 100644 --- a/tst/unit/task_step/test_prompt_agent_between_turns.py +++ b/tst/unit/task_step/test_prompt_agent_between_turns.py @@ -3,6 +3,7 @@ from __future__ import annotations import copy +import time from datetime import datetime, timedelta, timezone from unittest.mock import MagicMock @@ -167,7 +168,7 @@ async def test_two_events_days_apart_each_wake_the_agent_once_then_the_run_ends( DEFAULT_WAKE_MESSAGE.format(virtual_time=DAY2)] assert env.moves() == [("advance", {"virtual_time": DAY1}), ("advance", {"virtual_time": DAY2})] advances = ctx.metadata["env_event_advances"]["watch"] - assert advances[0] == {"turn": 1, "mark": DAY1, "trigger_ids": ["school-email"], "statuses": {"school-email": "fired"}, + assert advances[0] == {"turn": 1, "mark": DAY1, "trigger_ids": ["school-email"], "statuses": {"env-1": {"school-email": "fired"}}, "moved": {"env-1": "advance"}} assert advances[1]["trigger_ids"] == ["calendar-clash"] assert advances[2] == {"turn": 3, "stopped": "no_pending_time_triggers", "virtual_time": None} @@ -220,6 +221,21 @@ async def test_triggers_due_at_one_mark_share_one_wake(monkeypatch): assert seen["solver"][1:] == [f"ping {DAY1}: sale, text"] +@pytest.mark.asyncio +async def test_one_trigger_id_on_two_envs_is_awaited_on_each_without_waiting_out_the_timeout(monkeypatch): + seen = _wire(monkeypatch) + envs = (_Env({"news": DAY1}, env_id="env-1"), _Env({"news": DAY1}, env_id="env-2")) + started = time.monotonic() + + ctx = await _step({"mode": "next_env_event", "fire_timeout_seconds": 5}, turns=2).execute(_context(*envs)) + + assert time.monotonic() - started < 2 + advance = ctx.metadata["env_event_advances"]["watch"][0] + assert advance["statuses"] == {"env-1": {"news": "fired"}, "env-2": {"news": "fired"}} + assert (advance["trigger_ids"], advance["moved"]) == (["news"], {"env-1": "advance", "env-2": "advance"}) + assert seen["solver"][1] == DEFAULT_WAKE_MESSAGE.format(virtual_time=DAY1) + + @pytest.mark.asyncio async def test_a_gateway_without_advance_is_rearmed_at_its_own_rate(monkeypatch): _wire(monkeypatch) @@ -247,8 +263,8 @@ async def test_a_failed_or_stuck_trigger_is_reported_and_the_agent_still_woken(m stuck = await _step({"mode": "next_env_event", "fire_timeout_seconds": 0.01}, turns=2).execute( _context(_Env({"e": DAY1}, outcome="stuck"))) - assert failed.metadata["env_event_advances"]["watch"][0]["statuses"] == {"e": "failed"} - assert stuck.metadata["env_event_advances"]["watch"][0]["statuses"] == {"e": "timeout"} + assert failed.metadata["env_event_advances"]["watch"][0]["statuses"] == {"env-1": {"e": "failed"}} + assert stuck.metadata["env_event_advances"]["watch"][0]["statuses"] == {"env-1": {"e": "timeout"}} assert len(seen["solver"]) == 4 From 974d115fdb827953f66a93e79e9360b1bb2b86e3 Mon Sep 17 00:00:00 2001 From: Sarah Chitty Date: Tue, 20 Oct 2026 09:42:40 -0700 Subject: [PATCH 03/15] fix(prompt_agent): a running clock that reaches the mark before the advance 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 --- .../task_step/task_steps/prompt_agent.py | 14 ++++++++-- .../test_prompt_agent_between_turns.py | 28 ++++++++++++++++++- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/agent_env/task_step/task_steps/prompt_agent.py b/src/agent_env/task_step/task_steps/prompt_agent.py index 87839df60..6dd75d50e 100644 --- a/src/agent_env/task_step/task_steps/prompt_agent.py +++ b/src/agent_env/task_step/task_steps/prompt_agent.py @@ -985,8 +985,7 @@ async def _move_env_clocks(self, envs: list, target: datetime, *, check_marks: b if _utc(clock["virtual_time"]) >= target: continue if d.supports(EXT_CLOCK_URI, "advance"): - await d.invoke(EXT_CLOCK_URI, "advance", {"virtual_time": _iso(target)}, timeout=15) - moved[d.env_id] = "advance" + moved[d.env_id] = await self._advance_clock(d, target) continue unsafe = [t["id"] for t in rows if check_marks and t.get("type") == "time" and t.get("status") == "armed" and not _rearm_safe(t.get("when") or {})] @@ -998,6 +997,17 @@ async def _move_env_clocks(self, envs: list, target: datetime, *, check_marks: b moved[d.env_id] = "set_time" return moved + async def _advance_clock(self, d, target: datetime) -> str: + """Advance one env clock to ``target``: "advance", or "passed" when a running clock got there before the call did.""" + try: + await d.invoke(EXT_CLOCK_URI, "advance", {"virtual_time": _iso(target)}, timeout=15) + return "advance" + except httpx.HTTPStatusError as e: + now = await self._get_clock_state(d) if e.response.status_code == 400 else None + if now and now.get("armed") and _utc(now["virtual_time"]) >= target: + return "passed" + raise + async def _wait_fired(self, context: TaskStepContext, due: list, timeout_seconds: float) -> dict: """``{env_id: {trigger_id: outcome}}`` once each due trigger has fired, failed or been cancelled; "timeout" if not.""" before = {(d.env_id, t["id"]): (t.get("fire_count") or 0, t.get("failure_count") or 0) for d, t in due} diff --git a/tst/unit/task_step/test_prompt_agent_between_turns.py b/tst/unit/task_step/test_prompt_agent_between_turns.py index 88c191854..b3f9f639d 100644 --- a/tst/unit/task_step/test_prompt_agent_between_turns.py +++ b/tst/unit/task_step/test_prompt_agent_between_turns.py @@ -34,8 +34,9 @@ class _Env: """A deployed env whose clock and time triggers behave as a gateway's: a trigger fires once the clock reaches its mark.""" def __init__(self, marks: dict, *, rate: float = 0.0, advance: bool = True, outcome: str = "fired", - env_id: str = "env-1"): + env_id: str = "env-1", overrun: timedelta | None = None): self.env_id, self.rate, self.offers_advance, self.outcome = env_id, rate, advance, outcome + self.overrun = overrun self.now = _dt(START) self.calls: list[tuple[str, dict | None]] = [] self.rows = {tid: {"id": tid, "type": "time", "status": "armed", "next_mark": at if not at.startswith("P") else @@ -49,6 +50,10 @@ def supports(self, uri: str, method: str) -> bool: async def invoke(self, uri, method, params=None, *, timeout=30, environment_name=None): self.calls.append((method, params)) + if uri == EXT_CLOCK_URI and method == "advance" and self.overrun is not None: + self.now = _dt(params["virtual_time"]) + self.overrun + raise httpx.HTTPStatusError("advance only moves forward", request=httpx.Request("PUT", "http://gw"), + response=httpx.Response(400, request=httpx.Request("PUT", "http://gw"))) if uri == EXT_CLOCK_URI and method in ("advance", "set_time"): self.now = _dt(params["virtual_time"]) return {} @@ -236,6 +241,27 @@ async def test_one_trigger_id_on_two_envs_is_awaited_on_each_without_waiting_out assert seen["solver"][1] == DEFAULT_WAKE_MESSAGE.format(virtual_time=DAY1) +@pytest.mark.asyncio +async def test_a_running_clock_that_passes_the_mark_before_the_advance_lands_is_not_an_error(monkeypatch): + seen = _wire(monkeypatch) + env = _Env({"school-email": DAY1}, rate=60.0, overrun=timedelta(seconds=2)) + + ctx = await _step("next_env_event", turns=2).execute(_context(env)) + + advance = ctx.metadata["env_event_advances"]["watch"][0] + assert (advance["moved"], advance["statuses"]) == ({"env-1": "passed"}, {"env-1": {"school-email": "fired"}}) + assert len(seen["solver"]) == 2 + + +@pytest.mark.asyncio +async def test_an_advance_refused_for_another_reason_still_fails_the_step(monkeypatch): + _wire(monkeypatch) + env = _Env({"school-email": DAY1}, overrun=timedelta(seconds=-2)) + + with pytest.raises(httpx.HTTPStatusError): + await _step("next_env_event", turns=2).execute(_context(env)) + + @pytest.mark.asyncio async def test_a_gateway_without_advance_is_rearmed_at_its_own_rate(monkeypatch): _wire(monkeypatch) From e6b4d0e732387250ab5e86f70bf6e579209ce5c2 Mon Sep 17 00:00:00 2001 From: Sarah Chitty Date: Tue, 20 Oct 2026 09:43:05 -0700 Subject: [PATCH 04/15] fix(prompt_agent): a recurring time trigger still mid-fire keeps its 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 --- .../task_step/task_steps/prompt_agent.py | 5 +++-- .../test_prompt_agent_between_turns.py | 22 +++++++++++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/agent_env/task_step/task_steps/prompt_agent.py b/src/agent_env/task_step/task_steps/prompt_agent.py index 6dd75d50e..18b47e406 100644 --- a/src/agent_env/task_step/task_steps/prompt_agent.py +++ b/src/agent_env/task_step/task_steps/prompt_agent.py @@ -121,6 +121,7 @@ def _file_uris(parts: list[dict]) -> list[str]: _BETWEEN_TURNS_KEYS = ("mode", "wake", "wake_message", "end_time", "fire_timeout_seconds") _WAKES = ("message", "decide") _SETTLED_STATUSES = ("fired", "failed", "cancelled") +_SCHEDULED_STATUSES = ("armed", *TRIGGER_IN_FLIGHT_STATUSES) _FIRE_POLL_SECONDS = 0.5 @@ -962,7 +963,7 @@ async def _advance_to_next_env_event(self, context: TaskStepContext, advance: di if clock and clock.get("armed"): envs.append((d, clock, (await self._get_trigger_state(d)).get("triggers", []))) pending = [(_utc(t["next_mark"]), d, t) for d, _, rows in envs for t in rows - if t.get("type") == "time" and t.get("status") == "armed" and t.get("next_mark")] + if t.get("type") == "time" and t.get("status") in _SCHEDULED_STATUSES and t.get("next_mark")] if not envs: return {"stopped": "no_armed_env_clock", "virtual_time": None} end = _utc(advance["end_time"]) if advance["end_time"] else None @@ -988,7 +989,7 @@ async def _move_env_clocks(self, envs: list, target: datetime, *, check_marks: b moved[d.env_id] = await self._advance_clock(d, target) continue unsafe = [t["id"] for t in rows if check_marks and t.get("type") == "time" - and t.get("status") == "armed" and not _rearm_safe(t.get("when") or {})] + and t.get("status") in _SCHEDULED_STATUSES and not _rearm_safe(t.get("when") or {})] if unsafe: raise RuntimeError(f"{self.id}: env '{d.env_id}' has no clock advance, and a re-arm would re-anchor the " f"relative or recurring time trigger(s) {unsafe}; give them absolute marks") diff --git a/tst/unit/task_step/test_prompt_agent_between_turns.py b/tst/unit/task_step/test_prompt_agent_between_turns.py index b3f9f639d..6dfbf9cdd 100644 --- a/tst/unit/task_step/test_prompt_agent_between_turns.py +++ b/tst/unit/task_step/test_prompt_agent_between_turns.py @@ -60,8 +60,12 @@ async def invoke(self, uri, method, params=None, *, timeout=30, environment_name if uri == EXT_CLOCK_URI: return {"armed": True, "virtual_time": _z(self.now), "virtual_seconds_per_real_second": self.rate} for row in self.rows.values(): - if row["status"] == "armed" and row["next_mark"] and _dt(row["next_mark"]) <= self.now: - if self.outcome == "fired": + if row["status"] == "firing" and self.outcome == "fired": + row.update(status="armed", fire_count=row["fire_count"] + 1) + elif row["status"] == "armed" and row["next_mark"] and _dt(row["next_mark"]) <= self.now: + if "every" in row["when"] and self.outcome == "fired": + row.update(fire_count=row["fire_count"] + 1, next_mark=_z(_dt(row["next_mark"]) + timedelta(days=1))) + elif self.outcome == "fired": row.update(status="fired", fire_count=1, next_mark=None) elif self.outcome == "failed": row.update(status="failed", failure_count=1, next_mark=None) @@ -262,6 +266,20 @@ async def test_an_advance_refused_for_another_reason_still_fails_the_step(monkey await _step("next_env_event", turns=2).execute(_context(env)) +@pytest.mark.asyncio +async def test_a_recurring_trigger_still_mid_fire_keeps_its_next_mark_in_the_schedule(monkeypatch): + seen = _wire(monkeypatch) + env = _Env({}) + env.rows["daily"] = {"id": "daily", "type": "time", "status": "firing", "next_mark": DAY1, "fire_count": 3, + "failure_count": 0, "when": {"type": "time", "every": "P1D"}} + + ctx = await _step("next_env_event", turns=3).execute(_context(env)) + + assert env.moves() == [("advance", {"virtual_time": DAY1}), ("advance", {"virtual_time": "2026-03-04T09:30:00Z"})] + assert [a.get("trigger_ids") for a in ctx.metadata["env_event_advances"]["watch"]] == [["daily"], ["daily"]] + assert len(seen["solver"]) == 3 + + @pytest.mark.asyncio async def test_a_gateway_without_advance_is_rearmed_at_its_own_rate(monkeypatch): _wire(monkeypatch) From c42e8539f56a2ec7ef8b6648eaabab564f5f02a4 Mon Sep 17 00:00:00 2001 From: Sarah Chitty Date: Tue, 20 Oct 2026 09:43:25 -0700 Subject: [PATCH 05/15] fix(prompt_agent): the message an agent trigger ends the conversation with carries the env clock too Co-Authored-By: Claude Opus 5.5 --- .../task_step/task_steps/prompt_agent.py | 3 ++- .../test_prompt_agent_between_turns.py | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/agent_env/task_step/task_steps/prompt_agent.py b/src/agent_env/task_step/task_steps/prompt_agent.py index 18b47e406..258f1df84 100644 --- a/src/agent_env/task_step/task_steps/prompt_agent.py +++ b/src/agent_env/task_step/task_steps/prompt_agent.py @@ -709,7 +709,8 @@ def record_turn() -> None: if bool(resp.get("done")): current_user_parts = resp.get("parts") or [] conversation_store.add_a2a_task(conversation_id, parts=current_user_parts, - a2a_task_id=uuid.uuid4().hex, role="user") + a2a_task_id=uuid.uuid4().hex, role="user", + **(await self._env_clock_kwargs(clock_envs))) conversation_store.mark_closed(conversation_id) break if resp.get("fired"): diff --git a/tst/unit/task_step/test_prompt_agent_between_turns.py b/tst/unit/task_step/test_prompt_agent_between_turns.py index 6dfbf9cdd..9b4551555 100644 --- a/tst/unit/task_step/test_prompt_agent_between_turns.py +++ b/tst/unit/task_step/test_prompt_agent_between_turns.py @@ -345,6 +345,25 @@ async def test_a_run_with_no_armed_env_clock_ends_after_the_first_turn(monkeypat assert ctx.metadata["env_event_advances"]["watch"] == [{"turn": 1, "stopped": "no_armed_env_clock", "virtual_time": None}] +@pytest.mark.asyncio +async def test_an_agent_trigger_that_ends_the_run_records_its_message_with_the_env_clock(monkeypatch): + async def decide(self, url, turn, message, context_id, env_triggers): + return {"fired": ["bye"], "done": True, "parts": [{"kind": "text", "text": "[news] that's all"}]} + + async def readback(self, url, timeout): + return httpx.Response(200, json={"firing_log": []}, request=httpx.Request("GET", url)) + + seen = _wire(monkeypatch, decide=decide) + monkeypatch.setattr(httpx.AsyncClient, "get", readback) + + await _step({"mode": "next_env_event", "wake": "decide"}, user_agent_name="stakeholder").execute( + _context(_Env({"school-email": DAY1}), user_sim_triggers=True)) + + assert seen["stored"][-1] == ("add", {"parts": [{"kind": "text", "text": "[news] that's all"}], + "a2a_task_id": seen["stored"][-1][1]["a2a_task_id"], "role": "user", + "env_clock": {"env-1": DAY1}}) + + @pytest.mark.asyncio async def test_wake_decide_without_agent_triggers_is_refused(monkeypatch): _wire(monkeypatch) From 810b448fcd93dda2802cb3061d0d7d3b322e4c00 Mon Sep 17 00:00:00 2001 From: Sarah Chitty Date: Tue, 20 Oct 2026 09:43:47 -0700 Subject: [PATCH 06/15] fix(gateway): PUT /clock/advance answers a body that isn't a JSON object with 400, not a server error Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/gateway/gateway.py | 2 ++ tst/unit/env/gateway/clock_advance_test.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/agent_env/env/gateway/gateway.py b/src/agent_env/env/gateway/gateway.py index d3c8b19a1..435fe3496 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -275,6 +275,8 @@ async def clock_advance(request: Request) -> Response: body = await request.json() except Exception: return self._json_response({"ok": False, "error": "Invalid JSON body"}, 400) + if not isinstance(body, dict): + return self._json_response({"ok": False, "error": "body must be a JSON object"}, 400) try: state = self._clock.advance(body.get("virtual_time")) except ClockError as e: diff --git a/tst/unit/env/gateway/clock_advance_test.py b/tst/unit/env/gateway/clock_advance_test.py index df7e625b1..0a76031f9 100644 --- a/tst/unit/env/gateway/clock_advance_test.py +++ b/tst/unit/env/gateway/clock_advance_test.py @@ -152,6 +152,9 @@ def test_route_advances_an_armed_clock(gateway): @pytest.mark.parametrize("body,fragment", [ ({"virtual_time": "2026-01-04T08:00:00Z"}, "not armed"), ("{not json", "Invalid JSON body"), + ("null", "must be a JSON object"), + ("[]", "must be a JSON object"), + ('"2026-01-04T08:00:00Z"', "must be a JSON object"), ]) def test_route_refuses_with_400(gateway, body, fragment): resp = _put(gateway, "/clock/advance", body) From 8c1a11ae756a591cf25f1a4df7ce52fd357ad153 Mon Sep 17 00:00:00 2001 From: Sarah Chitty Date: Tue, 20 Oct 2026 09:52:40 -0700 Subject: [PATCH 07/15] fix(prompt_agent): a mark whose trigger didn't settle is not chosen again 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 --- .../task_step/task_steps/prompt_agent.py | 11 ++++++++--- .../test_prompt_agent_between_turns.py | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/agent_env/task_step/task_steps/prompt_agent.py b/src/agent_env/task_step/task_steps/prompt_agent.py index 258f1df84..02c5a8e68 100644 --- a/src/agent_env/task_step/task_steps/prompt_agent.py +++ b/src/agent_env/task_step/task_steps/prompt_agent.py @@ -563,6 +563,7 @@ async def _execute_conversation( f"user simulator with agent triggers registered") clock_envs = ([d for d in context.deployed_envs if d.supports(EXT_CLOCK_URI, "state")] if advance is not None or self.record_env_clock else None) + timed_out: set[tuple[str, str, str]] = set() solver_context_id = a2a_context_id if self.context_id: @@ -690,7 +691,7 @@ def record_turn() -> None: await series.capture_turn(context, turn + 1) if advance is not None: - event = await self._advance_to_next_env_event(context, advance) + event = await self._advance_to_next_env_event(context, advance, timed_out) (context.metadata.setdefault("env_event_advances", {}) .setdefault(self.id, []).append({"turn": turn + 1, **event})) if "stopped" in event: @@ -954,7 +955,8 @@ async def _env_clock_kwargs(self, envs: Optional[list]) -> dict: return {"env_clock": {d.env_id: (s or {}).get("virtual_time") if (s or {}).get("armed") else None for d, s in zip(envs, states)}} - async def _advance_to_next_env_event(self, context: TaskStepContext, advance: dict) -> dict: + async def _advance_to_next_env_event(self, context: TaskStepContext, advance: dict, + timed_out: set[tuple[str, str, str]]) -> dict: """Move armed env clocks to the earliest pending time-trigger mark and await its triggers; ``stopped`` once none is due.""" envs = [] for d in context.deployed_envs: @@ -964,7 +966,8 @@ async def _advance_to_next_env_event(self, context: TaskStepContext, advance: di if clock and clock.get("armed"): envs.append((d, clock, (await self._get_trigger_state(d)).get("triggers", []))) pending = [(_utc(t["next_mark"]), d, t) for d, _, rows in envs for t in rows - if t.get("type") == "time" and t.get("status") in _SCHEDULED_STATUSES and t.get("next_mark")] + if t.get("type") == "time" and t.get("status") in _SCHEDULED_STATUSES and t.get("next_mark") + and (d.env_id, t["id"], t["next_mark"]) not in timed_out] if not envs: return {"stopped": "no_armed_env_clock", "virtual_time": None} end = _utc(advance["end_time"]) if advance["end_time"] else None @@ -977,6 +980,8 @@ async def _advance_to_next_env_event(self, context: TaskStepContext, advance: di due = [(d, t) for m, d, t in pending if m == mark] moved = await self._move_env_clocks(envs, mark, check_marks=True) statuses = await self._wait_fired(context, due, advance["fire_timeout_seconds"]) + # A mark whose trigger didn't settle is not chosen again, so an unfinished fire can't hold time still. + timed_out.update((d.env_id, t["id"], t["next_mark"]) for d, t in due if statuses[d.env_id][t["id"]] == "timeout") trigger_ids = list(dict.fromkeys(t["id"] for _, t in due)) return {"mark": _iso(mark), "trigger_ids": trigger_ids, "statuses": statuses, "moved": moved} diff --git a/tst/unit/task_step/test_prompt_agent_between_turns.py b/tst/unit/task_step/test_prompt_agent_between_turns.py index 9b4551555..60195eb13 100644 --- a/tst/unit/task_step/test_prompt_agent_between_turns.py +++ b/tst/unit/task_step/test_prompt_agent_between_turns.py @@ -60,6 +60,8 @@ async def invoke(self, uri, method, params=None, *, timeout=30, environment_name if uri == EXT_CLOCK_URI: return {"armed": True, "virtual_time": _z(self.now), "virtual_seconds_per_real_second": self.rate} for row in self.rows.values(): + if row.get("stuck"): + continue if row["status"] == "firing" and self.outcome == "fired": row.update(status="armed", fire_count=row["fire_count"] + 1) elif row["status"] == "armed" and row["next_mark"] and _dt(row["next_mark"]) <= self.now: @@ -280,6 +282,22 @@ async def test_a_recurring_trigger_still_mid_fire_keeps_its_next_mark_in_the_sch assert len(seen["solver"]) == 3 +@pytest.mark.asyncio +async def test_an_unfinished_recurring_fire_does_not_hold_time_at_its_mark(monkeypatch): + seen = _wire(monkeypatch) + env = _Env({"later": DAY2}) + env.rows["daily"] = {"id": "daily", "type": "time", "status": "firing", "next_mark": DAY1, "fire_count": 1, + "failure_count": 0, "when": {"type": "time", "every": "P1D"}, "stuck": True} + + ctx = await _step({"mode": "next_env_event", "fire_timeout_seconds": 0.01}, turns=5).execute(_context(env)) + + assert env.moves() == [("advance", {"virtual_time": DAY1}), ("advance", {"virtual_time": DAY2})] + advances = ctx.metadata["env_event_advances"]["watch"] + assert advances[0]["statuses"] == {"env-1": {"daily": "timeout"}} + assert advances[1]["statuses"] == {"env-1": {"later": "fired"}} + assert advances[2]["stopped"] == "no_pending_time_triggers" and len(seen["solver"]) == 3 + + @pytest.mark.asyncio async def test_a_gateway_without_advance_is_rearmed_at_its_own_rate(monkeypatch): _wire(monkeypatch) From eda2fe5031b8af78dfcd06d2526aca03ae9861d8 Mon Sep 17 00:00:00 2001 From: Sarah Chitty Date: Tue, 20 Oct 2026 18:00:43 -0700 Subject: [PATCH 08/15] review: the jump to end_time refuses a re-arm over a relative or recurring 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 --- src/agent_env/task_step/task_steps/prompt_agent.py | 8 ++++---- tst/unit/task_step/test_prompt_agent_between_turns.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/agent_env/task_step/task_steps/prompt_agent.py b/src/agent_env/task_step/task_steps/prompt_agent.py index 02c5a8e68..0d4aa3977 100644 --- a/src/agent_env/task_step/task_steps/prompt_agent.py +++ b/src/agent_env/task_step/task_steps/prompt_agent.py @@ -974,18 +974,18 @@ async def _advance_to_next_env_event(self, context: TaskStepContext, advance: di mark = min((m for m, _, _ in pending), default=None) if mark is None or (end is not None and mark > end): if end is not None: - await self._move_env_clocks(envs, end, check_marks=False) + await self._move_env_clocks(envs, end) return {"stopped": "end_time" if mark is not None else "no_pending_time_triggers", "virtual_time": _iso(end) if end is not None else None} due = [(d, t) for m, d, t in pending if m == mark] - moved = await self._move_env_clocks(envs, mark, check_marks=True) + moved = await self._move_env_clocks(envs, mark) statuses = await self._wait_fired(context, due, advance["fire_timeout_seconds"]) # A mark whose trigger didn't settle is not chosen again, so an unfinished fire can't hold time still. timed_out.update((d.env_id, t["id"], t["next_mark"]) for d, t in due if statuses[d.env_id][t["id"]] == "timeout") trigger_ids = list(dict.fromkeys(t["id"] for _, t in due)) return {"mark": _iso(mark), "trigger_ids": trigger_ids, "statuses": statuses, "moved": moved} - async def _move_env_clocks(self, envs: list, target: datetime, *, check_marks: bool) -> dict: + async def _move_env_clocks(self, envs: list, target: datetime) -> dict: """Bring each env clock behind ``target`` up to it, by advance where offered, else a re-arm only fixed marks survive.""" moved = {} for d, clock, rows in envs: @@ -994,7 +994,7 @@ async def _move_env_clocks(self, envs: list, target: datetime, *, check_marks: b if d.supports(EXT_CLOCK_URI, "advance"): moved[d.env_id] = await self._advance_clock(d, target) continue - unsafe = [t["id"] for t in rows if check_marks and t.get("type") == "time" + unsafe = [t["id"] for t in rows if t.get("type") == "time" and t.get("status") in _SCHEDULED_STATUSES and not _rearm_safe(t.get("when") or {})] if unsafe: raise RuntimeError(f"{self.id}: env '{d.env_id}' has no clock advance, and a re-arm would re-anchor the " diff --git a/tst/unit/task_step/test_prompt_agent_between_turns.py b/tst/unit/task_step/test_prompt_agent_between_turns.py index 60195eb13..1ce4948e2 100644 --- a/tst/unit/task_step/test_prompt_agent_between_turns.py +++ b/tst/unit/task_step/test_prompt_agent_between_turns.py @@ -318,6 +318,16 @@ async def test_a_gateway_without_advance_refuses_to_rearm_over_a_relative_mark(m assert env.moves() == [] +@pytest.mark.asyncio +async def test_a_gateway_without_advance_refuses_to_rearm_to_end_time_over_a_relative_mark(monkeypatch): + _wire(monkeypatch) + env = _Env({"reminder": "P1D"}, advance=False) + + with pytest.raises(RuntimeError, match=r"re-anchor .*\['reminder'\]"): + await _step({"mode": "next_env_event", "end_time": "2026-03-02T20:00:00Z"}).execute(_context(env)) + assert env.moves() == [] + + @pytest.mark.asyncio async def test_a_failed_or_stuck_trigger_is_reported_and_the_agent_still_woken(monkeypatch): seen = _wire(monkeypatch) From b8f659ea588ead406fae8e32fe0db7160651ebfd Mon Sep 17 00:00:00 2001 From: Sarah Chitty Date: Mon, 26 Oct 2026 08:56:08 -0700 Subject: [PATCH 09/15] review: prompt_agent.post_turn_hooks replace between_turns, and every 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 --- .../agentenv_protocol/a2a_agent/_triggers.py | 3 + .../agentenv_protocol/a2a_agent/extensions.py | 1 + .../agentenv_protocol/a2a_agent/framework.py | 1 + .../agentenv-protocol/tests/test_a2a_agent.py | 17 + src/agent_env/a2a_agent/conversation_store.py | 8 +- .../task_step/task_steps/post_turn_hooks.py | 259 +++++++++ .../task_step/task_steps/prompt_agent.py | 461 ++++++--------- .../task_steps/verifiers/__init__.py | 3 +- .../task_steps/verifiers/run_context.py | 14 +- .../agents/claude_code/test_card_contract.py | 3 +- tst/unit/agents/codex/test_card_contract.py | 3 +- tst/unit/agents/gemini/test_card_contract.py | 3 +- .../test_prompt_agent_between_turns.py | 412 ------------- .../test_prompt_agent_post_turn_hooks.py | 542 ++++++++++++++++++ .../task_step/test_prompt_agent_user_side.py | 477 +++++++++++++++ .../task_step/verifiers/test_run_context.py | 9 +- 16 files changed, 1518 insertions(+), 698 deletions(-) create mode 100644 src/agent_env/task_step/task_steps/post_turn_hooks.py delete mode 100644 tst/unit/task_step/test_prompt_agent_between_turns.py create mode 100644 tst/unit/task_step/test_prompt_agent_post_turn_hooks.py create mode 100644 tst/unit/task_step/test_prompt_agent_user_side.py diff --git a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/_triggers.py b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/_triggers.py index 6fbc1644b..f274fa03e 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/_triggers.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/_triggers.py @@ -206,6 +206,7 @@ def decide( solver_message: str = "", context_id: str = "default", env_triggers: Any = None, + record_unfired: bool = True, ) -> dict[str, Any]: if isinstance(turn, bool) or not isinstance(turn, int) or turn < 1: raise TriggerError("decide: 'turn' must be a positive integer") @@ -256,6 +257,8 @@ def decide( elif action["type"] == "end": done = True + if not fired_ids and not record_unfired: + return {"parts": [], "done": False, "fired": []} # Commit only after every predicate succeeds. A timed-out decision can then # be retried without consuming the turn or partially firing once-only rules. context = self._context_state(context_id) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/extensions.py b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/extensions.py index a35c19bbf..af0b0af87 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/extensions.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/extensions.py @@ -285,6 +285,7 @@ class TriggerDecideRequest(ExtensionRequest): solver_message: str = Field(default="", max_length=MAX_SOLVER_MESSAGE_LENGTH) context_id: str = "default" env_triggers: dict[str, dict[str, Any]] | None = None + record_unfired: bool = True # False: a decision where nothing fires changes no state, so it can be repeated def _tuple(values: Iterable[str]) -> tuple[str, ...]: diff --git a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/framework.py b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/framework.py index 5cdcef4dd..1cccdd2bd 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/framework.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/framework.py @@ -836,6 +836,7 @@ async def triggers_decide(self, request: TriggerDecideRequest) -> dict[str, Any] solver_message=request.solver_message, context_id=request.context_id, env_triggers=request.env_triggers, + record_unfired=request.record_unfired, ) async def triggers_state(self) -> dict[str, Any]: diff --git a/packages/agentenv-protocol/tests/test_a2a_agent.py b/packages/agentenv-protocol/tests/test_a2a_agent.py index 92c7c4b68..422b4e334 100644 --- a/packages/agentenv-protocol/tests/test_a2a_agent.py +++ b/packages/agentenv-protocol/tests/test_a2a_agent.py @@ -302,6 +302,23 @@ def trigger(trigger_id: str) -> dict[str, Any]: engine.decide(turn=1, context_id="x" * 257) +def test_a_decision_where_nothing_fires_can_leave_no_trace() -> None: + engine = TriggerEngine() + engine.register({"triggers": [{ + "id": "julia", + "when": {"type": "env_trigger", "env_id": "mail", "trigger_id": "school", "status": "fired"}, + "actions": [{"type": "say", "text": "did you see it?"}], + }]}) + fired = {"mail": {"school": "fired"}} + + assert engine.decide(turn=1, record_unfired=False)["fired"] == [] + assert engine.decide(turn=1, env_triggers=fired)["fired"] == ["julia"] + assert engine.decide(turn=2, env_triggers=fired, record_unfired=False)["fired"] == [] + # Recorded, turn 2 would now repeat a turn and reset, firing the once-only trigger again. + assert engine.decide(turn=2, env_triggers=fired)["fired"] == [] + assert [entry["kind"] for entry in engine.state()["firing_log"]] == ["registered", "fired"] + + def test_trigger_engine_bounds_retained_trigger_content() -> None: engine = TriggerEngine() diff --git a/src/agent_env/a2a_agent/conversation_store.py b/src/agent_env/a2a_agent/conversation_store.py index 7f4781dfa..8721e4bf4 100644 --- a/src/agent_env/a2a_agent/conversation_store.py +++ b/src/agent_env/a2a_agent/conversation_store.py @@ -109,8 +109,8 @@ def find_by_a2a_task_id(a2a_task_id: str) -> Optional[dict]: ) -def _message(role: str, parts: list[dict], now: datetime, env_clock: Optional[dict]) -> dict: - """A conversation message; ``env_clock`` maps each env to its virtual time when the message was recorded.""" +def _message(role: str, parts: list[dict], now: datetime, env_clock: Optional[list[dict]]) -> dict: + """A conversation message; ``env_clock`` holds each armed env clock's virtual time when it was recorded.""" message = {"role": role, "parts": parts, "ts": now.isoformat()} if env_clock is not None: message["env_clock"] = env_clock @@ -122,7 +122,7 @@ def add_a2a_task( parts: list[dict], a2a_task_id: str, role: str, - env_clock: Optional[dict] = None, + env_clock: Optional[list[dict]] = None, ) -> Optional[dict]: """Append a caller-side message and create a new working a2a_task. @@ -170,7 +170,7 @@ def complete_a2a_task( conversation_id: str, parts: list[dict], role: str, - env_clock: Optional[dict] = None, + env_clock: Optional[list[dict]] = None, ) -> Optional[dict]: """Append a responder-side message and complete the oldest working a2a_task. diff --git a/src/agent_env/task_step/task_steps/post_turn_hooks.py b/src/agent_env/task_step/task_steps/post_turn_hooks.py new file mode 100644 index 000000000..691063466 --- /dev/null +++ b/src/agent_env/task_step/task_steps/post_turn_hooks.py @@ -0,0 +1,259 @@ +"""prompt_agent's post-turn hooks: task-referenced Python run after each agent turn, trusted as a verifier script is.""" +from __future__ import annotations + +import asyncio +import copy +import functools +import importlib.util +import inspect +import os +import tempfile +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass +from types import MappingProxyType +from typing import Any, Optional + +import httpx +from a2a.types import Part +from agentenv_protocol import client as protocol_v1 + +from agent_env.artifact import FileArtifact +from agent_env.env.env import DeployedEnv, EnvCapabilityUnsupported +from agent_env.env.gateway.constants import ( + AGENT_ENV_ROLE_HEADER, + EXT_CLOCK_URI, + EXT_STEP_URI, + EXT_TRIGGERS_URI, + STEP_SOURCE, + STEP_SOURCE_POST_TURN_HOOK, +) +from agent_env.task_step.context import TaskStepContext, _strip_redacted_keys + +_SPEC_KEYS = ("file_artifact_id", "file_artifact_version", "params") +_RECORD_TAGS = ("hook", "turn") +_INVOKABLE = (EXT_CLOCK_URI, EXT_TRIGGERS_URI) +_TOOL_CALL_TIMEOUT_S = 600 + +Answer = dict[str, Any] + + +def validate_hook_specs(specs: Any) -> None: + """Refuse a ``post_turn_hooks`` value that names no hook artifact the way the step loads one.""" + if not isinstance(specs, list): + raise ValueError("post_turn_hooks must be a list of {file_artifact_id, file_artifact_version?, params?}") + for i, spec in enumerate(specs): + where = f"post_turn_hooks[{i}]" + if not isinstance(spec, dict): + raise ValueError(f"{where} must be an object, got {type(spec).__name__}") + unknown = sorted(set(spec) - set(_SPEC_KEYS)) + if unknown: + raise ValueError(f"{where} has unknown key(s) {unknown}; expected {list(_SPEC_KEYS)}") + if not isinstance(spec.get("file_artifact_id"), str) or not spec["file_artifact_id"]: + raise ValueError(f"{where}.file_artifact_id must be a non-empty string") + version = spec.get("file_artifact_version") + if version is not None and (isinstance(version, bool) or not isinstance(version, int) or version < 1): + raise ValueError(f"{where}.file_artifact_version must be a positive integer, got {version!r}") + if spec.get("params") is not None and not isinstance(spec["params"], dict): + raise ValueError(f"{where}.params must be an object") + + +@dataclass(frozen=True) +class PostTurnHook: + artifact_id: str + version: Optional[int] + params: dict + post_turn: Callable[..., Awaitable[Optional[dict]]] + + +class PostTurnHooks: + """The hooks of one step run, each loaded once; ``close`` removes their scripts' temporary files.""" + + def __init__(self) -> None: + self.hooks: list[PostTurnHook] = [] + self._paths: list[str] = [] + + @classmethod + async def load(cls, specs: list[dict], step_id: str) -> PostTurnHooks: + loaded = cls() + try: + for index, spec in enumerate(specs): + loaded.hooks.append(await loaded._load(spec, f"post_turn_hook_{step_id}_{index}")) + except BaseException: + loaded.close() + raise + return loaded + + async def _load(self, spec: dict, module_name: str) -> PostTurnHook: + artifact = FileArtifact.get(spec["file_artifact_id"], spec.get("file_artifact_version")) + source = await asyncio.to_thread(artifact.load) + fd, path = tempfile.mkstemp(suffix=".py") + self._paths.append(path) + os.write(fd, source) + os.close(fd) + module_spec = importlib.util.spec_from_file_location(module_name, path) + module = importlib.util.module_from_spec(module_spec) + module_spec.loader.exec_module(module) + post_turn = getattr(module, "post_turn", None) + if not inspect.iscoroutinefunction(post_turn): + raise ValueError(f"post-turn hook '{spec['file_artifact_id']}' must define " + "`async def post_turn(turn, params)`: steps and runs share one event loop") + return PostTurnHook(spec["file_artifact_id"], spec.get("file_artifact_version"), spec.get("params") or {}, + post_turn) + + def close(self) -> None: + for path in self._paths: + if os.path.exists(path): + os.unlink(path) + self._paths.clear() + + +class HookEnv: + """A deployed env as a hook reaches it: clock and triggers calls, and tool calls recorded as the hook's.""" + + def __init__(self, deployed: DeployedEnv, hook_artifact_id: str) -> None: + self.env_id = deployed.env_id + self.env_step_id = (deployed.metadata or {}).get("deploy_step_id") + self.instance_id = deployed.instance_id + self._deployed = deployed + self._hook_artifact_id = hook_artifact_id + + def supports(self, extension: str, method: str) -> bool: + return self._deployed.supports(extension, method) + + async def invoke(self, extension: str, method: str, params: Optional[dict] = None) -> Any: + if extension not in _INVOKABLE: + raise ValueError(f"a post-turn hook invokes {list(_INVOKABLE)} only; call tools with call_tool") + return await self._deployed.invoke(extension, method, params) + + async def call_tool(self, tool: str, args: Optional[dict], role: str) -> dict: + """Call ``tool`` as ``role`` through /step, so role rules, triggers watching the role and barriers apply.""" + if not isinstance(role, str) or not role: + raise ValueError("call_tool needs the AgentEnv-Role to call as") + card = self._deployed.environment_card or {} + op = protocol_v1.find_extension_method(card, EXT_STEP_URI, "step") + if op is None or not self._deployed.environment_url: + raise EnvCapabilityUnsupported(EXT_STEP_URI, "step", self.env_id, self.instance_id) + if STEP_SOURCE not in (protocol_v1.extension_params(card, EXT_STEP_URI).get("features") or []): + raise RuntimeError(f"env '{self.env_id}' can't record a post-turn hook's tool calls as the hook's; " + "rebuild its gateway image") + body = {"action": "call_tool", "tool_name": tool, "arguments": args or {}, + "source": STEP_SOURCE_POST_TURN_HOOK, "hook_artifact_id": self._hook_artifact_id} + async with httpx.AsyncClient() as client: + resp = await client.request(op["method"], f"{self._deployed.environment_url}{op['endpoint']}", json=body, + headers={AGENT_ENV_ROLE_HEADER: role}, timeout=_TOOL_CALL_TIMEOUT_S) + if resp.status_code >= 400: + raise RuntimeError(f"env '{self.env_id}': /step call_tool {tool!r} failed (HTTP {resp.status_code}): " + f"{resp.text[:300]}") + return resp.json() + + +class _Answer: + """The user side's answer this turn: asked at most once, then the same for every later call.""" + + def __init__(self, ask_user: Callable[[], Awaitable[Answer]], + user_triggers: Callable[[], Awaitable[Optional[Answer]]]) -> None: + self.value: Optional[Answer] = None + self._ask_user = ask_user + self._user_triggers = user_triggers + self._asking = asyncio.Lock() + + async def ask_user(self) -> Answer: + async with self._asking: + if self.value is None: + self.value = await self._ask_user() + return self.value + + async def user_triggers(self) -> Optional[Answer]: + async with self._asking: + if self.value is None: + self.value = await self._user_triggers() + return self.value + + +class Turn: + """What a hook is handed after an agent turn; ``last`` after the final one, when a returned message is ignored.""" + + def __init__(self, *, context: TaskStepContext, step_id: str, hook: PostTurnHook, answer: _Answer, number: int, + last: bool, agent_reply: str, agent_parts: list[dict]) -> None: + self.number = number + self.last = last + self.agent_reply = agent_reply + self.agent_parts = copy.deepcopy(agent_parts) + self.seed = copy.deepcopy(context.metadata.get("seed", {})) + self.envs = [HookEnv(d, hook.artifact_id) for d in context.deployed_envs] + self._context = context + self._step_id = step_id + self._hook = hook + self._answer = answer + + @functools.cached_property + def metadata(self) -> Mapping[str, Any]: + """A read-only copy of the run's metadata, its secret keys stripped.""" + return MappingProxyType(_strip_redacted_keys(self._context.metadata)) + + async def ask_user(self) -> Answer: + """Today's user side: agent triggers, then a human or the LLM user sim, which sees only replies sent here.""" + return await self._answer.ask_user() + + async def user_triggers(self) -> Optional[Answer]: + """Agent triggers alone; None, leaving no trace, when none fires.""" + return await self._answer.user_triggers() + + def record(self, entry: dict) -> None: + """Append ``entry`` to ``metadata["post_turn_hooks"][step_id]``, tagged with this hook and turn.""" + if not isinstance(entry, dict) or set(entry) & set(_RECORD_TAGS): + raise ValueError(f"a hook records an object, without the keys {list(_RECORD_TAGS)} it is tagged with") + (self._context.metadata.setdefault("post_turn_hooks", {}).setdefault(self._step_id, []) + .append({"hook": self._hook.artifact_id, "turn": self.number, **copy.deepcopy(entry)})) + + def heartbeat(self) -> None: + self._context.heartbeat() + + +def _decision(value: Any, hook: PostTurnHook) -> Optional[Answer]: + """A hook's return as an answer: ``{"message": parts}`` or ``{"end": reason}`` with an optional closing message.""" + if value is None: + return None + shape = "None, {'message': text or parts}, or {'end': reason} with an optional closing 'message'" + if not isinstance(value, dict) or not value or set(value) - {"message", "end"}: + raise RuntimeError(f"post-turn hook '{hook.artifact_id}' returned {value!r}; expected {shape}") + if "end" in value and (not isinstance(value["end"], str) or not value["end"]): + raise RuntimeError(f"post-turn hook '{hook.artifact_id}' ended with {value['end']!r}; the reason is a string") + answer: Answer = {"end": value["end"]} if "end" in value else {} + if "message" in value: + answer["message"] = _parts(value["message"], hook) + return answer + + +def _parts(message: Any, hook: PostTurnHook) -> list[dict]: + if isinstance(message, str): + return [{"kind": "text", "text": message}] + if not isinstance(message, list) or not message: + raise RuntimeError(f"post-turn hook '{hook.artifact_id}': a message is text or a non-empty list of A2A parts") + for i, part in enumerate(message): + try: + Part.model_validate(part) + except Exception as e: + raise RuntimeError(f"post-turn hook '{hook.artifact_id}': message part {i} is not an A2A Part: {e}") from e + return copy.deepcopy(message) + + +async def run_post_turn_hooks(hooks: PostTurnHooks, *, context: TaskStepContext, step_id: str, number: int, last: bool, + agent_reply: str, agent_parts: list[dict], + ask_user: Callable[[], Awaitable[Answer]], + user_triggers: Callable[[], Awaitable[Optional[Answer]]]) -> Optional[Answer]: + """Run the hooks in order; the first to return a message or an end decides, and one that asked returns that.""" + answer = _Answer(ask_user, user_triggers) + for hook in hooks.hooks: + turn = Turn(context=context, step_id=step_id, hook=hook, answer=answer, number=number, last=last, + agent_reply=agent_reply, agent_parts=agent_parts) + returned = await hook.post_turn(turn, copy.deepcopy(hook.params)) + if answer.value is not None: + if returned != answer.value: + raise RuntimeError(f"post-turn hook '{hook.artifact_id}' asked the user side at turn {number} and " + "must return its answer") + return answer.value + decision = _decision(returned, hook) + if decision is not None: + return decision + return None diff --git a/src/agent_env/task_step/task_steps/prompt_agent.py b/src/agent_env/task_step/task_steps/prompt_agent.py index 5a2d1d63c..13b2e6d76 100644 --- a/src/agent_env/task_step/task_steps/prompt_agent.py +++ b/src/agent_env/task_step/task_steps/prompt_agent.py @@ -3,9 +3,9 @@ import asyncio import copy +import functools import json import logging -import math import time import uuid from contextlib import nullcontext @@ -13,6 +13,7 @@ from typing import Any, ClassVar, Optional import httpx from a2a.types import TaskState +from agentenv_protocol.a2a_agent import card_request_accepts from agent_env.a2a_agent import A2AAgent, conversation_store from agent_env.a2a_agent import protocol @@ -28,10 +29,11 @@ from agent_env.env.gateway.constants import EXT_CLOCK_URI, EXT_TRIGGERS_URI, TRIGGER_IN_FLIGHT_STATUSES from agent_env.store import DuplicateKeyError, get_config from agent_env.store.object_store import ObjectStore -from agent_env.task_step.context import PromptResponse, TaskStepContext +from agent_env.task_step.context import DeployedAgent, PromptResponse, TaskStepContext from agent_env.entity.refs import EntityRef from agent_env.entity.scopes import FieldScope from agent_env.task_step.task_step import RetryConfig, TaskStep, TaskStepDependency +from agent_env.task_step.task_steps.post_turn_hooks import PostTurnHooks, run_post_turn_hooks, validate_hook_specs from agent_env.task_step.task_steps.sandbox_utils.sandbox_utils import ( agent_error_text, fetch_container_logs, @@ -103,6 +105,9 @@ def _file_uris(parts: list[dict]) -> list[str]: return [file["uri"] for file in files if isinstance(file, dict) and isinstance(file.get("uri"), str)] +# The decide body that asks a user agent to record nothing when no agent trigger fires. +_QUIET_DECIDE = ("turn", "solver_message", "context_id", "env_triggers", "record_unfired") + _DEFAULT_USER_SIM_OUTPUT_FORMAT: dict[str, Any] = { "type": "json_schema", "schema": { @@ -116,71 +121,14 @@ def _file_uris(parts: list[dict]) -> list[str]: } -NEXT_ENV_EVENT = "next_env_event" -DEFAULT_WAKE_MESSAGE = "[{virtual_time}] Time has passed. Check for anything new and handle it." -_BETWEEN_TURNS_KEYS = ("mode", "wake", "wake_message", "end_time", "fire_timeout_seconds") -_WAKES = ("message", "decide") -_SETTLED_STATUSES = ("fired", "failed", "cancelled") -_SCHEDULED_STATUSES = ("armed", *TRIGGER_IN_FLIGHT_STATUSES) -_FIRE_POLL_SECONDS = 0.5 - - -def _utc(text: str) -> datetime: - """An RFC 3339 instant as an aware UTC datetime; ValueError without an offset.""" - parsed = datetime.fromisoformat(text.replace("Z", "+00:00").replace("z", "+00:00")) - if parsed.tzinfo is None: - raise ValueError(f"{text!r} has no UTC offset") - return parsed.astimezone(timezone.utc) - - -def _iso(value: datetime) -> str: - return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") - - -def _between_turns_spec(raw: Any) -> Optional[dict]: - """``between_turns`` with its defaults filled in, refusing a shape the turn loop can't follow; None when unset.""" - if raw is None: - return None - spec = {"mode": raw} if isinstance(raw, str) else raw - if not isinstance(spec, dict): - raise ValueError(f"between_turns must be {NEXT_ENV_EVENT!r} or an object with a mode") - unknown = sorted(set(spec) - set(_BETWEEN_TURNS_KEYS)) - if unknown: - raise ValueError(f"between_turns has unknown key(s) {unknown}; expected {list(_BETWEEN_TURNS_KEYS)}") - if spec.get("mode") != NEXT_ENV_EVENT: - raise ValueError(f"between_turns.mode must be {NEXT_ENV_EVENT!r}, got {spec.get('mode')!r}") - out = {"mode": NEXT_ENV_EVENT, "wake": spec.get("wake", "message"), - "wake_message": spec.get("wake_message", DEFAULT_WAKE_MESSAGE), "end_time": spec.get("end_time"), - "fire_timeout_seconds": spec.get("fire_timeout_seconds", 60)} - if out["wake"] not in _WAKES: - raise ValueError(f"between_turns.wake must be one of {list(_WAKES)}, got {out['wake']!r}") - if not isinstance(out["wake_message"], str) or not out["wake_message"].strip(): - raise ValueError("between_turns.wake_message must be a non-empty string") - try: - out["wake_message"].format(virtual_time="", trigger_ids="") - except (KeyError, IndexError, ValueError) as e: - raise ValueError(f"between_turns.wake_message may use only {{virtual_time}} and {{trigger_ids}}: {e!r}") from e - if out["end_time"] is not None: - try: - _utc(out["end_time"]) - except (TypeError, AttributeError, ValueError) as e: - raise ValueError(f"between_turns.end_time must be an RFC 3339 instant: {e}") from e - timeout = out["fire_timeout_seconds"] - if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not math.isfinite(timeout) or timeout <= 0: - raise ValueError(f"between_turns.fire_timeout_seconds must be a positive number, got {timeout!r}") - return out - - -def _rearm_safe(when: dict) -> bool: - """Whether a time trigger keeps its mark across a clock re-arm: event-anchored, or one absolute `at`.""" - at = when.get("at") - return "after" in when or ("every" not in when and isinstance(at, str) and not at.startswith("P")) - - class PromptAgentTaskStep(TaskStep): type: ClassVar[str] = "prompt_agent" DEFAULT_USER_AGENT_NAME: ClassVar[str] = "human_agent" - entity_refs = (EntityRef.env("snapshot_config.env_id"),) + entity_refs = ( + EntityRef.env("snapshot_config.env_id"), + EntityRef.artifact("post_turn_hooks[].file_artifact_id", version_field="file_artifact_version", + artifact_type="file"), + ) # user_a2a_url stays the task's: setting it is also what tells a human counterpart from a simulator. field_scopes = {**TaskStep.field_scopes, **dict.fromkeys( ("model", "model_params", "effort", "max_thinking_tokens", "harness", "user_model", "trajectory_output_prefix"), @@ -221,8 +169,7 @@ def __init__( *, snapshot_config: Optional[SnapshotConfig] = None, live_trajectory: bool = False, - between_turns: Optional[str | dict] = None, - record_env_clock: bool = False, + post_turn_hooks: Optional[list[dict]] = None, ): # `prompt` is the legacy text-only field; `parts` is the multimodal # form that's the authoritative A2A wire shape. Exactly one must be set: @@ -236,9 +183,8 @@ def __init__( self._validate_parts(parts) if max_conversation_turns < 1: raise ValueError("max_conversation_turns must be >= 1") - advance = _between_turns_spec(between_turns) - if advance is not None and max_conversation_turns < 2: - raise ValueError("between_turns needs max_conversation_turns >= 2: it acts between turns") + if post_turn_hooks is not None: + validate_hook_specs(post_turn_hooks) if model_params: reserved = set(model_params) & MODEL_PARAMS_RESERVED if reserved: @@ -269,9 +215,7 @@ def __init__( self.user_model = user_model self.snapshot_config = snapshot_config self.live_trajectory = live_trajectory - self.between_turns = between_turns - self.record_env_clock = record_env_clock - self._advance = advance + self.post_turn_hooks = post_turn_hooks @staticmethod def _validate_parts(parts: list[dict]) -> None: @@ -347,10 +291,8 @@ def to_dict(self) -> dict: base["snapshot_config"] = self.snapshot_config.to_dict() if self.live_trajectory: base["live_trajectory"] = True - if self.between_turns is not None: - base["between_turns"] = self.between_turns - if self.record_env_clock: - base["record_env_clock"] = True + if self.post_turn_hooks is not None: + base["post_turn_hooks"] = self.post_turn_hooks return base @classmethod @@ -387,8 +329,7 @@ def from_dict(cls, data: dict) -> PromptAgentTaskStep: else None ), live_trajectory=data.get("live_trajectory", False), - between_turns=data.get("between_turns"), - record_env_clock=data.get("record_env_clock", False), + post_turn_hooks=data.get("post_turn_hooks"), ) def _trajectory_prefix(self) -> str: @@ -486,6 +427,21 @@ async def _execute_conversation( *, a2a_context_id: str, series: Optional[SnapshotSeries] = None, + ) -> TaskStepContext: + hooks = await PostTurnHooks.load(self.post_turn_hooks, self.id) if self.post_turn_hooks else None + try: + return await self._converse(context, a2a_context_id=a2a_context_id, series=series, hooks=hooks) + finally: + if hooks is not None: + hooks.close() + + async def _converse( + self, + context: TaskStepContext, + *, + a2a_context_id: str, + series: Optional[SnapshotSeries], + hooks: Optional[PostTurnHooks], ) -> TaskStepContext: agent = next((a for a in context.deployed_agents if a.agent_name == self.agent_name), None) if agent is None: @@ -495,7 +451,8 @@ async def _execute_conversation( user_url = self.user_a2a_url or ( (user_sim.a2a_url or user_sim.api_url) if user_sim else None ) - if user_url is None and user_sim is None and self.max_conversation_turns > 1 and self._advance is None: + # With hooks, a human peer is looked up only once one is asked. + if user_url is None and user_sim is None and self.max_conversation_turns > 1 and hooks is None: user_url = get_config().get_default_human_a2a_url() trajectory_output_prefix = self._trajectory_prefix() @@ -550,6 +507,7 @@ async def _execute_conversation( ) trig_ext = A2AAgent.find_extension(user_sim.a2a_card or {}, A2AAgent.EXT_TRIGGERS) if is_user_sim else None decide_url = state_url = None + decide_method: dict = {} if trig_ext and triggers_registered: decide_method = A2AAgent.extension_method(user_sim.a2a_card, A2AAgent.EXT_TRIGGERS, "decide") or {} decide_url = user_url + decide_method.get("endpoint", "/ext/triggers/decide") @@ -558,13 +516,6 @@ async def _execute_conversation( logger.warning( f"user agent '{self.user_agent_name}' advertises {A2AAgent.EXT_TRIGGERS} but " f"max_conversation_turns={self.max_conversation_turns} (<2); the trigger engine is never invoked") - advance = self._advance - if advance is not None and advance["wake"] == "decide" and not (trig_ext and triggers_registered): - raise RuntimeError(f"{self.id}: between_turns wake 'decide' needs user agent '{self.user_agent_name}' to be a " - f"user simulator with agent triggers registered") - clock_envs = ([d for d in context.deployed_envs if d.supports(EXT_CLOCK_URI, "state")] - if advance is not None or self.record_env_clock else None) - timed_out: set[tuple[str, str, str]] = set() solver_context_id = a2a_context_id if self.context_id: @@ -589,6 +540,7 @@ async def _execute_conversation( ) current_user_parts = initial_parts + clock_envs = [d for d in context.deployed_envs if d.supports(EXT_CLOCK_URI, "state")] final_terminal = protocol.TerminalResponse(response_text="") # Each turn's terminal reports only that turn's tool calls, so the conversation's count is # their sum. None until a turn reports one, so agents that don't report it stay None. @@ -606,6 +558,11 @@ async def _execute_conversation( # What the target has been sent: of the files its replies name, the only ones a user-sim is made able # to read, so a reply naming any other object a store owns can't read it out through the user-sim. sent_to_target: set[str] = set() + user_side = _UserSide( + self, context, url=user_url, user_sim=user_sim, is_user_sim=is_user_sim, conversation_id=conversation_id, + decide_url=decide_url, shareable=sent_to_target, + decides_quietly=card_request_accepts(decide_method.get("request") or {}, _QUIET_DECIDE), + ) try: for turn in range(self.max_conversation_turns): @@ -613,7 +570,7 @@ async def _execute_conversation( # agent and recorded on the conversation as `a2a_task_id`. # This id is also used as part of the key name for the trajectory S3 object. target_a2a_task_id = uuid.uuid4().hex - sent_clock = await self._env_clock_kwargs(clock_envs) + sent_clock = await self._env_clock(clock_envs) # The turn records the objects' own URLs, and only once the parts are ready to send, so an # object the agent can't be sent leaves no turn waiting. @@ -657,7 +614,7 @@ def record_turn() -> None: conversation_id=conversation_id, parts=agent_response_parts, role="agent", - **(await self._env_clock_kwargs(clock_envs)), + **(await self._env_clock(clock_envs)), ) if traj_ext_cached: @@ -678,7 +635,8 @@ def record_turn() -> None: if target_state == TaskState.failed: conversation_store.mark_closed(conversation_id) break - if turn == self.max_conversation_turns - 1: + last = turn == self.max_conversation_turns - 1 + if last and hooks is None: break conv_doc = conversation_store.get_conversation(conversation_id) @@ -688,92 +646,30 @@ def record_turn() -> None: # After every `break` above: a turn that ends the conversation is covered # by teardown's final capture instead of being captured twice. - if series is not None: + if series is not None and not last: await series.capture_turn(context, turn + 1) - if advance is not None: - event = await self._advance_to_next_env_event(context, advance, timed_out) - (context.metadata.setdefault("env_event_advances", {}) - .setdefault(self.id, []).append({"turn": turn + 1, **event})) - if "stopped" in event: - logger.info(f"Turn {turn+1}: no further env event ({event['stopped']}); ending multi-turn") - conversation_store.mark_closed(conversation_id) - break - - if trig_ext and triggers_registered and (advance is None or advance["wake"] == "decide"): - env_triggers = await self._read_env_triggers(context) - (context.metadata.setdefault("env_trigger_snapshots", {}) - .setdefault(self.id, []).append({"turn": turn + 1, "envs": env_triggers})) - resp = await self._decide(decide_url, turn + 1, final_terminal.response_text, conversation_id, env_triggers) - (context.metadata.setdefault("agent_trigger_firings", {}) - .setdefault(self.id, []).append({"turn": turn + 1, "fired": resp.get("fired", [])})) - logger.info(f"Turn {turn+1} agent-trigger decide: fired={resp.get('fired', [])} done={resp.get('done')}") - if bool(resp.get("done")): - current_user_parts = resp.get("parts") or [] - conversation_store.add_a2a_task(conversation_id, parts=current_user_parts, - a2a_task_id=uuid.uuid4().hex, role="user", - **(await self._env_clock_kwargs(clock_envs))) - conversation_store.mark_closed(conversation_id) - break - if resp.get("fired"): - current_user_parts = resp.get("parts") or [] - continue - # no trigger fired -> fall through to the LLM user-sim for this turn (hybrid) - - if advance is not None: - text = advance["wake_message"].format(virtual_time=event["mark"], - trigger_ids=", ".join(event["trigger_ids"])) - current_user_parts = [{"kind": "text", "text": text}] - continue - - user_a2a_task_id = uuid.uuid4().hex - try: - # A user-sim runs in a sandbox agent-env deployed; a human peer, registered or named by - # user_a2a_url, has none and reads the store itself. - _, user_result = await send_and_wait( - user_url, agent_response_parts, - agent=user_sim if is_user_sim and user_sim.sandbox_id else None, shareable=sent_to_target, - message_id=user_a2a_task_id, context_id=conversation_id, - timeout_seconds=self.user_agent_timeout_seconds, - poll_interval_seconds=self.poll_interval_seconds, + answer = None + if hooks is not None: + reply = final_terminal.response_text + answer = await run_post_turn_hooks( + hooks, context=context, step_id=self.id, number=turn + 1, last=last, agent_reply=reply, + agent_parts=agent_response_parts, + ask_user=functools.partial(user_side.ask, turn + 1, reply, agent_response_parts), + user_triggers=functools.partial(user_side.triggers, turn + 1, reply), ) - except TimeoutError as e: - logger.warning(f"user_a2a_url timeout for conversation {conversation_id} ({e}); marking abandoned") - conversation_store.mark_closed(conversation_id) - break - - user_state = (user_result.get("status") or {}).get("state") - if user_state != TaskState.completed: - logger.info(f"user_a2a_url returned state={user_state}; ending multi-turn") + if answer is None and not last: + answer = await user_side.ask(turn + 1, final_terminal.response_text, agent_response_parts) + if answer is not None and "end" in answer: + if "message" in answer: + conversation_store.add_a2a_task(conversation_id, parts=answer["message"], + a2a_task_id=uuid.uuid4().hex, role="user", + **(await self._env_clock(clock_envs))) conversation_store.mark_closed(conversation_id) break - - user_msg = (user_result.get("status") or {}).get("message") or {} - current_user_parts = user_msg.get("parts") or [] - if not current_user_parts: - logger.warning("user_a2a_url returned no parts; ending multi-turn") - conversation_store.mark_closed(conversation_id) + if last: break - - if is_user_sim: - raw_text = next((p["text"] for p in current_user_parts if p.get("kind") == "text"), "") - try: - parsed = json.loads(raw_text) - message_text = parsed["message"] - done = bool(parsed["done"]) - except (json.JSONDecodeError, KeyError) as e: - logger.warning(f"user-sim returned malformed output despite output_format ({e}); using raw text as next prompt; raw={raw_text[:200]!r}") - current_user_parts = [{"kind": "text", "text": raw_text}] - else: - extras = {k: v for k, v in parsed.items() if k not in ("message", "done")} - if extras: - (context.metadata.setdefault("usersim_turn_outputs", {}) - .setdefault(self.id, []).append({"turn": turn + 1, "fields": extras})) - current_user_parts = [{"kind": "text", "text": message_text}] - if done: - logger.info(f"user-sim signaled done=true at turn {turn+1}; ending multi-turn") - conversation_store.mark_closed(conversation_id) - break + current_user_parts = answer["message"] finally: # On failure too, so a reader of the last turn knows no other follows. if live_turns is not None: @@ -918,6 +814,16 @@ async def _fetch_trajectory( fetched = await fetch_trajectory(a2a_url + get_path, {"task_id": a2a_server_task_id}, upload=upload, store=store) return await asyncio.to_thread(store_trajectory, fetched, trajectory_output_prefix, name=target_a2a_task_id) + async def _env_clock(self, envs: list) -> dict: + """``{"env_clock": [...]}``, each armed env clock for a conversation message, or {} when no clock is armed.""" + if not envs: + return {} + states = await asyncio.gather(*(self._get_clock_state(d) for d in envs)) + clocks = [{"env_id": d.env_id, "env_step_id": (d.metadata or {}).get("deploy_step_id"), + "virtual_time": state["virtual_time"]} + for d, state in zip(envs, states) if state and state.get("armed")] + return {"env_clock": clocks} if clocks else {} + async def _read_env_triggers(self, context: TaskStepContext) -> dict: """Snapshot each deployed env's triggers state as {env_id: {trigger_id: status}}; {} when its card offers none; fail-closed on a read error.""" env_triggers: dict = {} @@ -939,116 +845,17 @@ async def _read_env_triggers(self, context: TaskStepContext) -> dict: raise RuntimeError(f"fail-closed: could not read /triggers/state for env '{d.env_id}': {last_exc}") return env_triggers - async def _decide(self, decide_url: str, turn: int, solver_message: str, context_id: str, env_triggers: dict) -> dict: + async def _decide(self, decide_url: str, turn: int, solver_message: str, context_id: str, env_triggers: dict, + **quiet: bool) -> dict: """Call the usersim's /decide for the triggers that should fire this turn.""" - payload = {"turn": turn, "solver_message": solver_message, "context_id": context_id, "env_triggers": env_triggers} + payload = {"turn": turn, "solver_message": solver_message, "context_id": context_id, + "env_triggers": env_triggers, **quiet} async with httpx.AsyncClient() as client: resp = await client.post(decide_url, json=payload, timeout=self.user_agent_timeout_seconds) if resp.status_code >= 400: raise RuntimeError(f"agent-trigger /decide failed (HTTP {resp.status_code}): {resp.text}") return resp.json() - async def _env_clock_kwargs(self, envs: Optional[list]) -> dict: - """``{"env_clock": {env_id: virtual_time or None}}`` for a conversation message, or {} when not recording.""" - if envs is None: - return {} - states = await asyncio.gather(*(self._get_clock_state(d) for d in envs)) - return {"env_clock": {d.env_id: (s or {}).get("virtual_time") if (s or {}).get("armed") else None - for d, s in zip(envs, states)}} - - async def _advance_to_next_env_event(self, context: TaskStepContext, advance: dict, - timed_out: set[tuple[str, str, str]]) -> dict: - """Move armed env clocks to the earliest pending time-trigger mark and await its triggers; ``stopped`` once none is due.""" - envs = [] - for d in context.deployed_envs: - if not (d.supports(EXT_TRIGGERS_URI, "state") and d.supports(EXT_CLOCK_URI, "state")): - continue - clock = await self._get_clock_state(d) - if clock and clock.get("armed"): - envs.append((d, clock, (await self._get_trigger_state(d)).get("triggers", []))) - pending = [(_utc(t["next_mark"]), d, t) for d, _, rows in envs for t in rows - if t.get("type") == "time" and t.get("status") in _SCHEDULED_STATUSES and t.get("next_mark") - and (d.env_id, t["id"], t["next_mark"]) not in timed_out] - if not envs: - return {"stopped": "no_armed_env_clock", "virtual_time": None} - end = _utc(advance["end_time"]) if advance["end_time"] else None - mark = min((m for m, _, _ in pending), default=None) - if mark is None or (end is not None and mark > end): - if end is not None: - await self._move_env_clocks(envs, end) - return {"stopped": "end_time" if mark is not None else "no_pending_time_triggers", - "virtual_time": _iso(end) if end is not None else None} - due = [(d, t) for m, d, t in pending if m == mark] - moved = await self._move_env_clocks(envs, mark) - statuses = await self._wait_fired(context, due, advance["fire_timeout_seconds"]) - # A mark whose trigger didn't settle is not chosen again, so an unfinished fire can't hold time still. - timed_out.update((d.env_id, t["id"], t["next_mark"]) for d, t in due if statuses[d.env_id][t["id"]] == "timeout") - trigger_ids = list(dict.fromkeys(t["id"] for _, t in due)) - return {"mark": _iso(mark), "trigger_ids": trigger_ids, "statuses": statuses, "moved": moved} - - async def _move_env_clocks(self, envs: list, target: datetime) -> dict: - """Bring each env clock behind ``target`` up to it, by advance where offered, else a re-arm only fixed marks survive.""" - moved = {} - for d, clock, rows in envs: - if _utc(clock["virtual_time"]) >= target: - continue - if d.supports(EXT_CLOCK_URI, "advance"): - moved[d.env_id] = await self._advance_clock(d, target) - continue - unsafe = [t["id"] for t in rows if t.get("type") == "time" - and t.get("status") in _SCHEDULED_STATUSES and not _rearm_safe(t.get("when") or {})] - if unsafe: - raise RuntimeError(f"{self.id}: env '{d.env_id}' has no clock advance, and a re-arm would re-anchor the " - f"relative or recurring time trigger(s) {unsafe}; give them absolute marks") - await d.invoke(EXT_CLOCK_URI, "set_time", {"virtual_time": _iso(target), "virtual_seconds_per_real_second": - clock.get("virtual_seconds_per_real_second", 1.0)}, timeout=15) - moved[d.env_id] = "set_time" - return moved - - async def _advance_clock(self, d, target: datetime) -> str: - """Advance one env clock to ``target``: "advance", or "passed" when a running clock got there before the call did.""" - try: - await d.invoke(EXT_CLOCK_URI, "advance", {"virtual_time": _iso(target)}, timeout=15) - return "advance" - except httpx.HTTPStatusError as e: - now = await self._get_clock_state(d) if e.response.status_code == 400 else None - if now and now.get("armed") and _utc(now["virtual_time"]) >= target: - return "passed" - raise - - async def _wait_fired(self, context: TaskStepContext, due: list, timeout_seconds: float) -> dict: - """``{env_id: {trigger_id: outcome}}`` once each due trigger has fired, failed or been cancelled; "timeout" if not.""" - before = {(d.env_id, t["id"]): (t.get("fire_count") or 0, t.get("failure_count") or 0) for d, t in due} - by_env: dict[str, tuple[Any, list[str]]] = {} - for d, t in due: - by_env.setdefault(d.env_id, (d, []))[1].append(t["id"]) - outcome: dict[tuple[str, str], str] = {} - deadline = time.monotonic() + timeout_seconds - while True: - for env_id, (d, ids) in by_env.items(): - rows = {t["id"]: t for t in (await self._get_trigger_state(d)).get("triggers", [])} - for tid in (i for i in ids if (env_id, i) not in outcome): - row = rows.get(tid) - fires, failures = before[(env_id, tid)] - if row is None: - outcome[(env_id, tid)] = "removed" - elif row.get("status") in TRIGGER_IN_FLIGHT_STATUSES: - continue - elif (row.get("failure_count") or 0) > failures: - outcome[(env_id, tid)] = "failed" - elif (row.get("fire_count") or 0) > fires or row.get("status") in _SETTLED_STATUSES: - outcome[(env_id, tid)] = "fired" if row.get("status") == "armed" else row["status"] - if len(outcome) == len(before) or time.monotonic() >= deadline: - break - context.heartbeat() - await asyncio.sleep(_FIRE_POLL_SECONDS) - statuses: dict[str, dict[str, str]] = {} - for env_id, tid in before: - if (env_id, tid) not in outcome: - logger.warning(f"{self.id}: time trigger '{tid}' on env '{env_id}' had not settled {timeout_seconds}s after its mark") - statuses.setdefault(env_id, {})[tid] = outcome.get((env_id, tid), "timeout") - return statuses - _CAPTURE_BUDGET_SECONDS: ClassVar[float] = 90 async def _persist_env_trigger_state(self, context: TaskStepContext) -> None: @@ -1198,3 +1005,113 @@ async def _get_trigger_state(self, deployed) -> dict: last_exc = e await asyncio.sleep(0.5 * (attempt + 1)) raise RuntimeError(f"could not read /triggers/state for env '{deployed.env_id}': {last_exc}") + + +class _UserSide: + """Who answers the agent between turns: agent triggers first, then the LLM user simulator or a human peer.""" + + def __init__(self, step: PromptAgentTaskStep, context: TaskStepContext, *, url: Optional[str], + user_sim: Optional[DeployedAgent], is_user_sim: bool, conversation_id: str, + decide_url: Optional[str], decides_quietly: bool, shareable: set[str]) -> None: + self._step = step + self._context = context + self._url = url + self._user_sim = user_sim + self._is_user_sim = is_user_sim + self._conversation_id = conversation_id + self._decide_url = decide_url + self._decides_quietly = decides_quietly + self._shareable = shareable + + async def ask(self, number: int, solver_message: str, agent_parts: list[dict]) -> dict: + """The user side's answer: ``{"message": parts}``, or ``{"end": reason}`` with any closing message to record.""" + if self._decide_url is not None: + env_triggers = await self._step._read_env_triggers(self._context) + self._record("env_trigger_snapshots", {"turn": number, "envs": env_triggers}) + resp = await self._step._decide(self._decide_url, number, solver_message, self._conversation_id, + env_triggers) + self._record("agent_trigger_firings", {"turn": number, "fired": resp.get("fired", [])}) + logger.info(f"Turn {number} agent-trigger decide: fired={resp.get('fired', [])} done={resp.get('done')}") + answer = _trigger_answer(resp) + if answer is not None: + return answer + # no trigger fired -> fall through to the LLM user-sim for this turn (hybrid) + return await self._ask_peer(number, agent_parts) + + async def triggers(self, number: int, solver_message: str) -> Optional[dict]: + """Agent triggers alone: their answer, recorded as ``ask`` records it, or None, recording nothing.""" + if self._decide_url is None: + return None + if not self._decides_quietly: + raise RuntimeError(f"user agent '{self._step.user_agent_name}' records every agent-trigger decide, so one " + "where none fires would reset its once-only triggers; rebuild it on an " + "agentenv-framework-protocol whose triggers/v1 decide takes record_unfired") + env_triggers = await self._step._read_env_triggers(self._context) + resp = await self._step._decide(self._decide_url, number, solver_message, self._conversation_id, env_triggers, + record_unfired=False) + logger.info(f"Turn {number} agent-trigger decide: fired={resp.get('fired', [])} done={resp.get('done')}") + answer = _trigger_answer(resp) + if answer is not None: + self._record("env_trigger_snapshots", {"turn": number, "envs": env_triggers}) + self._record("agent_trigger_firings", {"turn": number, "fired": resp.get("fired", [])}) + return answer + + async def _ask_peer(self, number: int, agent_parts: list[dict]) -> dict: + step = self._step + if self._url is None: + self._url = get_config().get_default_human_a2a_url() + user_a2a_task_id = uuid.uuid4().hex + try: + # A user-sim runs in a sandbox agent-env deployed; a human peer, registered or named by + # user_a2a_url, has none and reads the store itself. + _, user_result = await send_and_wait( + self._url, agent_parts, + agent=self._user_sim if self._is_user_sim and self._user_sim.sandbox_id else None, + shareable=self._shareable, message_id=user_a2a_task_id, context_id=self._conversation_id, + timeout_seconds=step.user_agent_timeout_seconds, + poll_interval_seconds=step.poll_interval_seconds, + ) + except TimeoutError as e: + logger.warning(f"user_a2a_url timeout for conversation {self._conversation_id} ({e}); marking abandoned") + return {"end": "user_timeout"} + + user_state = (user_result.get("status") or {}).get("state") + if user_state != TaskState.completed: + logger.info(f"user_a2a_url returned state={user_state}; ending multi-turn") + return {"end": "user_not_completed"} + + user_msg = (user_result.get("status") or {}).get("message") or {} + parts = user_msg.get("parts") or [] + if not parts: + logger.warning("user_a2a_url returned no parts; ending multi-turn") + return {"end": "user_no_parts"} + + if not self._is_user_sim: + return {"message": parts} + raw_text = next((p["text"] for p in parts if p.get("kind") == "text"), "") + try: + parsed = json.loads(raw_text) + message_text = parsed["message"] + done = bool(parsed["done"]) + except (json.JSONDecodeError, KeyError) as e: + logger.warning(f"user-sim returned malformed output despite output_format ({e}); using raw text as next prompt; raw={raw_text[:200]!r}") + return {"message": [{"kind": "text", "text": raw_text}]} + extras = {k: v for k, v in parsed.items() if k not in ("message", "done")} + if extras: + self._record("usersim_turn_outputs", {"turn": number, "fields": extras}) + if done: + logger.info(f"user-sim signaled done=true at turn {number}; ending multi-turn") + return {"end": "user_done"} + return {"message": [{"kind": "text", "text": message_text}]} + + def _record(self, key: str, entry: dict) -> None: + self._context.metadata.setdefault(key, {}).setdefault(self._step.id, []).append(entry) + + +def _trigger_answer(resp: dict) -> Optional[dict]: + """An agent-trigger decide's answer: it ended the conversation, it speaks for the user, or None when none fired.""" + if bool(resp.get("done")): + return {"end": "agent_trigger_done", "message": resp.get("parts") or []} + if resp.get("fired"): + return {"message": resp.get("parts") or []} + return None diff --git a/src/agent_env/task_step/task_steps/verifiers/__init__.py b/src/agent_env/task_step/task_steps/verifiers/__init__.py index 73ae84f83..781c29c39 100644 --- a/src/agent_env/task_step/task_steps/verifiers/__init__.py +++ b/src/agent_env/task_step/task_steps/verifiers/__init__.py @@ -4,7 +4,7 @@ the LLM judge).""" from agent_env.task_step.task_steps.verifiers.scoring import ScoreAggregator, aggregate_score -from agent_env.task_step.task_steps.verifiers.run_context import Firing, Message, Run, RunRecordUnavailable, Turn +from agent_env.task_step.task_steps.verifiers.run_context import EnvClock, Firing, Message, Run, RunRecordUnavailable, Turn from agent_env.task_step.task_steps.verifiers.env_outcome_verifier import EnvOutcomeVerifierTaskStep from agent_env.task_step.task_steps.verifiers.rubrics_verifier import RubricsVerifierTaskStep from agent_env.task_step.task_steps.verifiers.agent_prompt_response_verifier import AgentPromptResponseVerifierTaskStep @@ -15,6 +15,7 @@ __all__ = [ "AgentPromptResponseVerifierTaskStep", "AggregateVerifiersTaskStep", + "EnvClock", "EnvOutcomeVerifierTaskStep", "Firing", "Message", 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 4cd86d39c..6356f53d2 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 @@ -27,6 +27,13 @@ def _need(unavailable: Mapping[str, str], sources) -> None: raise RunRecordUnavailable(source, unavailable[source]) +@dataclass(frozen=True) +class EnvClock: + env_id: str + env_step_id: Optional[str] # the deploy_env step, which tells two deployments of one env apart + virtual_time: datetime + + @dataclass(frozen=True) class Message: turn: int @@ -37,7 +44,7 @@ class Message: written_by_triggers: tuple[str, ...] = () # the user agent's triggers that wrote this user message user_sim: Optional[Mapping[str, Any]] = None # the user sim's extra fields for this turn parts: tuple[Mapping[str, Any], ...] = () - env_clock: Optional[Mapping[str, Optional[datetime]]] = None # each env's virtual time when it was recorded + env_clock: Optional[tuple[EnvClock, ...]] = None # each armed env clock when it was recorded; None if none was @dataclass(frozen=True) @@ -308,9 +315,10 @@ def _first_message_index(prompt_response: PromptResponse, stored: list[dict]) -> def _message(turn: int, role: str, parts: list[dict], *, ts: Optional[str] = None, author: Optional[str] = None, fired: Optional[list[str]] = None, user_sim: Optional[Mapping[str, Any]] = None, - env_clock: Optional[Mapping[str, Optional[str]]] = None) -> Message: + env_clock: Optional[list[Mapping[str, Any]]] = None) -> Message: text = "\n".join(p.get("text", "") for p in parts if p.get("kind") == "text") - clock = None if env_clock is None else {env_id: _time(t) for env_id, t in env_clock.items()} + clock = (tuple(EnvClock(c["env_id"], c.get("env_step_id"), _time(c["virtual_time"])) for c in env_clock) + if env_clock else None) return Message(turn=turn, role=role, text=text, sent_at=_time(ts), author=author or None, written_by_triggers=tuple(fired or ()), user_sim=user_sim, parts=tuple(parts), env_clock=clock) diff --git a/tst/unit/agents/claude_code/test_card_contract.py b/tst/unit/agents/claude_code/test_card_contract.py index bd964ead3..e52b893c2 100644 --- a/tst/unit/agents/claude_code/test_card_contract.py +++ b/tst/unit/agents/claude_code/test_card_contract.py @@ -110,7 +110,8 @@ def test_request_and_response_contracts(app): assert triggers["decide"] == { "method": "POST", "endpoint": "/ext/triggers/decide", - "request": {"required": ["turn"], "optional": ["solver_message", "context_id", "env_triggers"]}, + "request": {"required": ["turn"], + "optional": ["solver_message", "context_id", "env_triggers", "record_unfired"]}, "response": {"required": ["parts", "done", "fired"]}, } assert triggers["state"] == {"method": "GET", "response": {"required": ["firing_log"]}} diff --git a/tst/unit/agents/codex/test_card_contract.py b/tst/unit/agents/codex/test_card_contract.py index 8d0d95e35..3d3ccbefa 100644 --- a/tst/unit/agents/codex/test_card_contract.py +++ b/tst/unit/agents/codex/test_card_contract.py @@ -159,7 +159,8 @@ def test_request_and_response_contracts(codex): assert triggers["decide"] == { "method": "POST", "endpoint": "/ext/triggers/decide", - "request": {"required": ["turn"], "optional": ["solver_message", "context_id", "env_triggers"]}, + "request": {"required": ["turn"], + "optional": ["solver_message", "context_id", "env_triggers", "record_unfired"]}, "response": {"required": ["parts", "done", "fired"]}, } assert triggers["state"] == {"method": "GET", "response": {"required": ["firing_log"]}} diff --git a/tst/unit/agents/gemini/test_card_contract.py b/tst/unit/agents/gemini/test_card_contract.py index 0f2c073e7..d0cae540c 100644 --- a/tst/unit/agents/gemini/test_card_contract.py +++ b/tst/unit/agents/gemini/test_card_contract.py @@ -191,7 +191,8 @@ def test_request_and_response_contracts(app): assert triggers["decide"] == { "method": "POST", "endpoint": "/ext/triggers/decide", - "request": {"required": ["turn"], "optional": ["solver_message", "context_id", "env_triggers"]}, + "request": {"required": ["turn"], + "optional": ["solver_message", "context_id", "env_triggers", "record_unfired"]}, "response": {"required": ["parts", "done", "fired"]}, } assert triggers["state"] == {"method": "GET", "response": {"required": ["firing_log"]}} diff --git a/tst/unit/task_step/test_prompt_agent_between_turns.py b/tst/unit/task_step/test_prompt_agent_between_turns.py deleted file mode 100644 index 1ce4948e2..000000000 --- a/tst/unit/task_step/test_prompt_agent_between_turns.py +++ /dev/null @@ -1,412 +0,0 @@ -"""prompt_agent between_turns next_env_event: between turns the env clock jumps to the next pending time-trigger mark, -the run waits for those triggers, and the agent is woken; with the option unset nothing changes.""" -from __future__ import annotations - -import copy -import time -from datetime import datetime, timedelta, timezone -from unittest.mock import MagicMock - -import httpx -import pytest -from a2a.types import TaskState - -from agent_env.a2a_agent import A2AAgent -from agent_env.env.gateway.constants import EXT_CLOCK_URI, EXT_TRIGGERS_URI -from agent_env.task_step.context import DeployedAgent, TaskStepContext -from agent_env.task_step.task_steps import prompt_agent as pa_mod -from agent_env.task_step.task_steps.prompt_agent import DEFAULT_WAKE_MESSAGE, PromptAgentTaskStep - -START = "2026-03-02T13:00:00Z" -DAY1 = "2026-03-03T09:30:00Z" -DAY2 = "2026-03-04T21:15:00Z" - - -def _dt(text: str) -> datetime: - return datetime.fromisoformat(text.replace("Z", "+00:00")) - - -def _z(value: datetime) -> str: - return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") - - -class _Env: - """A deployed env whose clock and time triggers behave as a gateway's: a trigger fires once the clock reaches its mark.""" - - def __init__(self, marks: dict, *, rate: float = 0.0, advance: bool = True, outcome: str = "fired", - env_id: str = "env-1", overrun: timedelta | None = None): - self.env_id, self.rate, self.offers_advance, self.outcome = env_id, rate, advance, outcome - self.overrun = overrun - self.now = _dt(START) - self.calls: list[tuple[str, dict | None]] = [] - self.rows = {tid: {"id": tid, "type": "time", "status": "armed", "next_mark": at if not at.startswith("P") else - _z(self.now + timedelta(days=1)), "fire_count": 0, "failure_count": 0, - "when": {"type": "time", "at": at}} for tid, at in marks.items()} - - def supports(self, uri: str, method: str) -> bool: - if uri == EXT_CLOCK_URI: - return method in ("state", "set_time") or (method == "advance" and self.offers_advance) - return uri == EXT_TRIGGERS_URI and method == "state" - - async def invoke(self, uri, method, params=None, *, timeout=30, environment_name=None): - self.calls.append((method, params)) - if uri == EXT_CLOCK_URI and method == "advance" and self.overrun is not None: - self.now = _dt(params["virtual_time"]) + self.overrun - raise httpx.HTTPStatusError("advance only moves forward", request=httpx.Request("PUT", "http://gw"), - response=httpx.Response(400, request=httpx.Request("PUT", "http://gw"))) - if uri == EXT_CLOCK_URI and method in ("advance", "set_time"): - self.now = _dt(params["virtual_time"]) - return {} - if uri == EXT_CLOCK_URI: - return {"armed": True, "virtual_time": _z(self.now), "virtual_seconds_per_real_second": self.rate} - for row in self.rows.values(): - if row.get("stuck"): - continue - if row["status"] == "firing" and self.outcome == "fired": - row.update(status="armed", fire_count=row["fire_count"] + 1) - elif row["status"] == "armed" and row["next_mark"] and _dt(row["next_mark"]) <= self.now: - if "every" in row["when"] and self.outcome == "fired": - row.update(fire_count=row["fire_count"] + 1, next_mark=_z(_dt(row["next_mark"]) + timedelta(days=1))) - elif self.outcome == "fired": - row.update(status="fired", fire_count=1, next_mark=None) - elif self.outcome == "failed": - row.update(status="failed", failure_count=1, next_mark=None) - return {"triggers": copy.deepcopy(list(self.rows.values()))} - - def moves(self) -> list[tuple[str, dict]]: - return [(m, p) for m, p in self.calls if m in ("advance", "set_time")] - - -def _wire(monkeypatch, *, decide=None): - """Mock the conversation's HTTP and store boundary; returns what the solver was sent and what the store recorded.""" - cfg = MagicMock() - cfg.get_model_params.return_value = {} - cfg.get_default_human_a2a_url.side_effect = AssertionError("no human peer in an event-driven run") - monkeypatch.setattr(pa_mod, "get_config", lambda: cfg) - seen = {"solver": [], "stored": [], "closed": 0} - monkeypatch.setattr(pa_mod.conversation_store, "create_conversation", lambda *a, **kw: None) - monkeypatch.setattr(pa_mod.conversation_store, "get_conversation", lambda *a, **kw: None) - monkeypatch.setattr(pa_mod.conversation_store, "add_a2a_task", lambda *a, **kw: seen["stored"].append(("add", kw))) - monkeypatch.setattr(pa_mod.conversation_store, "complete_a2a_task", - lambda *a, **kw: seen["stored"].append(("complete", kw))) - - def closed(*a, **kw): - seen["closed"] += 1 - - monkeypatch.setattr(pa_mod.conversation_store, "mark_closed", closed) - - async def send(url, parts, message_id, context_id, timeout): - if "user" in url: - raise AssertionError("the user side is never asked in an event-driven run") - seen["solver"].append(parts[0]["text"]) - return f"task-{message_id}", None - - async def poll(url, task_id, timeout, interval, **_): - return {"status": {"state": TaskState.completed, "message": {"parts": [{"kind": "text", "text": "ok"}]}}} - - monkeypatch.setattr(pa_mod.protocol, "send_a2a_message", send) - monkeypatch.setattr(pa_mod.protocol, "poll_a2a_task", poll) - monkeypatch.setattr(pa_mod.asyncio, "sleep", _no_sleep) - if decide is not None: - monkeypatch.setattr(PromptAgentTaskStep, "_decide", decide) - return seen - - -async def _no_sleep(_seconds): - return None - - -def _context(*envs, user_sim_triggers: bool = False) -> TaskStepContext: - ctx = TaskStepContext(instance_id="ti-1", deployed_envs=list(envs)) - ctx.deployed_agents.append(DeployedAgent(agent_name="solver", api_url="http://solver", a2a_url="http://solver", - a2a_card={})) - if user_sim_triggers: - card = {"capabilities": {"extensions": [{"uri": A2AAgent.EXT_TRIGGERS, "params": { - "endpoint": "/ext/triggers", "methods": {"decide": {"method": "POST", "endpoint": "/ext/triggers/decide"}}}}]}} - ctx.deployed_agents.append(DeployedAgent(agent_name="stakeholder", api_url="http://user-sim", - a2a_url="http://user-sim", a2a_card=card)) - ctx.metadata["agent_trigger_registrations"] = [{"agent_name": "stakeholder", "added": ["news"]}] - return ctx - - -def _step(between_turns, turns: int = 6, **kw) -> PromptAgentTaskStep: - return PromptAgentTaskStep(id="watch", version=None, prompt="Watch my day.", agent_name="solver", - max_conversation_turns=turns, between_turns=between_turns, **kw) - - -def test_unset_adds_nothing_to_the_stored_step(): - data = PromptAgentTaskStep(id="s", version=None, prompt="hi").to_dict() - assert "between_turns" not in data and "record_env_clock" not in data - again = PromptAgentTaskStep.from_dict(data) - assert (again.between_turns, again.record_env_clock) == (None, False) - - -@pytest.mark.parametrize("given", ["next_env_event", {"mode": "next_env_event", "end_time": DAY2, "wake": "message"}]) -def test_the_option_round_trips_as_given(given): - step = PromptAgentTaskStep.from_dict(_step(given, record_env_clock=True).to_dict()) - assert (step.between_turns, step.record_env_clock) == (given, True) - - -@pytest.mark.parametrize("given,fragment", [ - ("every_turn", "mode must be"), - ({"mode": "next_env_event", "until": DAY1}, "unknown key"), - ({"mode": "next_env_event", "wake": "user"}, "wake must be one of"), - ({"mode": "next_env_event", "wake_message": "{now}"}, "may use only"), - ({"mode": "next_env_event", "wake_message": " "}, "non-empty"), - ({"mode": "next_env_event", "end_time": "Tuesday"}, "end_time"), - ({"mode": "next_env_event", "end_time": "2026-03-04T21:15:00"}, "end_time"), - ({"mode": "next_env_event", "fire_timeout_seconds": 0}, "positive"), - (["next_env_event"], "or an object with a mode"), -]) -def test_a_bad_option_is_refused_at_construction(given, fragment): - with pytest.raises(ValueError, match=fragment): - _step(given) - - -def test_a_single_turn_step_cannot_act_between_turns(): - with pytest.raises(ValueError, match="max_conversation_turns >= 2"): - _step("next_env_event", turns=1) - - -@pytest.mark.asyncio -async def test_two_events_days_apart_each_wake_the_agent_once_then_the_run_ends(monkeypatch): - seen = _wire(monkeypatch) - env = _Env({"school-email": DAY1, "calendar-clash": DAY2}) - - ctx = await _step("next_env_event").execute(_context(env)) - - assert seen["solver"] == ["Watch my day.", DEFAULT_WAKE_MESSAGE.format(virtual_time=DAY1), - DEFAULT_WAKE_MESSAGE.format(virtual_time=DAY2)] - assert env.moves() == [("advance", {"virtual_time": DAY1}), ("advance", {"virtual_time": DAY2})] - advances = ctx.metadata["env_event_advances"]["watch"] - assert advances[0] == {"turn": 1, "mark": DAY1, "trigger_ids": ["school-email"], "statuses": {"env-1": {"school-email": "fired"}}, - "moved": {"env-1": "advance"}} - assert advances[1]["trigger_ids"] == ["calendar-clash"] - assert advances[2] == {"turn": 3, "stopped": "no_pending_time_triggers", "virtual_time": None} - assert seen["closed"] >= 1 - - -@pytest.mark.asyncio -async def test_every_message_carries_the_env_clock_when_the_run_is_event_driven(monkeypatch): - seen = _wire(monkeypatch) - env = _Env({"school-email": DAY1}) - - await _step("next_env_event").execute(_context(env)) - - stamps = [(kind, kw["env_clock"]) for kind, kw in seen["stored"]] - assert stamps == [("add", {"env-1": START}), ("complete", {"env-1": START}), - ("add", {"env-1": DAY1}), ("complete", {"env-1": DAY1})] - - -@pytest.mark.asyncio -async def test_end_time_stops_the_run_and_brings_the_clock_to_it(monkeypatch): - seen = _wire(monkeypatch) - env = _Env({"day-1": DAY1, "day-5": "2026-03-07T09:00:00Z"}) - end = "2026-03-05T07:00:00Z" - - ctx = await _step({"mode": "next_env_event", "end_time": end}).execute(_context(env)) - - assert len(seen["solver"]) == 2 - assert env.moves()[-1] == ("advance", {"virtual_time": end}) - assert ctx.metadata["env_event_advances"]["watch"][-1] == {"turn": 2, "stopped": "end_time", "virtual_time": end} - assert env.rows["day-5"]["status"] == "armed" - - -@pytest.mark.asyncio -async def test_max_conversation_turns_still_bounds_the_run(monkeypatch): - seen = _wire(monkeypatch) - env = _Env({"a": DAY1, "b": DAY2, "c": "2026-03-06T00:00:00Z"}) - - await _step("next_env_event", turns=2).execute(_context(env)) - - assert len(seen["solver"]) == 2 and env.moves() == [("advance", {"virtual_time": DAY1})] - - -@pytest.mark.asyncio -async def test_triggers_due_at_one_mark_share_one_wake(monkeypatch): - seen = _wire(monkeypatch) - env = _Env({"sale": DAY1, "text": DAY1}) - - await _step({"mode": "next_env_event", "wake_message": "ping {virtual_time}: {trigger_ids}"}).execute(_context(env)) - - assert seen["solver"][1:] == [f"ping {DAY1}: sale, text"] - - -@pytest.mark.asyncio -async def test_one_trigger_id_on_two_envs_is_awaited_on_each_without_waiting_out_the_timeout(monkeypatch): - seen = _wire(monkeypatch) - envs = (_Env({"news": DAY1}, env_id="env-1"), _Env({"news": DAY1}, env_id="env-2")) - started = time.monotonic() - - ctx = await _step({"mode": "next_env_event", "fire_timeout_seconds": 5}, turns=2).execute(_context(*envs)) - - assert time.monotonic() - started < 2 - advance = ctx.metadata["env_event_advances"]["watch"][0] - assert advance["statuses"] == {"env-1": {"news": "fired"}, "env-2": {"news": "fired"}} - assert (advance["trigger_ids"], advance["moved"]) == (["news"], {"env-1": "advance", "env-2": "advance"}) - assert seen["solver"][1] == DEFAULT_WAKE_MESSAGE.format(virtual_time=DAY1) - - -@pytest.mark.asyncio -async def test_a_running_clock_that_passes_the_mark_before_the_advance_lands_is_not_an_error(monkeypatch): - seen = _wire(monkeypatch) - env = _Env({"school-email": DAY1}, rate=60.0, overrun=timedelta(seconds=2)) - - ctx = await _step("next_env_event", turns=2).execute(_context(env)) - - advance = ctx.metadata["env_event_advances"]["watch"][0] - assert (advance["moved"], advance["statuses"]) == ({"env-1": "passed"}, {"env-1": {"school-email": "fired"}}) - assert len(seen["solver"]) == 2 - - -@pytest.mark.asyncio -async def test_an_advance_refused_for_another_reason_still_fails_the_step(monkeypatch): - _wire(monkeypatch) - env = _Env({"school-email": DAY1}, overrun=timedelta(seconds=-2)) - - with pytest.raises(httpx.HTTPStatusError): - await _step("next_env_event", turns=2).execute(_context(env)) - - -@pytest.mark.asyncio -async def test_a_recurring_trigger_still_mid_fire_keeps_its_next_mark_in_the_schedule(monkeypatch): - seen = _wire(monkeypatch) - env = _Env({}) - env.rows["daily"] = {"id": "daily", "type": "time", "status": "firing", "next_mark": DAY1, "fire_count": 3, - "failure_count": 0, "when": {"type": "time", "every": "P1D"}} - - ctx = await _step("next_env_event", turns=3).execute(_context(env)) - - assert env.moves() == [("advance", {"virtual_time": DAY1}), ("advance", {"virtual_time": "2026-03-04T09:30:00Z"})] - assert [a.get("trigger_ids") for a in ctx.metadata["env_event_advances"]["watch"]] == [["daily"], ["daily"]] - assert len(seen["solver"]) == 3 - - -@pytest.mark.asyncio -async def test_an_unfinished_recurring_fire_does_not_hold_time_at_its_mark(monkeypatch): - seen = _wire(monkeypatch) - env = _Env({"later": DAY2}) - env.rows["daily"] = {"id": "daily", "type": "time", "status": "firing", "next_mark": DAY1, "fire_count": 1, - "failure_count": 0, "when": {"type": "time", "every": "P1D"}, "stuck": True} - - ctx = await _step({"mode": "next_env_event", "fire_timeout_seconds": 0.01}, turns=5).execute(_context(env)) - - assert env.moves() == [("advance", {"virtual_time": DAY1}), ("advance", {"virtual_time": DAY2})] - advances = ctx.metadata["env_event_advances"]["watch"] - assert advances[0]["statuses"] == {"env-1": {"daily": "timeout"}} - assert advances[1]["statuses"] == {"env-1": {"later": "fired"}} - assert advances[2]["stopped"] == "no_pending_time_triggers" and len(seen["solver"]) == 3 - - -@pytest.mark.asyncio -async def test_a_gateway_without_advance_is_rearmed_at_its_own_rate(monkeypatch): - _wire(monkeypatch) - env = _Env({"school-email": DAY1}, rate=60.0, advance=False) - - await _step("next_env_event").execute(_context(env)) - - assert env.moves() == [("set_time", {"virtual_time": DAY1, "virtual_seconds_per_real_second": 60.0})] - - -@pytest.mark.asyncio -async def test_a_gateway_without_advance_refuses_to_rearm_over_a_relative_mark(monkeypatch): - _wire(monkeypatch) - env = _Env({"school-email": DAY1, "reminder": "P1D"}, advance=False) - - with pytest.raises(RuntimeError, match=r"re-anchor .*\['reminder'\]"): - await _step("next_env_event").execute(_context(env)) - assert env.moves() == [] - - -@pytest.mark.asyncio -async def test_a_gateway_without_advance_refuses_to_rearm_to_end_time_over_a_relative_mark(monkeypatch): - _wire(monkeypatch) - env = _Env({"reminder": "P1D"}, advance=False) - - with pytest.raises(RuntimeError, match=r"re-anchor .*\['reminder'\]"): - await _step({"mode": "next_env_event", "end_time": "2026-03-02T20:00:00Z"}).execute(_context(env)) - assert env.moves() == [] - - -@pytest.mark.asyncio -async def test_a_failed_or_stuck_trigger_is_reported_and_the_agent_still_woken(monkeypatch): - seen = _wire(monkeypatch) - failed = await _step("next_env_event", turns=2).execute(_context(_Env({"e": DAY1}, outcome="failed"))) - stuck = await _step({"mode": "next_env_event", "fire_timeout_seconds": 0.01}, turns=2).execute( - _context(_Env({"e": DAY1}, outcome="stuck"))) - - assert failed.metadata["env_event_advances"]["watch"][0]["statuses"] == {"env-1": {"e": "failed"}} - assert stuck.metadata["env_event_advances"]["watch"][0]["statuses"] == {"env-1": {"e": "timeout"}} - assert len(seen["solver"]) == 4 - - -@pytest.mark.asyncio -async def test_wake_decide_lets_agent_triggers_speak_and_falls_back_to_the_wake_message(monkeypatch): - calls = [] - - async def decide(self, url, turn, message, context_id, env_triggers): - calls.append((turn, env_triggers)) - return {"fired": ["news"], "done": False, "parts": [{"kind": "text", "text": "[news] the school wrote"}]} \ - if turn == 1 else {"fired": [], "done": False, "parts": []} - - async def readback(self, url, timeout): - return httpx.Response(200, json={"firing_log": []}, request=httpx.Request("GET", url)) - - seen = _wire(monkeypatch, decide=decide) - monkeypatch.setattr(httpx.AsyncClient, "get", readback) - env = _Env({"school-email": DAY1, "calendar-clash": DAY2}) - - await _step({"mode": "next_env_event", "wake": "decide"}, user_agent_name="stakeholder").execute( - _context(env, user_sim_triggers=True)) - - assert seen["solver"][1:] == ["[news] the school wrote", DEFAULT_WAKE_MESSAGE.format(virtual_time=DAY2)] - assert calls[0] == (1, {"env-1": {"school-email": "fired", "calendar-clash": "armed"}}) - - -@pytest.mark.asyncio -async def test_a_run_with_no_armed_env_clock_ends_after_the_first_turn(monkeypatch): - seen = _wire(monkeypatch) - - ctx = await _step("next_env_event").execute(_context()) - - assert len(seen["solver"]) == 1 - assert ctx.metadata["env_event_advances"]["watch"] == [{"turn": 1, "stopped": "no_armed_env_clock", "virtual_time": None}] - - -@pytest.mark.asyncio -async def test_an_agent_trigger_that_ends_the_run_records_its_message_with_the_env_clock(monkeypatch): - async def decide(self, url, turn, message, context_id, env_triggers): - return {"fired": ["bye"], "done": True, "parts": [{"kind": "text", "text": "[news] that's all"}]} - - async def readback(self, url, timeout): - return httpx.Response(200, json={"firing_log": []}, request=httpx.Request("GET", url)) - - seen = _wire(monkeypatch, decide=decide) - monkeypatch.setattr(httpx.AsyncClient, "get", readback) - - await _step({"mode": "next_env_event", "wake": "decide"}, user_agent_name="stakeholder").execute( - _context(_Env({"school-email": DAY1}), user_sim_triggers=True)) - - assert seen["stored"][-1] == ("add", {"parts": [{"kind": "text", "text": "[news] that's all"}], - "a2a_task_id": seen["stored"][-1][1]["a2a_task_id"], "role": "user", - "env_clock": {"env-1": DAY1}}) - - -@pytest.mark.asyncio -async def test_wake_decide_without_agent_triggers_is_refused(monkeypatch): - _wire(monkeypatch) - with pytest.raises(RuntimeError, match="wake 'decide' needs"): - await _step({"mode": "next_env_event", "wake": "decide"}).execute(_context(_Env({"e": DAY1}))) - - -@pytest.mark.asyncio -async def test_record_env_clock_stamps_a_plain_conversation_and_unset_stamps_nothing(monkeypatch): - seen = _wire(monkeypatch) - env = _Env({}) - await PromptAgentTaskStep(id="s", version=None, prompt="hi", agent_name="solver", - record_env_clock=True).execute(_context(env)) - await PromptAgentTaskStep(id="s", version=None, prompt="hi", agent_name="solver").execute(_context(env)) - - assert [kw.get("env_clock", "absent") for _, kw in seen["stored"]] == [{"env-1": START}, {"env-1": START}, - "absent", "absent"] - assert env.calls == [("state", None), ("state", None)] diff --git a/tst/unit/task_step/test_prompt_agent_post_turn_hooks.py b/tst/unit/task_step/test_prompt_agent_post_turn_hooks.py new file mode 100644 index 000000000..68a775303 --- /dev/null +++ b/tst/unit/task_step/test_prompt_agent_post_turn_hooks.py @@ -0,0 +1,542 @@ +"""prompt_agent's post_turn_hooks: what a hook is handed, how its answer is used, and the env clock on messages.""" + +from __future__ import annotations + +import copy +import json +import textwrap + +import httpx +import pytest +from agentenv_protocol.a2a_agent._triggers import TriggerEngine + +from agent_env.artifact import FileArtifact +from agent_env.bundle import BundleKind, parse_bundle +from agent_env.bundle.installed import CORE, installed_bundles +from agent_env.bundle.plan import plan_bundle +from agent_env.bundle.resolve import resolve_bundle +from agent_env.entity.refs import ref_sites +from agent_env.env.env import DeployedGatewayEnv +from agent_env.env.gateway.constants import ( + EXT_CLOCK_URI, EXT_STEP_URI, EXT_TRIGGERS_URI, GATEWAY_EXTENSIONS, WELL_KNOWN_PATH, +) +from agent_env.task_step.context import HEARTBEAT_KEY +from agent_env.task_step.task_steps.prompt_agent import PromptAgentTaskStep +from tst.unit.task_step.test_prompt_agent_user_side import ( + AGENT, ENV, USER, USER_SIM_CONFIG, _TRIGGERS_CARD, _USER_SIM_CARD, Peers, _context, _stored, decide, exchange, + send, sim, text, +) + +pytestmark = pytest.mark.usefixtures("local_stores") + +TUESDAY = "2026-03-03T09:00:00Z" +# A user agent whose triggers/v1 decide takes record_unfired, so a hook may call user_triggers() more than once a turn. +_QUIET_TRIGGERS_CARD = copy.deepcopy(_TRIGGERS_CARD) +_QUIET_TRIGGERS_CARD["capabilities"]["extensions"][1]["params"]["methods"]["decide"]["request"] = { + "required": ["turn"], "optional": ["solver_message", "context_id", "env_triggers", "record_unfired"]} + + +class EnvPeers(Peers): + """``Peers`` plus the env's clock and /step, so hooks and the env clock stamp have a gateway to read.""" + + def __init__(self, monkeypatch, replies, *, clock: dict | None = None, engine: TriggerEngine | None = None, **kw): + super().__init__(monkeypatch, replies, **kw) + self.clock = clock or {"armed": False} + self.engine = engine + self.steps: list[tuple[str, dict]] = [] + + def _answer(self, request: httpx.Request) -> httpx.Response: + origin = f"{request.url.scheme}://{request.url.host}" + if self.engine is not None and request.url.path == "/ext/triggers/decide": + self.raw.append((request.method, str(request.url), json.loads(request.content), 600)) + return httpx.Response(200, json=self.engine.decide(**json.loads(request.content))) + if origin == ENV and request.url.path == "/clock/state": + self.raw.append((request.method, str(request.url), None, request.extensions["timeout"]["read"])) + return httpx.Response(200, json=self.clock) + if origin == ENV and request.url.path == "/step": + self.steps.append((request.headers.get("AgentEnv-Role"), json.loads(request.content))) + return httpx.Response(200, json={"content": [{"type": "text", "text": "ok"}], "changelog_id": 7}) + return super()._answer(request) + + +def _hook(hook_id: str, source: str) -> str: + FileArtifact.put_bytes(hook_id, description=hook_id, filename="hook.py", + content=textwrap.dedent(source).encode()) + return hook_id + + +def _env(*uris: str, step_features: list | None = None) -> DeployedGatewayEnv: + extensions = [dict(e) for e in GATEWAY_EXTENSIONS if e["uri"] in uris] + for e in extensions: + if e["uri"] == EXT_STEP_URI and step_features is not None: + e["params"] = {**e["params"], "features": step_features} + return DeployedGatewayEnv(env_id="env", env_version=1, gateway_url=ENV, mcp_url="", sandbox_id="sb-env", + environment_card_url=f"{ENV}{WELL_KNOWN_PATH}", instance_id="env-inst-1", + metadata={"deploy_step_id": "deploy_env"}, + environment_card={"capabilities": {"extensions": extensions}}) + + +def _step(*hooks: str, turns: int = 4, params: dict | None = None, **kw) -> PromptAgentTaskStep: + return PromptAgentTaskStep(id="talk", version=None, prompt="plan my week", agent_name="solver", + poll_interval_seconds=0, max_conversation_turns=turns, + post_turn_hooks=[{"file_artifact_id": h, "params": params or {}} for h in hooks], **kw) + + +def _recorded(ctx) -> list[dict]: + return ctx.metadata.get("post_turn_hooks", {}).get("talk", []) + + +SAYS_NEXT = """ + async def post_turn(turn, params): + turn.record({"number": turn.number, "last": turn.last, "reply": turn.agent_reply}) + return {"message": f"wake {turn.number}"} +""" + + +# ---- the field ------------------------------------------------------------------------------------------------------ + +def test_a_step_without_hooks_stores_no_hook_field(): + step = PromptAgentTaskStep(id="talk", version=None, prompt="hi") + assert "post_turn_hooks" not in step.to_dict() + + +def test_hooks_round_trip_and_are_entity_refs_a_bundle_or_export_rewrites(): + hooks = [{"file_artifact_id": "next-env-event", "file_artifact_version": 3, "params": {"end_time": "P4D"}}] + step = PromptAgentTaskStep(id="talk", version=None, prompt="hi", post_turn_hooks=hooks) + + stored = step.to_dict() + + assert PromptAgentTaskStep.from_dict(stored).post_turn_hooks == hooks == stored["post_turn_hooks"] + [site] = [s for s in ref_sites(PromptAgentTaskStep.entity_refs, stored) if s.ref.field == "post_turn_hooks"] + assert (site.path, site.value, site.version, site.version_key) == ( + "post_turn_hooks[0].file_artifact_id", "next-env-event", 3, "file_artifact_version") + + +def test_a_bundle_task_naming_a_hook_carries_the_hook_artifact_into_its_run(): + bundle = next(b for b in installed_bundles() if b.name == "next-env-event" and b.dist == CORE) + + plan = plan_bundle(resolve_bundle(parse_bundle(bundle.root, id_root=bundle.id_root)), installed=installed_bundles) + + assert (BundleKind.ARTIFACT, f"{bundle.id_root}/next-env-event") in [(w.kind, w.id) for w in plan.writes] + + +@pytest.mark.parametrize("hooks, fragment", [ + ("next-env-event", "must be a list"), + (["next-env-event"], "post_turn_hooks[0] must be an object"), + ([{"file_artifact_id": "h", "wake": "decide"}], "unknown key(s) ['wake']"), + ([{"params": {}}], "file_artifact_id must be a non-empty string"), + ([{"file_artifact_id": "h", "file_artifact_version": 0}], "file_artifact_version must be a positive integer"), + ([{"file_artifact_id": "h", "params": []}], "params must be an object"), +]) +def test_a_hook_list_the_step_cannot_load_is_refused_at_construction(hooks, fragment): + with pytest.raises(ValueError) as e: + PromptAgentTaskStep(id="talk", version=None, prompt="hi", post_turn_hooks=hooks) + assert fragment in str(e.value) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("source", ["def post_turn(turn, params):\n return None\n", "VALUE = 1\n"]) +async def test_a_hook_that_is_not_an_async_post_turn_fails_the_step_before_the_agent_is_prompted(monkeypatch, source): + peers = EnvPeers(monkeypatch, {AGENT: []}) + ctx = _context() + + with pytest.raises(ValueError, match="must define `async def post_turn"): + await _step(_hook("sync", source)).execute(ctx) + + assert peers.raw == [] + + +# ---- what a hook decides -------------------------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_a_hook_message_is_the_agents_next_prompt_and_no_user_side_is_asked_or_looked_up(monkeypatch): + peers = EnvPeers(monkeypatch, {AGENT: [text("one"), text("two"), text("three")]}) + ctx = _context() + + await _step(_hook("next", SAYS_NEXT), turns=3).execute(ctx) + + assert peers.requests(ctx) == [ + *exchange(AGENT, text("plan my week"), "", "", "task-1"), + *exchange(AGENT, text("wake 1"), "", "", "task-2"), + *exchange(AGENT, text("wake 2"), "", "", "task-3"), + ] + assert [(e["turn"], e["last"], e["reply"]) for e in _recorded(ctx)] == [ + (1, False, "one"), (2, False, "two"), (3, True, "three")] + assert {e["hook"] for e in _recorded(ctx)} == {"next"} + assert _stored(ctx, peers)["messages"][2] == {"role": "user", "parts": text("wake 1")} + + +@pytest.mark.asyncio +async def test_an_end_closes_the_conversation_recording_its_closing_message(monkeypatch): + peers = EnvPeers(monkeypatch, {AGENT: [text("one")]}) + ctx = _context() + hook = _hook("bye", """ + async def post_turn(turn, params): + return {"end": "no_pending_events", "message": "nothing more this week"} + """) + + await _step(hook).execute(ctx) + + assert peers.requests(ctx) == [*exchange(AGENT, text("plan my week"), "", "", "task-1")] + stored = _stored(ctx, peers) + assert stored["status"] == "closed" + assert stored["messages"][-1] == {"role": "user", "parts": text("nothing more this week")} + + +@pytest.mark.asyncio +async def test_the_first_hook_to_decide_wins_and_one_returning_none_passes(monkeypatch): + peers = EnvPeers(monkeypatch, {AGENT: [text("one"), text("two")]}) + ctx = _context() + passes = _hook("passes", """ + async def post_turn(turn, params): + turn.record({"saw": turn.number}) + return None + """) + later = _hook("later", """ + async def post_turn(turn, params): + raise AssertionError("an earlier hook decided") + """) + + await _step(passes, _hook("next", SAYS_NEXT), later, turns=2).execute(ctx) + + assert [e["hook"] for e in _recorded(ctx)] == ["passes", "next", "passes", "next"] + assert peers.requests(ctx)[2] == send(AGENT, text("wake 1"), "", "") + + +@pytest.mark.asyncio +async def test_with_no_hook_deciding_the_user_side_is_asked_as_without_hooks(monkeypatch): + peers = EnvPeers(monkeypatch, {AGENT: [text("one"), text("two")], USER: [sim("again")]}) + ctx = _context(user_card=_USER_SIM_CARD) + hook = _hook("quiet", "async def post_turn(turn, params):\n return None\n") + + await _step(hook, turns=2).execute(ctx) + + assert peers.requests(ctx) == [ + USER_SIM_CONFIG, + *exchange(AGENT, text("plan my week"), "", "", "task-1"), + *exchange(USER, text("one"), "", "", "task-2"), + *exchange(AGENT, text("again"), "", "", "task-3"), + ] + + +@pytest.mark.asyncio +async def test_a_hook_that_asks_the_user_reaches_the_configured_human_peer(monkeypatch): + monkeypatch.setenv("AGENT_ENV_HUMAN_A2A_URL", "http://human.test") + peers = EnvPeers(monkeypatch, {AGENT: [text("one"), text("two")], "http://human.test": [text("go on")]}) + ctx = _context() + hook = _hook("asks", """ + async def post_turn(turn, params): + return None if turn.last else await turn.ask_user() + """) + + await _step(hook, turns=2).execute(ctx) + + assert peers.requests(ctx)[2:5] == [ + *exchange("http://human.test", text("one"), "", "", "task-2"), + send(AGENT, text("go on"), "", ""), + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("returned, fragment", [ + ("'wake'", "returned 'wake'"), + ("{'message': 'x', 'mood': 'calm'}", "returned {'message': 'x', 'mood': 'calm'}"), + ("{'end': ''}", "the reason is a string"), + ("{'message': []}", "a message is text or a non-empty list of A2A parts"), + ("{'message': [{'kind': 'nope'}]}", "message part 0 is not an A2A Part"), +]) +async def test_a_hook_returning_something_else_fails_the_step(monkeypatch, returned, fragment): + EnvPeers(monkeypatch, {AGENT: [text("one")]}) + hook = _hook("odd", f"async def post_turn(turn, params):\n return {returned}\n") + + with pytest.raises(RuntimeError) as e: + await _step(hook).execute(_context()) + assert fragment in str(e.value) + + +@pytest.mark.asyncio +async def test_a_message_returned_after_the_last_turn_is_ignored(monkeypatch): + peers = EnvPeers(monkeypatch, {AGENT: [text("one")]}) + ctx = _context() + + result = await _step(_hook("next", SAYS_NEXT), turns=1).execute(ctx) + + assert [(e["turn"], e["last"]) for e in _recorded(ctx)] == [(1, True)] + assert len(peers.requests(ctx)) == 2 and result.prompt_responses[-1].response == "one" + + +# ---- asking the user side once -------------------------------------------------------------------------------------- + +CHECKS_TRIGGERS_THEN_PASSES = """ + async def post_turn(turn, params): + if turn.last: + return None + answer = await turn.user_triggers() + turn.record({"triggers": answer}) + return answer +""" + + +@pytest.mark.asyncio +async def test_agent_triggers_that_fire_for_a_hook_answer_once_and_ask_user_returns_the_same(monkeypatch): + peers = EnvPeers(monkeypatch, {AGENT: [text("one"), text("two")]}, + decide=[{"fired": ["nudge"], "done": False, "parts": text("[school] pick-up moved")}]) + ctx = _context(user_card=_QUIET_TRIGGERS_CARD, triggers=True) + hook = _hook("both", """ + async def post_turn(turn, params): + if turn.last: + return None + fired = await turn.user_triggers() + assert await turn.ask_user() == fired and await turn.user_triggers() == fired + return fired + """) + + await _step(hook, turns=2).execute(ctx) + + assert [r for r in peers.requests(ctx) if "decide" in r[1]] == [quiet(decide(1, "one", ""))] + assert peers.requests(ctx)[-3] == send(AGENT, text("[school] pick-up moved"), "", "") + assert ctx.metadata["agent_trigger_firings"] == {"talk": [{"turn": 1, "fired": ["nudge"]}]} + + +@pytest.mark.asyncio +async def test_agent_triggers_that_do_not_fire_leave_no_trace_and_the_user_side_is_then_asked_afresh(monkeypatch): + peers = EnvPeers(monkeypatch, {AGENT: [text("one"), text("two")], USER: [sim("again")]}, + decide=[{"fired": [], "done": False}, {"fired": [], "done": False}]) + ctx = _context(user_card=_QUIET_TRIGGERS_CARD, triggers=True) + + await _step(_hook("checks", CHECKS_TRIGGERS_THEN_PASSES), turns=2).execute(ctx) + + assert [r for r in peers.requests(ctx) if "decide" in r[1]] == [quiet(decide(1, "one", "")), + decide(1, "one", "")] + assert ctx.metadata["agent_trigger_firings"] == {"talk": [{"turn": 1, "fired": []}]} + assert len(ctx.metadata["env_trigger_snapshots"]["talk"]) == 1 + assert _recorded(ctx) == [{"hook": "checks", "turn": 1, "triggers": None}] + + +def quiet(request: tuple) -> tuple: + method, url, body, timeout = request + return method, url, {**body, "record_unfired": False}, timeout + + +@pytest.mark.asyncio +async def test_a_once_trigger_fires_once_however_often_a_hook_checks_the_agent_triggers(monkeypatch): + engine = TriggerEngine() + engine.register({"triggers": [{"id": "julia", "when": {"type": "env_trigger", "env_id": "env", + "trigger_id": "school-email", "status": "fired"}, + "actions": [{"type": "say", "text": "[Julia] did you see the school email?"}]}]}) + peers = EnvPeers(monkeypatch, {AGENT: [text("one"), text("two"), text("three")], USER: [sim("ok")]}, engine=engine) + ctx = _context(user_card=_QUIET_TRIGGERS_CARD, triggers=True) + hook = _hook("twice", """ + async def post_turn(turn, params): + if turn.last: + return None + return await turn.user_triggers() or await turn.user_triggers() + """) + + await _step(hook, turns=3).execute(ctx) + + sent = [r[2]["params"]["message"]["parts"] for r in peers.requests(ctx) if r[1] == f"{AGENT}/a2a" + and r[2]["method"] == "message/send"] + assert sent == [text("plan my week"), text("[Julia] did you see the school email?"), text("ok")] + assert [e["kind"] for e in engine.state()["firing_log"]] == ["registered", "fired"] + + +@pytest.mark.asyncio +async def test_a_user_agent_whose_decide_records_every_call_is_refused_before_its_once_triggers_reset(monkeypatch): + peers = EnvPeers(monkeypatch, {AGENT: [text("one")]}) + ctx = _context(user_card=_TRIGGERS_CARD, triggers=True) + + with pytest.raises(RuntimeError, match="takes record_unfired"): + await _step(_hook("checks", CHECKS_TRIGGERS_THEN_PASSES), turns=2).execute(ctx) + assert not [r for r in peers.requests(ctx) if "decide" in r[1]] + + +@pytest.mark.asyncio +async def test_a_hook_passing_on_an_agent_trigger_s_end_records_its_closing_message_even_when_empty(monkeypatch): + peers = EnvPeers(monkeypatch, {AGENT: [text("one")]}, decide=[{"fired": ["wrap-up"], "done": True}]) + ctx = _context(user_card=_QUIET_TRIGGERS_CARD, triggers=True) + + await _step(_hook("checks", CHECKS_TRIGGERS_THEN_PASSES)).execute(ctx) + + stored = _stored(ctx, peers) + assert stored["status"] == "closed" and stored["messages"][-1] == {"role": "user", "parts": []} + assert _recorded(ctx) == [{"hook": "checks", "turn": 1, "triggers": {"end": "agent_trigger_done", "message": []}}] + + +@pytest.mark.asyncio +async def test_a_hook_asking_the_user_twice_at_once_asks_once(monkeypatch): + peers = EnvPeers(monkeypatch, {AGENT: [text("one"), text("two")], USER: [sim("again")]}) + ctx = _context(user_card=_USER_SIM_CARD) + hook = _hook("eager", """ + import asyncio + + async def post_turn(turn, params): + if turn.last: + return None + first, second = await asyncio.gather(turn.ask_user(), turn.ask_user()) + assert first == second + return first + """) + + await _step(hook, turns=2).execute(ctx) + + assert [r[1] for r in peers.requests(ctx)].count(f"{USER}/a2a") == 2 + + +@pytest.mark.asyncio +async def test_a_hook_that_asked_and_returns_another_answer_fails_the_step(monkeypatch): + EnvPeers(monkeypatch, {AGENT: [text("one")], USER: [sim("again")]}) + hook = _hook("liar", """ + async def post_turn(turn, params): + await turn.ask_user() + return {"message": "something else"} + """) + + with pytest.raises(RuntimeError, match="asked the user side at turn 1 and must return its answer"): + await _step(hook).execute(_context(user_card=_USER_SIM_CARD)) + + +# ---- what a hook is handed ------------------------------------------------------------------------------------------ + +@pytest.mark.asyncio +async def test_a_hook_is_handed_the_turn_the_seed_a_safe_copy_of_the_metadata_and_the_envs(monkeypatch): + EnvPeers(monkeypatch, {AGENT: [[{"kind": "text", "text": "one"}, {"kind": "data", "data": {"n": 1}}]]}) + ctx = _context() + ctx.deployed_envs.append(_env(EXT_CLOCK_URI, EXT_TRIGGERS_URI)) + ctx.metadata.update(seed={"city": "Lyon"}, user_overrides={"litellm_api_key": "sk-test", "agent_effort": "low"}) + beats = [] + ctx.metadata[HEARTBEAT_KEY] = lambda: beats.append(1) + hook = _hook("looks", """ + async def post_turn(turn, params): + try: + turn.metadata["x"] = 1 + writable = True + except TypeError: + writable = False + [env] = turn.envs + turn.heartbeat() + turn.record({"params": params, "parts": turn.agent_parts, "seed": turn.seed, "writable": writable, + "overrides": turn.metadata["user_overrides"], "env": [env.env_id, env.env_step_id, + env.instance_id, env.supports("urn:agentenv:clock/v1", "advance")]}) + return None + """) + + await _step(hook, turns=1, params={"end_time": "P4D"}).execute(ctx) + + [entry] = _recorded(ctx) + assert entry["params"] == {"end_time": "P4D"} and entry["seed"] == {"city": "Lyon"} + assert entry["parts"] == [{"kind": "text", "text": "one"}, {"kind": "data", "data": {"n": 1}}] + assert entry["writable"] is False and entry["overrides"] == {"agent_effort": "low"} + assert entry["env"] == ["env", "deploy_env", "env-inst-1", True] and beats == [1] + + +@pytest.mark.asyncio +async def test_a_hook_calls_a_tool_through_step_as_the_role_it_names_tagged_as_the_hooks(monkeypatch): + peers = EnvPeers(monkeypatch, {AGENT: [text("one")]}) + ctx = _context() + ctx.deployed_envs.append(_env(EXT_STEP_URI)) + hook = _hook("mailer", """ + async def post_turn(turn, params): + [env] = turn.envs + turn.record({"result": await env.call_tool("send_email", {"to": "a@example.com"}, role="school")}) + return None + """) + + await _step(hook, turns=1).execute(ctx) + + assert peers.steps == [("school", {"action": "call_tool", "tool_name": "send_email", + "arguments": {"to": "a@example.com"}, "source": "post_turn_hook", + "hook_artifact_id": "mailer"})] + assert _recorded(ctx)[0]["result"]["changelog_id"] == 7 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("call, fragment", [ + ("env.call_tool('send_email', {}, role='')", "needs the AgentEnv-Role"), + ("env.invoke('urn:agentenv:step/v1', 'step', {})", "call tools with call_tool"), +]) +async def test_a_hook_env_refuses_a_call_with_no_role_or_outside_clock_and_triggers(monkeypatch, call, fragment): + EnvPeers(monkeypatch, {AGENT: [text("one")]}) + ctx = _context() + ctx.deployed_envs.append(_env(EXT_STEP_URI)) + hook = _hook("bad", f"async def post_turn(turn, params):\n [env] = turn.envs\n await {call}\n") + + with pytest.raises(ValueError, match=fragment): + await _step(hook, turns=1).execute(ctx) + + +@pytest.mark.asyncio +async def test_a_tool_call_on_a_gateway_that_cannot_tag_it_as_the_hooks_is_refused(monkeypatch): + peers = EnvPeers(monkeypatch, {AGENT: [text("one")]}) + ctx = _context() + ctx.deployed_envs.append(_env(EXT_STEP_URI, step_features=[])) + hook = _hook("mailer", "async def post_turn(turn, params):\n await turn.envs[0].call_tool('x', {}, 'r')\n") + + with pytest.raises(RuntimeError, match="rebuild its gateway image"): + await _step(hook, turns=1).execute(ctx) + assert peers.steps == [] + + +# ---- the env clock on stored messages ------------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_every_stored_message_carries_each_armed_env_clock(monkeypatch): + peers = EnvPeers(monkeypatch, {AGENT: [text("one"), text("two")]}, + clock={"armed": True, "virtual_time": TUESDAY, "t0": TUESDAY}) + ctx = _context() + ctx.deployed_envs.append(_env(EXT_CLOCK_URI)) + hook = _hook("bye", """ + async def post_turn(turn, params): + return {"message": "wake"} if turn.number == 1 else {"end": "done", "message": "bye"} + """) + + await _step(hook, turns=3).execute(ctx) + + stamp = [{"env_id": "env", "env_step_id": "deploy_env", "virtual_time": TUESDAY}] + messages = _stored(ctx, peers)["messages"] + assert [m["parts"] for m in messages] == [text("plan my week"), text("one"), text("wake"), text("two"), text("bye")] + assert all(m["env_clock"] == stamp for m in messages) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("armed", [False, True]) +async def test_without_hooks_an_env_offering_a_clock_adds_clock_reads_and_nothing_else(monkeypatch, armed): + real_client = httpx.AsyncClient + + def run(full_card: bool): + monkeypatch.setattr(httpx, "AsyncClient", real_client) + clock = {"armed": True, "virtual_time": TUESDAY, "t0": TUESDAY} if armed else None + peers = EnvPeers(monkeypatch, {AGENT: [text("one"), text("two")], USER: [sim("over", done=True)]}, + decide=[{"fired": ["nudge"], "done": False, "parts": text("[school] pick-up moved to 3pm")}, + {"fired": [], "done": False}], clock=clock) + ctx = _context(user_card=_TRIGGERS_CARD, triggers=True) + if full_card: + ctx.deployed_envs[0].environment_card = {"capabilities": {"extensions": GATEWAY_EXTENSIONS}} + return peers, ctx + + base_peers, base_ctx = run(full_card=False) + await PromptAgentTaskStep(id="talk", version=None, prompt="plan my week", agent_name="solver", + poll_interval_seconds=0, max_conversation_turns=4).execute(base_ctx) + peers, ctx = run(full_card=True) + await PromptAgentTaskStep(id="talk", version=None, prompt="plan my week", agent_name="solver", + poll_interval_seconds=0, max_conversation_turns=4).execute(ctx) + + requests = peers.requests(ctx) + assert [r for r in requests if r[1] != f"{ENV}/clock/state"] == base_peers.requests(base_ctx) + assert [r for r in requests if r[1] == f"{ENV}/clock/state"] == [("GET", f"{ENV}/clock/state", None, 5)] * 4 + stored, base = _stored(ctx, peers)["messages"], _stored(base_ctx, base_peers)["messages"] + stamp = [{"env_id": "env", "env_step_id": None, "virtual_time": TUESDAY}] + assert [{k: v for k, v in m.items() if k != "env_clock"} for m in stored] == base + assert [m.get("env_clock") for m in stored] == [stamp if armed else None] * len(base) + + +@pytest.mark.asyncio +async def test_with_no_armed_env_clock_stored_messages_carry_none(monkeypatch): + peers = EnvPeers(monkeypatch, {AGENT: [text("one")]}) + ctx = _context() + ctx.deployed_envs.append(_env(EXT_CLOCK_URI)) + + await PromptAgentTaskStep(id="talk", version=None, prompt="plan my week", agent_name="solver", + poll_interval_seconds=0).execute(ctx) + + assert all("env_clock" not in m for m in _stored(ctx, peers)["messages"]) diff --git a/tst/unit/task_step/test_prompt_agent_user_side.py b/tst/unit/task_step/test_prompt_agent_user_side.py new file mode 100644 index 000000000..fa2a14a0d --- /dev/null +++ b/tst/unit/task_step/test_prompt_agent_user_side.py @@ -0,0 +1,477 @@ +"""prompt_agent's user side as it runs today: each request a conversation makes, in order, and what it records.""" + +from __future__ import annotations + +import json +import re + +import httpx +import pytest + +from agent_env.a2a_agent import conversation_store +from agent_env.env.env import DeployedGatewayEnv +from agent_env.env.gateway.constants import EXT_TRIGGERS_URI, GATEWAY_EXTENSIONS, WELL_KNOWN_PATH +from agent_env.task_step.context import DeployedAgent, TaskStepContext +from agent_env.task_step.task_steps.prompt_agent import _DEFAULT_USER_SIM_OUTPUT_FORMAT, PromptAgentTaskStep + +AGENT = "http://agent.test" +USER = "http://user.test" +HUMAN = "http://human.test" +ENV = "http://env.test" +HANG = "hang" + +_ID_RE = re.compile(r"[0-9a-f]{32}") + +pytestmark = pytest.mark.usefixtures("local_stores") + +_AGENT_CONFIG = {"uri": "urn:agentenv:agent-config/v1", "params": { + "endpoint": "/ext/agent-config", "methods": {"set": {"request": {"supported": ["output_format", "model"]}}}}} +_USER_SIM_CARD = {"capabilities": {"extensions": [_AGENT_CONFIG]}} +_TRIGGERS_CARD = {"capabilities": {"extensions": [ + _AGENT_CONFIG, + {"uri": "urn:agentenv:triggers/v1", + "params": {"endpoint": "/ext/triggers", "methods": {"decide": {"endpoint": "/ext/triggers/decide"}}}}, +]}} +_ENV_TRIGGERS = {"triggers": [{"id": "school-email", "status": "fired"}, {"id": "reminder", "status": "armed"}]} +_FIRING_LOG = {"firing_log": [{"turn": 1, "fired": ["nudge"]}]} + + +def text(value: str) -> list[dict]: + return [{"kind": "text", "text": value}] + + +def sim(message: str, done: bool = False, **extra) -> list[dict]: + return text(json.dumps({"message": message, "done": done, **extra})) + + +class Peers: + """Scripted agent, user side and env; ``requests`` lists each request, ids named (the conversation), .""" + + def __init__(self, monkeypatch, replies: dict[str, list], *, decide: list[dict] | None = None, on_poll=None): + self.replies = {origin: list(answers) for origin, answers in replies.items()} + self.decide = list(decide or []) + self.on_poll = on_poll + self.tasks: dict[str, object] = {} + self.raw: list[tuple] = [] + self._ids: dict[str, str] = {} + real_client = httpx.AsyncClient + transport = httpx.MockTransport(self._answer) + monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: real_client(*a, transport=transport, **kw)) + + def requests(self, ctx: TaskStepContext) -> list[tuple]: + self._ids = {ctx.metadata["a2a_conversations"]["talk"]: ""} + return self._norm(self.raw) + + def _norm(self, value): + if isinstance(value, dict): + return {k: self._norm(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return type(value)(self._norm(v) for v in value) + if isinstance(value, str) and _ID_RE.fullmatch(value): + return self._ids.setdefault(value, f"") + return value + + def _answer(self, request: httpx.Request) -> httpx.Response: + origin = f"{request.url.scheme}://{request.url.host}" + body = json.loads(request.content) if request.content else None + self.raw.append((request.method, str(request.url), body, request.extensions["timeout"]["read"])) + path = request.url.path + if path == "/a2a": + return self._a2a(origin, body) + if path == "/ext/agent-config": + return httpx.Response(200, json={"ok": True}) + if path == "/ext/triggers/decide": + return httpx.Response(200, json=self.decide.pop(0)) + if path == "/ext/triggers": + return httpx.Response(200, json=_FIRING_LOG) + if origin == ENV and path == "/triggers/state": + return httpx.Response(200, json=_ENV_TRIGGERS) + raise AssertionError(f"unexpected request {request.method} {request.url}") + + def _a2a(self, origin: str, body: dict) -> httpx.Response: + if body["method"] == "message/send": + task_id = f"task-{len(self.tasks) + 1}" + self.tasks[task_id] = self.replies[origin].pop(0) + result = {"id": task_id, "contextId": "ctx"} + elif body["method"] == "tasks/get": + reply = self.tasks[body["params"]["id"]] + if self.on_poll is not None: + self.on_poll(origin) + if reply == HANG: + result = {"id": body["params"]["id"], "status": {"state": "working"}} + else: + state, parts = reply if isinstance(reply, tuple) else ("completed", reply) + result = {"id": body["params"]["id"], "status": {"state": state, "message": {"parts": parts}}} + else: + result = {} + return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": result}) + + +def send(origin: str, parts: list[dict], message_id: str, context_id: str, timeout: int = 600) -> tuple: + message = {"messageId": message_id, "role": "user", "parts": parts, "contextId": context_id} + return ("POST", f"{origin}/a2a", {"jsonrpc": "2.0", "id": "1", "method": "message/send", + "params": {"message": message, "configuration": {"blocking": False}}}, timeout) + + +def poll(origin: str, task_id: str) -> tuple: + return ("POST", f"{origin}/a2a", {"jsonrpc": "2.0", "id": "poll", "method": "tasks/get", + "params": {"id": task_id}}, 30) + + +def exchange(origin: str, parts: list[dict], message_id: str, context_id: str, task_id: str, timeout: int = 600): + return [send(origin, parts, message_id, context_id, timeout), poll(origin, task_id)] + + +USER_SIM_CONFIG = ("POST", f"{USER}/ext/agent-config", {"output_format": _DEFAULT_USER_SIM_OUTPUT_FORMAT}, 60) +ENV_STATE = ("GET", f"{ENV}/triggers/state", None, 15) +FIRING_LOG = ("GET", f"{USER}/ext/triggers", None, 15) + + +def decide(turn: int, solver_message: str, context_id: str) -> tuple: + return ("POST", f"{USER}/ext/triggers/decide", + {"turn": turn, "solver_message": solver_message, "context_id": context_id, + "env_triggers": {"env": {"school-email": "fired", "reminder": "armed"}}}, 600) + + +def _context(*, user_card: dict | None = None, triggers: bool = False) -> TaskStepContext: + ctx = TaskStepContext(instance_id="ti-1") + ctx.deployed_agents.append(DeployedAgent(agent_name="solver", api_url=AGENT, a2a_url=AGENT, a2a_card={})) + if user_card is not None: + ctx.deployed_agents.append(DeployedAgent(agent_name="human_agent", api_url=USER, a2a_url=USER, + sandbox_id="sb-user", sandbox_type="local", a2a_card=user_card)) + if triggers: + extensions = [e for e in GATEWAY_EXTENSIONS if e["uri"] == EXT_TRIGGERS_URI] + ctx.deployed_envs.append(DeployedGatewayEnv( + env_id="env", env_version=1, gateway_url=ENV, mcp_url="", sandbox_id="sb-env", + environment_card_url=f"{ENV}{WELL_KNOWN_PATH}", + environment_card={"capabilities": {"extensions": extensions}})) + ctx.metadata["agent_trigger_registrations"] = [ + {"step_id": "reg", "agent_name": "human_agent", "added": ["nudge"]}] + return ctx + + +def _step(turns: int = 4, **kw) -> PromptAgentTaskStep: + return PromptAgentTaskStep(id="talk", version=None, prompt="plan my week", agent_name="solver", + poll_interval_seconds=0, max_conversation_turns=turns, **kw) + + +def _stored(ctx: TaskStepContext, peers: Peers) -> dict: + """The stored conversation, its message timestamps dropped and its ids named as ``peers.requests`` names them.""" + peers.requests(ctx) + doc = conversation_store.get_conversation(ctx.metadata["a2a_conversations"]["talk"]) + for message in doc["messages"]: + assert message.pop("ts") + return peers._norm({ + "status": doc["status"], + "messages": doc["messages"], + "a2a_tasks": [(t["a2a_task_id"], t["state"], t["input_message_idx"], t.get("response_message_idx")) + for t in doc["a2a_tasks"]], + }) + + +def _user_side_metadata(ctx: TaskStepContext) -> dict: + keys = ("env_trigger_snapshots", "agent_trigger_firings", "usersim_turn_outputs", "agent_trigger_state") + return {k: ctx.metadata[k] for k in keys if k in ctx.metadata} + + +# ---- the LLM user simulator ---------------------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_the_user_sims_reply_is_the_agents_next_prompt_and_its_done_ends_the_run_unrecorded(monkeypatch): + peers = Peers(monkeypatch, {AGENT: [text("draft one"), text("draft two")], + USER: [sim("make it shorter"), sim("perfect, thanks", done=True)]}) + ctx = _context(user_card=_USER_SIM_CARD) + + result = await _step().execute(ctx) + + assert peers.requests(ctx) == [ + USER_SIM_CONFIG, + *exchange(AGENT, text("plan my week"), "", "", "task-1"), + *exchange(USER, text("draft one"), "", "", "task-2"), + *exchange(AGENT, text("make it shorter"), "", "", "task-3"), + *exchange(USER, text("draft two"), "", "", "task-4"), + ] + assert _stored(ctx, peers) == { + "status": "closed", + "messages": [{"role": "user", "parts": text("plan my week")}, {"role": "agent", "parts": text("draft one")}, + {"role": "user", "parts": text("make it shorter")}, {"role": "agent", "parts": text("draft two")}], + "a2a_tasks": [("", "completed", 0, 1), ("", "completed", 2, 3)], + } + assert result.prompt_responses[-1].response == "draft two" + assert result.prompt_responses[-1].source_agent_per_turn_prompt_parts == [None, text("make it shorter")] + assert _user_side_metadata(ctx) == {} + + +@pytest.mark.asyncio +async def test_malformed_user_sim_output_is_sent_on_as_raw_text(monkeypatch): + peers = Peers(monkeypatch, {AGENT: [text("draft one"), text("draft two")], USER: [text("not json at all")]}) + ctx = _context(user_card=_USER_SIM_CARD) + + await _step(turns=2).execute(ctx) + + assert peers.requests(ctx) == [ + USER_SIM_CONFIG, + *exchange(AGENT, text("plan my week"), "", "", "task-1"), + *exchange(USER, text("draft one"), "", "", "task-2"), + *exchange(AGENT, text("not json at all"), "", "", "task-3"), + ] + assert _stored(ctx, peers)["messages"][2] == {"role": "user", "parts": text("not json at all")} + + +@pytest.mark.asyncio +async def test_a_user_sim_reply_missing_done_is_malformed_too(monkeypatch): + raw = json.dumps({"message": "shorter"}) + peers = Peers(monkeypatch, {AGENT: [text("draft one"), text("draft two")], USER: [text(raw)]}) + ctx = _context(user_card=_USER_SIM_CARD) + + await _step(turns=2).execute(ctx) + + assert peers.requests(ctx)[-2] == send(AGENT, text(raw), "", "") + assert _user_side_metadata(ctx) == {} + + +@pytest.mark.asyncio +async def test_extra_user_sim_fields_are_recorded_per_turn_and_not_sent(monkeypatch): + peers = Peers(monkeypatch, {AGENT: [text("draft one"), text("draft two"), text("draft three")], + USER: [sim("shorter", mood="impatient", score=2), sim("good")]}) + ctx = _context(user_card=_USER_SIM_CARD) + + await _step(turns=3).execute(ctx) + + sent = [r for r in peers.requests(ctx) if r[1] == f"{AGENT}/a2a" and r[2]["method"] == "message/send"] + assert sent == [ + send(AGENT, text("plan my week"), "", ""), + send(AGENT, text("shorter"), "", ""), + send(AGENT, text("good"), "", ""), + ] + assert ctx.metadata["usersim_turn_outputs"] == {"talk": [{"turn": 1, "fields": {"mood": "impatient", "score": 2}}]} + + +@pytest.mark.asyncio +async def test_a_user_sim_that_times_out_ends_the_run_and_its_task_is_cancelled(monkeypatch): + peers = Peers(monkeypatch, {AGENT: [text("draft one")], USER: [HANG]}) + ctx = _context(user_card=_USER_SIM_CARD) + + result = await _step(user_agent_timeout_seconds=0).execute(ctx) + + assert peers.requests(ctx) == [ + USER_SIM_CONFIG, + *exchange(AGENT, text("plan my week"), "", "", "task-1"), + send(USER, text("draft one"), "", "", timeout=0), + ("POST", f"{USER}/a2a", {"jsonrpc": "2.0", "id": "cancel", "method": "tasks/cancel", + "params": {"id": "task-2"}}, 10), + ] + assert _stored(ctx, peers)["status"] == "closed" + assert result.prompt_responses[-1].response == "draft one" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("state", ["failed", "canceled", "rejected"]) +async def test_a_user_side_task_that_does_not_complete_ends_the_run(monkeypatch, state): + peers = Peers(monkeypatch, {AGENT: [text("draft one")], USER: [(state, sim("ignored"))]}) + ctx = _context(user_card=_USER_SIM_CARD) + + result = await _step().execute(ctx) + + assert peers.requests(ctx) == [ + USER_SIM_CONFIG, + *exchange(AGENT, text("plan my week"), "", "", "task-1"), + *exchange(USER, text("draft one"), "", "", "task-2"), + ] + assert _stored(ctx, peers) == { + "status": "closed", + "messages": [{"role": "user", "parts": text("plan my week")}, {"role": "agent", "parts": text("draft one")}], + "a2a_tasks": [("", "completed", 0, 1)], + } + assert result.prompt_responses[-1].response == "draft one" + + +@pytest.mark.asyncio +async def test_a_user_side_reply_with_no_parts_ends_the_run(monkeypatch): + peers = Peers(monkeypatch, {AGENT: [text("draft one")], USER: [[]]}) + ctx = _context(user_card=_USER_SIM_CARD) + + await _step().execute(ctx) + + assert peers.requests(ctx) == [ + USER_SIM_CONFIG, + *exchange(AGENT, text("plan my week"), "", "", "task-1"), + *exchange(USER, text("draft one"), "", "", "task-2"), + ] + assert _stored(ctx, peers)["status"] == "closed" + + +@pytest.mark.asyncio +async def test_the_turn_limit_ends_the_run_after_the_agents_last_turn_without_asking_the_user(monkeypatch): + peers = Peers(monkeypatch, {AGENT: [text("one"), text("two"), text("three")], USER: [sim("again"), sim("more")]}) + ctx = _context(user_card=_USER_SIM_CARD) + + result = await _step(turns=3).execute(ctx) + + assert peers.requests(ctx) == [ + USER_SIM_CONFIG, + *exchange(AGENT, text("plan my week"), "", "", "task-1"), + *exchange(USER, text("one"), "", "", "task-2"), + *exchange(AGENT, text("again"), "", "", "task-3"), + *exchange(USER, text("two"), "", "", "task-4"), + *exchange(AGENT, text("more"), "", "", "task-5"), + ] + assert _stored(ctx, peers)["status"] == "closed" + assert len(_stored(ctx, peers)["messages"]) == 6 + assert result.prompt_responses[-1].response == "three" + + +@pytest.mark.asyncio +async def test_a_conversation_closed_from_outside_ends_the_run_after_that_turn(monkeypatch): + ctx = _context(user_card=_USER_SIM_CARD) + + def close_on_second_agent_turn(origin): + if origin == AGENT and len(peers.tasks) == 3: + conversation_store.mark_closed(ctx.metadata["a2a_conversations"]["talk"]) + + peers = Peers(monkeypatch, {AGENT: [text("one"), text("two")], USER: [sim("again")]}, + on_poll=close_on_second_agent_turn) + + await _step().execute(ctx) + + assert peers.requests(ctx) == [ + USER_SIM_CONFIG, + *exchange(AGENT, text("plan my week"), "", "", "task-1"), + *exchange(USER, text("one"), "", "", "task-2"), + *exchange(AGENT, text("again"), "", "", "task-3"), + ] + assert _stored(ctx, peers)["messages"][-1] == {"role": "agent", "parts": text("two")} + + +@pytest.mark.asyncio +async def test_an_agent_failure_ends_the_run_without_asking_the_user_and_fails_the_step(monkeypatch): + peers = Peers(monkeypatch, {AGENT: [text("one"), ("failed", text("boom"))], USER: [sim("again")]}) + ctx = _context(user_card=_USER_SIM_CARD) + + with pytest.raises(RuntimeError, match="A2A task failed"): + await _step().execute(ctx) + + assert peers.requests(ctx) == [ + USER_SIM_CONFIG, + *exchange(AGENT, text("plan my week"), "", "", "task-1"), + *exchange(USER, text("one"), "", "", "task-2"), + *exchange(AGENT, text("again"), "", "", "task-3"), + ] + assert _stored(ctx, peers) == { + "status": "closed", + "messages": [{"role": "user", "parts": text("plan my week")}, {"role": "agent", "parts": text("one")}, + {"role": "user", "parts": text("again")}, {"role": "agent", "parts": text("boom")}], + "a2a_tasks": [("", "completed", 0, 1), ("", "completed", 2, 3)], + } + + +# ---- agent triggers ------------------------------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_a_fired_agent_trigger_speaks_for_the_user_that_turn(monkeypatch): + peers = Peers(monkeypatch, {AGENT: [text("one"), text("two"), text("three")], USER: [sim("over", done=True)]}, + decide=[{"fired": ["nudge"], "done": False, "parts": text("[school] pick-up moved to 3pm")}, + {"fired": [], "done": False}]) + ctx = _context(user_card=_TRIGGERS_CARD, triggers=True) + + await _step().execute(ctx) + + assert peers.requests(ctx) == [ + USER_SIM_CONFIG, + *exchange(AGENT, text("plan my week"), "", "", "task-1"), + ENV_STATE, + decide(1, "one", ""), + *exchange(AGENT, text("[school] pick-up moved to 3pm"), "", "", "task-2"), + ENV_STATE, + decide(2, "two", ""), + *exchange(USER, text("two"), "", "", "task-3"), + FIRING_LOG, + ] + assert _stored(ctx, peers)["messages"] == [ + {"role": "user", "parts": text("plan my week")}, {"role": "agent", "parts": text("one")}, + {"role": "user", "parts": text("[school] pick-up moved to 3pm")}, {"role": "agent", "parts": text("two")}, + ] + snapshot = {"env": {"school-email": "fired", "reminder": "armed"}} + assert _user_side_metadata(ctx) == { + "env_trigger_snapshots": {"talk": [{"turn": 1, "envs": snapshot}, {"turn": 2, "envs": snapshot}]}, + "agent_trigger_firings": {"talk": [{"turn": 1, "fired": ["nudge"]}, {"turn": 2, "fired": []}]}, + "agent_trigger_state": {"talk": _FIRING_LOG["firing_log"]}, + } + + +@pytest.mark.asyncio +async def test_an_agent_trigger_that_ends_the_conversation_records_its_closing_message(monkeypatch): + peers = Peers(monkeypatch, {AGENT: [text("one")], USER: []}, + decide=[{"fired": ["wrap-up"], "done": True, "parts": text("[school] that's all for today")}]) + ctx = _context(user_card=_TRIGGERS_CARD, triggers=True) + + result = await _step().execute(ctx) + + assert peers.requests(ctx) == [ + USER_SIM_CONFIG, + *exchange(AGENT, text("plan my week"), "", "", "task-1"), + ENV_STATE, + decide(1, "one", ""), + FIRING_LOG, + ] + assert _stored(ctx, peers) == { + "status": "closed", + "messages": [{"role": "user", "parts": text("plan my week")}, {"role": "agent", "parts": text("one")}, + {"role": "user", "parts": text("[school] that's all for today")}], + "a2a_tasks": [("", "completed", 0, 1), ("", "canceled", 2, None)], + } + assert ctx.metadata["agent_trigger_firings"] == {"talk": [{"turn": 1, "fired": ["wrap-up"]}]} + assert result.prompt_responses[-1].source_agent_per_turn_prompt_parts == [None] + + +@pytest.mark.asyncio +async def test_with_no_agent_trigger_fired_the_llm_user_sim_answers(monkeypatch): + peers = Peers(monkeypatch, {AGENT: [text("one"), text("two")], USER: [sim("again")]}, + decide=[{"fired": [], "done": False}]) + ctx = _context(user_card=_TRIGGERS_CARD, triggers=True) + + await _step(turns=2).execute(ctx) + + assert peers.requests(ctx) == [ + USER_SIM_CONFIG, + *exchange(AGENT, text("plan my week"), "", "", "task-1"), + ENV_STATE, + decide(1, "one", ""), + *exchange(USER, text("one"), "", "", "task-2"), + *exchange(AGENT, text("again"), "", "", "task-3"), + FIRING_LOG, + ] + assert ctx.metadata["agent_trigger_firings"] == {"talk": [{"turn": 1, "fired": []}]} + + +# ---- a human peer --------------------------------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_a_human_peer_is_sent_the_agents_reply_as_it_is_and_its_reply_is_sent_on_unparsed(monkeypatch): + reply = text(json.dumps({"message": "kept as typed", "done": True})) + peers = Peers(monkeypatch, {AGENT: [text("one"), text("two")], HUMAN: [reply]}) + ctx = _context(user_card=_USER_SIM_CARD) + + await _step(turns=2, user_a2a_url=HUMAN).execute(ctx) + + assert peers.requests(ctx) == [ + *exchange(AGENT, text("plan my week"), "", "", "task-1"), + *exchange(HUMAN, text("one"), "", "", "task-2"), + *exchange(AGENT, reply, "", "", "task-3"), + ] + assert _stored(ctx, peers)["messages"][2] == {"role": "user", "parts": reply} + + +@pytest.mark.asyncio +async def test_with_no_user_agent_the_configured_human_peer_answers(monkeypatch): + monkeypatch.setenv("AGENT_ENV_HUMAN_A2A_URL", HUMAN) + peers = Peers(monkeypatch, {AGENT: [text("one"), text("two")], HUMAN: [text("go on")]}) + ctx = _context() + + await _step(turns=2).execute(ctx) + + assert peers.requests(ctx) == [ + *exchange(AGENT, text("plan my week"), "", "", "task-1"), + *exchange(HUMAN, text("one"), "", "", "task-2"), + *exchange(AGENT, text("go on"), "", "", "task-3"), + ] diff --git a/tst/unit/task_step/verifiers/test_run_context.py b/tst/unit/task_step/verifiers/test_run_context.py index b0d6975d6..6403e5900 100644 --- a/tst/unit/task_step/verifiers/test_run_context.py +++ b/tst/unit/task_step/verifiers/test_run_context.py @@ -17,7 +17,7 @@ from agent_env.task_step.task_steps.verifiers.env_outcome_verifier import EnvOutcomeVerifierTaskStep from agent_env.task_step.task_steps.verifiers import Run, RunRecordUnavailable from agent_env.task_step.task_steps.verifiers.rubrics_verifier import RubricsVerifierTaskStep -from agent_env.task_step.task_steps.verifiers.run_context import (_fold, _message, _run, load_run_context, +from agent_env.task_step.task_steps.verifiers.run_context import (EnvClock, _fold, _message, _run, load_run_context, render_run_context) TURNS = [("Can you book lunch with Dana?", "Booked for noon."), @@ -497,14 +497,17 @@ async def test_a_message_carries_each_envs_virtual_time_when_the_conversation_re def with_clock(conversation_id): doc = original(conversation_id) - doc["messages"][0]["env_clock"] = {"calendar": "2026-10-17T09:00:00+00:00", "mail": None} + doc["messages"][0]["env_clock"] = [ + {"env_id": "calendar", "env_step_id": "deploy_calendar", "virtual_time": "2026-10-17T09:00:00Z"}, + {"env_id": "calendar", "env_step_id": "deploy_calendar_2", "virtual_time": "2026-10-18T09:00:00Z"}] return doc monkeypatch.setattr(conversation_store, "get_conversation", with_clock) run = await load_run_context(context, prompt_response) first, reply = run.turn(1).message, run.turn(1).reply - assert first.env_clock == {"calendar": datetime.fromisoformat("2026-10-17T09:00:00+00:00"), "mail": None} + assert first.env_clock == (EnvClock("calendar", "deploy_calendar", datetime.fromisoformat("2026-10-17T09:00:00Z")), + EnvClock("calendar", "deploy_calendar_2", datetime.fromisoformat("2026-10-18T09:00:00Z"))) assert reply.env_clock is None # a message stored without a clock has none From d2241e1bb8ec35e099479b5ac998b1bc99720be0 Mon Sep 17 00:00:00 2001 From: Sarah Chitty Date: Mon, 26 Oct 2026 08:56:15 -0700 Subject: [PATCH 10/15] review: clock advance returns what came due; triggers state gains next_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 --- src/agent_env/env/gateway/clock.py | 12 +- src/agent_env/env/gateway/constants.py | 16 +- src/agent_env/env/gateway/gateway.py | 37 ++- src/agent_env/env/gateway/triggers.py | 96 ++++++-- tst/unit/env/gateway/next_event_test.py | 305 ++++++++++++++++++++++++ 5 files changed, 434 insertions(+), 32 deletions(-) create mode 100644 tst/unit/env/gateway/next_event_test.py diff --git a/src/agent_env/env/gateway/clock.py b/src/agent_env/env/gateway/clock.py index 4b34162c3..cceb14364 100644 --- a/src/agent_env/env/gateway/clock.py +++ b/src/agent_env/env/gateway/clock.py @@ -171,7 +171,7 @@ def set_time(self, virtual_time: str, rate=1.0) -> dict: return self.state() def advance(self, virtual_time: str) -> dict: - """Jump forward to virtual_time on the current arm: t0, rate and generation stay, so time triggers keep their marks.""" + """Jump forward to virtual_time on the current arm, keeping t0, rate and generation, so marks stay put.""" target = _parse_rfc3339(virtual_time) with self._lock: if not self._armed or self._t0 is None: @@ -198,8 +198,7 @@ def clear(self) -> dict: return {"ok": True, "armed": False} def elapsed(self) -> float: - """Virtual seconds run so far: real time at each arm's rate (1 while unarmed), plus the jump of each re-arm - or advance forward; one back counts nothing.""" + """Virtual seconds run so far: real time at each arm's rate (1 unarmed), plus each forward re-arm or advance.""" with self._lock: return self._lived + (self._now() - self._anchor) * self._rate @@ -210,6 +209,13 @@ def now(self) -> datetime | None: elapsed = self._now() - self._anchor return _advance(self._t0, elapsed * self._rate + self._ahead) + def at_elapsed(self, elapsed: float) -> datetime | None: + """The virtual time at which elapsed() reaches `elapsed` on the current arm; None while unarmed.""" + with self._lock: + if not self._armed or self._t0 is None: + return None + return _advance(self._t0, elapsed - self._lived + self._ahead) + def read(self) -> dict: """The frozen consumer contract: {"virtual_time": ""} — pure read, never advances.""" current = self.now() diff --git a/src/agent_env/env/gateway/constants.py b/src/agent_env/env/gateway/constants.py index 3d3094a4b..a36db339a 100644 --- a/src/agent_env/env/gateway/constants.py +++ b/src/agent_env/env/gateway/constants.py @@ -121,8 +121,15 @@ def sources(names: list[str]) -> str: TRIGGER_UNLESS = "unless" TRIGGER_WEBSITE = "website" TRIGGER_EXECUTOR_HOURS_TIME_ZONE = "executor_hours_time_zone" +TRIGGER_NEXT_EVENT_AT = "next_event_at" TRIGGER_FEATURES = (TRIGGER_NAMED_EXECUTORS, TRIGGER_PROVOKING_ROLES, TRIGGER_ACTION_DELAY, TRIGGER_EXECUTOR_HOURS, - TRIGGER_UNLESS, TRIGGER_WEBSITE, TRIGGER_EXECUTOR_HOURS_TIME_ZONE) + TRIGGER_UNLESS, TRIGGER_WEBSITE, TRIGGER_EXECUTOR_HOURS_TIME_ZONE, TRIGGER_NEXT_EVENT_AT) + +# The one `source` a /step call may name instead of step_api, with the id of the hook artifact that made it. +STEP_SOURCE_POST_TURN_HOOK = "post_turn_hook" +# What step/v1 takes beyond its first release, declared on the card as triggers/v1 declares its features. +STEP_SOURCE = "source" +STEP_FEATURES = (STEP_SOURCE,) GATEWAY_TRAJECTORY_FILE = os.environ.get( "GATEWAY_TRAJECTORY_FILE", @@ -167,7 +174,8 @@ def random_mcp_server_name() -> str: # call_tool also needs a tool_name; if/then rather than oneOf keeps the fields top-level, where card viewers read them _STEP_REQUEST = { "type": "object", - "properties": {"action": {"enum": ["list_tools", "call_tool"]}, "tool_name": {"type": "string", "minLength": 1}, "arguments": {"type": "object"}}, + "properties": {"action": {"enum": ["list_tools", "call_tool"]}, "tool_name": {"type": "string", "minLength": 1}, "arguments": {"type": "object"}, + "source": {"enum": [STEP_SOURCE_POST_TURN_HOOK]}, "hook_artifact_id": {"type": "string", "minLength": 1}}, "required": ["action"], "if": {"properties": {"action": {"const": "call_tool"}}}, "then": {"required": ["tool_name"]}, @@ -215,8 +223,8 @@ def random_mcp_server_name() -> str: }, { "uri": EXT_STEP_URI, - "description": "Step (urn:agentenv:step/v1): list or call the env's tools over REST, filtered by the AgentEnv-Role header.", - "params": {"endpoint": "/step", "methods": { + "description": "Step (urn:agentenv:step/v1): list or call the env's tools over REST, filtered by the AgentEnv-Role header; a call a post-turn hook makes names it, and the trajectory records it as the hook's.", + "params": {"endpoint": "/step", "features": list(STEP_FEATURES), "methods": { "step": {"method": "POST", "endpoint": "/step", "request": _STEP_REQUEST}}}, "required": False, }, diff --git a/src/agent_env/env/gateway/gateway.py b/src/agent_env/env/gateway/gateway.py index 9e7990d18..58efa78c6 100644 --- a/src/agent_env/env/gateway/gateway.py +++ b/src/agent_env/env/gateway/gateway.py @@ -58,6 +58,7 @@ PROTOCOL_VERSION, RPC_PATH, STATE_TOOL_NAME_CLASHES_KEY, + STEP_SOURCE_POST_TURN_HOOK, TOOL_DISABLE_ACTION, TOOL_ENABLE_ACTION, WELL_KNOWN_PATH, @@ -77,6 +78,7 @@ # RFC 9110's safe methods: a request using one asks for no change, so the proxy measures no write window for it. _SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE"}) +_STEP_API = {"source": "step_api"} class ToolNameClash(ValueError): @@ -290,7 +292,7 @@ async def clock_advance(request: Request) -> Response: state = self._clock.advance(body.get("virtual_time")) except ClockError as e: return self._json_response({"ok": False, "error": str(e)}, 400) - return self._json_response(state, 200) + return self._json_response({**state, "due": self._trigger_engine.clock_advanced()}, 200) @self._mcp.custom_route("/clock/clear", methods=["POST"]) async def clock_clear(request: Request) -> Response: @@ -989,10 +991,14 @@ async def _handle_step(self, request: Request) -> Response: return self._json_response({"error": "Invalid JSON body"}, 400) action = body.get("action") role = self._request_role(request) + source = _step_source(body) + if source is None: + return self._json_response({"error": f"'source' may only be '{STEP_SOURCE_POST_TURN_HOOK}', with a " + "non-empty 'hook_artifact_id' naming the hook"}, 400) if action == "list_tools": return self._step_list_tools(role) elif action == "call_tool": - return await self._step_call_tool(body, role) + return await self._step_call_tool(body, role, source) else: return self._json_response({"error": f"Unknown action: {action}. Expected 'list_tools' or 'call_tool'."}, 400) @@ -1010,7 +1016,7 @@ def _step_list_tools(self, role: str) -> Response: ] return self._json_response({"tools": tools}, 200) - async def _step_call_tool(self, body: dict, role: str) -> Response: + async def _step_call_tool(self, body: dict, role: str, source: dict = _STEP_API) -> Response: tool_name = body.get("tool_name") if not tool_name or not isinstance(tool_name, str): return self._json_response({"error": "Missing or invalid 'tool_name' field"}, 400) @@ -1026,10 +1032,10 @@ async def _step_call_tool(self, body: dict, role: str) -> Response: server_url = self._tool_server_urls.get(tool_name) if server_url is None: - return await self._step_call_gateway_tool(tool_name, arguments, role) + return await self._step_call_gateway_tool(tool_name, arguments, role, source) tool_call_event_id = await self._log_event({ "event_type": "tool_call", - "source": "step_api", + **source, "tool_call": {"function_name": tool_name, "arguments": arguments}, **self._attribution(role), }) @@ -1039,13 +1045,13 @@ async def _step_call_tool(self, body: dict, role: str) -> Response: try: result = await self._call_child_tool(server_url, tool_name, arguments, caller=Caller(role=role)) except Exception as e: - await self._log_failed_call(tool_call_event_id, changelog_before, e, source="step_api") + await self._log_failed_call(tool_call_event_id, changelog_before, e, **source) raise changelog_id = self._query_changelog_id() await self._log_event({ "event_type": "tool_call_result", - "source": "step_api", + **source, "tool_call_event_id": tool_call_event_id, "tool_call_result": result.model_dump(mode="json"), "changelog_id_before": changelog_before, @@ -1063,14 +1069,15 @@ async def _step_call_tool(self, body: dict, role: str) -> Response: error_detail = str(e) or repr(e) return self._json_response({"error": f"Tool execution failed ({type(e).__name__}): {error_detail}"}, 500) - async def _step_call_gateway_tool(self, tool_name: str, arguments: dict, role: str = DEFAULT_ROLE) -> Response: + async def _step_call_gateway_tool(self, tool_name: str, arguments: dict, role: str = DEFAULT_ROLE, + source: dict = _STEP_API) -> Response: """Call a FastMCP-registered tool (e.g. list_website_urls) for /step.""" tool = self._mcp._tool_manager._tools.get(tool_name) if tool is None: return self._json_response({"error": f"Unknown tool: {tool_name}"}, 404) tool_call_event_id = await self._log_event({ "event_type": "tool_call", - "source": "step_api", + **source, "tool_call": {"function_name": tool_name, "arguments": arguments}, **self._attribution(role), }) @@ -1086,7 +1093,7 @@ async def _step_call_gateway_tool(self, tool_name: str, arguments: dict, role: s response_data = result.model_dump(mode="json", exclude_none=True) await self._log_event({ "event_type": "tool_call_result", - "source": "step_api", + **source, "tool_call_event_id": tool_call_event_id, "tool_call_result": response_data, "changelog_id": changelog_id, @@ -1333,6 +1340,16 @@ def model_dump_one_level(self) -> dict[str, Any]: return kwargs +def _step_source(body: dict) -> dict | None: + """The trajectory tag of a /step call: step_api, or the post-turn hook its body names; None for any other source.""" + if "source" not in body: + return _STEP_API + hook = body.get("hook_artifact_id") + if body["source"] != STEP_SOURCE_POST_TURN_HOOK or not isinstance(hook, str) or not hook: + return None + return {"source": STEP_SOURCE_POST_TURN_HOOK, "hook_artifact_id": hook} + + def _request_args(request: Request, body: bytes) -> dict: """A request's query parameters and form or JSON-object body fields (the body wins a clash; repeats become lists).""" args = _fields(parse_qsl(request.url.query, keep_blank_values=True)) diff --git a/src/agent_env/env/gateway/triggers.py b/src/agent_env/env/gateway/triggers.py index 8d4eb8817..38d35b915 100644 --- a/src/agent_env/env/gateway/triggers.py +++ b/src/agent_env/env/gateway/triggers.py @@ -414,6 +414,7 @@ def __init__(self, gateway): self._driver_task: asyncio.Task | None = None self._driver_interval = 1.0 # real seconds between autonomous time-trigger polls self._time_trigger_gate = asyncio.Event() # parks the driver while no time-trigger is armed + self._clock_moved = asyncio.Event() # set, and replaced, when an advance moves the clock under waiting actions def register(self, body: Any) -> dict: """Additively add each trigger by id (idempotent upsert; conflicting id or bad shape -> 400); watch_roles and @@ -461,7 +462,7 @@ def register(self, body: Any) -> dict: "failure_count": 0, "last_failure_at": None, "rng": _make_rng(spec), "mark_gen": None, "pending": deque(), "task": None, "delay_rngs": _delay_rngs(spec), - "attempts": 0, "waiting": False} + "attempts": 0, "waiting": False, "resumes": None} self._emit("added", tid) self._recompute_time_flag() return {"ok": True, "added": batch_order, "all": list(self._triggers)} @@ -840,6 +841,9 @@ def state(self) -> dict: events = self._events + list(self._events_tail) for t in self._triggers.values(): assert t["status"] in TRIGGER_STATUSES, f"unknown trigger status {t['status']!r}" + resumes = {tid: self._resumes_at(t) for tid, t in self._triggers.items()} + upcoming = [self._upcoming_mark(t) for t in self._triggers.values()] + list(resumes.values()) + next_event = min((at for at in upcoming if at is not None), default=None) return { "config": {"watch_roles": sorted(self._watch_roles), "executor_configured": None in self._executors, @@ -852,9 +856,11 @@ def state(self) -> dict: "failure_count": t["failure_count"], "last_failure_at": t["last_failure_at"], "barrier": t["spec"].get("barrier"), "pending": len(t["pending"]), "waiting": t["waiting"], + "resumes_at": _iso(resumes[tid]) if resumes[tid] else None, "next_mark": _iso(t["next_mark"]) if t["next_mark"] else None} for tid, t in self._triggers.items() ], + "next_event_at": _iso(next_event) if next_event else None, "events": events, "events_dropped": max(0, self._seq - len(events)), } @@ -984,12 +990,13 @@ def _mark_detected(self, tid: str, trig: dict, provoking: dict, ctx: dict | None trig["task"].set_name(tid) return trig["task"] - def _eval_time_triggers(self) -> None: - """Poll armed time-triggers against the virtual clock and schedule the due ones.""" + def _eval_time_triggers(self) -> list[str]: + """Poll armed time-triggers against the virtual clock and schedule the due ones; returns their ids.""" clock = self._gw._clock now = clock.now() + detected: list[str] = [] if now is None: # clock unarmed -> nothing can be due yet - return + return detected gen = clock.generation for tid, trig in list(self._triggers.items()): when = trig["spec"]["when"] @@ -1015,24 +1022,65 @@ def _eval_time_triggers(self) -> None: if marks: # The cause is time passing, not whichever path observed it. self._mark_time_detected(tid, trig, marks, capped, terminated, {"source": "clock"}) + detected.append(tid) elif terminated: # retired without firing (e.g. `until` already passed) trig["status"] = "fired" trig["next_mark"] = None self._recompute_time_flag() + return detected + + def clock_advanced(self) -> list[str]: + """The triggers an advance made due, fired now or with their wait ended, and their waiting actions woken.""" + due = self._eval_time_triggers() + now = self._gw._clock.now() + ended = [tid for tid, t in self._triggers.items() + if now is not None and (at := self._resumes_at(t)) is not None and at <= now] + self._clock_moved.set() + self._clock_moved = asyncio.Event() + return list(dict.fromkeys(due + ended)) def _resolve_first_mark(self, trig: dict, now: datetime) -> None: """Stamp the first mark for a non-anchored time-trigger; event-anchored ones wait for _resolve_dependents.""" when = trig["spec"]["when"] if "after" in when: return + trig["next_mark"] = self._first_mark(when, trig["rng"]) + trig["resolved"] = True + + def _first_mark(self, when: dict, rng: random.Random | None) -> datetime: + """A non-anchored time trigger's first mark on the current arm, a stochastic interval drawn from `rng`.""" t0 = self._gw._clock.t0() if "at" in when: kind, val = _time_mark_spec(when["at"]) - start = val if kind == "abs" else _advance(t0, val.total_seconds()) - else: # recurring with no explicit start: first fire is one interval after t0 - start = _advance(t0, self._next_interval_seconds(trig)) - trig["next_mark"] = start - trig["resolved"] = True + return val if kind == "abs" else _advance(t0, val.total_seconds()) + # recurring with no explicit start: first fire is one interval after t0 + return _advance(t0, _interval_seconds(when, rng)) + + def _upcoming_mark(self, trig: dict) -> datetime | None: + """The mark the engine fires `trig` at next, stamped as its next evaluation would stamp it, without stamping.""" + when = trig["spec"]["when"] + clock = self._gw._clock + # A trigger mid-fire can't fire again until that fire ends; a waiting one's resumes_at stands in for it. + if when["type"] != "time" or trig["status"] != "armed" or not clock.armed: + return None + if "after" in when or (trig["resolved"] and trig["mark_gen"] == clock.generation): + mark = trig["next_mark"] + else: # unresolved, or resolved on an earlier arm, which re-anchors it with a fresh draw + source = trig["rng"] if not trig["resolved"] else _make_rng(trig["spec"]) + rng = None + if source is not None: + rng = random.Random() + rng.setstate(source.getstate()) + mark = self._first_mark(when, rng) + until = self._until_dt(trig) + return None if mark is None or (until is not None and mark > until) else mark + + def _resumes_at(self, trig: dict) -> datetime | None: + """When a fire waiting out a delay or its executor's hours goes on, in virtual time; None when none waits.""" + resumes = trig["resumes"] + if resumes is None or isinstance(resumes, datetime): + return resumes + return self._gw._clock.at_elapsed(resumes) def _resolve_dependents(self, anchor_tid: str) -> None: """Stamp the concrete mark (virtual_now + offset) of every time-trigger anchored on `anchor_tid`.""" @@ -1060,11 +1108,7 @@ def _until_dt(self, trig: dict) -> datetime | None: def _next_interval_seconds(self, trig: dict) -> float: """Virtual seconds to the next mark; drawn on the sync path so the sequence depends only on the seed.""" - every = trig["spec"]["when"]["every"] - if isinstance(every, dict): - mean_s = _parse_duration(every["mean"]).total_seconds() - return trig["rng"].expovariate(1.0 / mean_s) - return _parse_duration(every).total_seconds() + return _interval_seconds(trig["spec"]["when"], trig["rng"]) def _advance_marks(self, trig: dict, now: datetime) -> tuple[list[datetime], bool, bool]: """SYNC: the marks due at `now` (<= _MAX_CATCHUP), advancing next_mark past them. @@ -1385,10 +1429,12 @@ async def _delay(self, tid: str, trig: dict, index: int, action: dict, since: fl seconds = trig["delay_rngs"][index].expovariate(1.0 / mean) self._emit("delayed", tid, action_index=index, virtual_seconds=round(seconds, 3), **_named_executor(action)) trig["waiting"] = True + trig["resumes"] = since + seconds try: await self._sleep_virtual(seconds - (self._gw._clock.elapsed() - since)) finally: trig["waiting"] = False + trig["resumes"] = None async def _await_hours(self, tid: str, trig: dict, index: int, hours: dict, named: dict) -> None: """Wait until the executor's working hours are open, reckoned again after a re-arm; they don't apply while the @@ -1403,6 +1449,7 @@ async def _await_hours(self, tid: str, trig: dict, index: int, hours: dict, name return if current != gen: gen, opens = current, _next_opening(now, hours) + trig["resumes"] = opens if opens is not None: self._emit("deferred", tid, action_index=index, until=_iso(opens), **named) if opens is None or now >= opens: @@ -1410,6 +1457,7 @@ async def _await_hours(self, tid: str, trig: dict, index: int, hours: dict, name await self._sleep_virtual((opens - now).total_seconds(), generation=gen) finally: trig["waiting"] = False + trig["resumes"] = None async def _sleep_virtual(self, seconds: float, generation: int | None = None) -> None: """Wait out ``seconds`` of virtual time as the clock runs (real time at its rate, 1 while unarmed, none while @@ -1418,7 +1466,17 @@ async def _sleep_virtual(self, seconds: float, generation: int | None = None) -> until = clock.elapsed() + seconds while (left := until - clock.elapsed()) > 0 and (generation is None or clock.generation == generation): rate = clock.rate - await asyncio.sleep(min(left / rate, _VIRTUAL_RECHECK_S) if rate else _VIRTUAL_RECHECK_S) + await self._nap(min(left / rate, _VIRTUAL_RECHECK_S) if rate else _VIRTUAL_RECHECK_S) + + async def _nap(self, seconds: float) -> None: + """Sleep `seconds` of real time, cut short when an advance moves the clock.""" + nap = asyncio.ensure_future(asyncio.sleep(seconds)) + moved = asyncio.ensure_future(self._clock_moved.wait()) + try: + await asyncio.wait({nap, moved}, return_when=asyncio.FIRST_COMPLETED) + finally: + nap.cancel() + moved.cancel() async def _call_executor(self, executor: dict | None, instruction: str, context_id: str) -> str | None: if executor is None: @@ -1459,6 +1517,14 @@ def _cancel(self, tid: str, trig: dict, **cause) -> None: self._cancel(dep_id, dep, anchor=tid) +def _interval_seconds(when: dict, rng: random.Random | None) -> float: + """Virtual seconds from one mark of a recurring time trigger to the next, a stochastic one drawn from `rng`.""" + every = when["every"] + if isinstance(every, dict): + return rng.expovariate(1.0 / _parse_duration(every["mean"]).total_seconds()) + return _parse_duration(every).total_seconds() + + def _result_text(result: CallToolResult) -> str: return "\n".join(c.text for c in result.content if getattr(c, "type", None) == "text") diff --git a/tst/unit/env/gateway/next_event_test.py b/tst/unit/env/gateway/next_event_test.py new file mode 100644 index 000000000..8edcc819e --- /dev/null +++ b/tst/unit/env/gateway/next_event_test.py @@ -0,0 +1,305 @@ +"""The engine's next_event_at and resumes_at, what an advance makes due, and the /step tag of a hook's tool calls.""" +from __future__ import annotations + +import asyncio +import json +import time + +import httpx +import pytest + +from agent_env.env.gateway import gateway as gw_mod +from agent_env.env.gateway.clock import Clock +from agent_env.env.gateway.constants import ( + EXT_STEP_URI, EXT_TRIGGERS_URI, GATEWAY_EXTENSIONS, STEP_SOURCE, TRIGGER_NEXT_EVENT_AT, +) +from agent_env.env.gateway.gateway import Gateway +from agent_env.env.gateway.triggers import TriggerEngine +from tst.unit.env.gateway.triggers_time_test import FakeNow, _FakeGateway, _perm, _result, _settle + +MONDAY = "2026-03-02T00:00:00Z" + + +def _engine(rate: float = 0.0) -> tuple[TriggerEngine, Clock, FakeNow]: + now = FakeNow() + clock = Clock(now_fn=now) + clock.set_time(MONDAY, rate=rate) + return TriggerEngine(_FakeGateway(clock)), clock, now + + +def _time(tid: str, **when) -> dict: + return {"id": tid, "when": {"type": "time", **when}, "actions": [_perm()]} + + +def _next(engine: TriggerEngine) -> str | None: + return engine.state()["next_event_at"] + + +def _row(engine: TriggerEngine, tid: str) -> dict: + return next(t for t in engine.state()["triggers"] if t["id"] == tid) + + +async def _until(predicate, seconds: float = 2.0) -> None: + deadline = time.monotonic() + seconds + while not predicate(): + assert time.monotonic() < deadline, "condition not reached" + await asyncio.sleep(0.01) + + +# ---- next_event_at --------------------------------------------------------------------------------------------------- + +def test_the_next_event_is_the_earliest_armed_mark_before_the_engine_has_evaluated_any(): + engine, _, _ = _engine() + engine.register({"triggers": [_time("thu", at="P2DT14H15M"), _time("tue", at="2026-03-03T09:00:00Z")]}) + + assert _next(engine) == "2026-03-03T09:00:00Z" + assert [_row(engine, tid)["next_mark"] for tid in ("thu", "tue")] == [None, None] + + +@pytest.mark.asyncio +async def test_a_recurring_trigger_is_next_due_one_interval_on_and_again_after_each_arrival(): + engine, clock, _ = _engine() + engine.register({"triggers": [_time("beat", every="PT6H")]}) + assert _next(engine) == "2026-03-02T06:00:00Z" + + clock.advance("2026-03-02T07:00:00Z") + engine._eval_time_triggers() + await _settle(engine) + + assert _next(engine) == "2026-03-02T12:00:00Z" + + +def test_a_stochastic_mark_is_read_without_drawing_it_so_the_engine_stamps_the_same_one(): + engine, _, _ = _engine() + engine.register({"triggers": [_time("poisson", every={"dist": "exp", "mean": "PT1H"}, seed=7)]}) + + first, again = _next(engine), _next(engine) + engine._eval_time_triggers() + + assert first == again == _row(engine, "poisson")["next_mark"] + + +def test_after_a_re_arm_a_relative_mark_is_read_on_the_new_t0(): + engine, clock, _ = _engine() + engine.register({"triggers": [_time("rel", at="PT3H")]}) + engine._eval_time_triggers() + clock.set_time("2026-03-05T00:00:00Z", rate=0) + + assert _next(engine) == "2026-03-05T03:00:00Z" + + +@pytest.mark.asyncio +async def test_a_mark_past_until_or_past_count_is_no_event(): + engine, clock, _ = _engine() + engine.register({"triggers": [_time("capped", every="PT1H", until="PT90M"), _time("twice", every="PT4H", count=2)]}) + clock.advance("2026-03-02T01:00:00Z") + engine._eval_time_triggers() + await _settle(engine) + assert _next(engine) == "2026-03-02T04:00:00Z" + + clock.advance("2026-03-02T08:00:00Z") + engine._eval_time_triggers() + await _settle(engine) + + assert _next(engine) is None + + +@pytest.mark.asyncio +async def test_a_deadline_after_another_trigger_is_next_due_only_once_its_anchor_fires(): + engine, clock, _ = _engine() + engine.register({"triggers": [_time("ask", at="PT1H"), _time("chase", after="ask", offset="P1D")]}) + assert _next(engine) == "2026-03-02T01:00:00Z" + + clock.advance("2026-03-02T01:00:00Z") + engine._eval_time_triggers() + await _settle(engine) + + assert _next(engine) == "2026-03-03T01:00:00Z" + + +@pytest.mark.asyncio +async def test_nothing_is_next_once_every_time_trigger_fired_and_while_the_clock_is_unarmed(): + engine, clock, _ = _engine() + engine.register({"triggers": [_time("once", at="PT1H")]}) + clock.advance("2026-03-02T02:00:00Z") + engine._eval_time_triggers() + await _settle(engine) + assert (_row(engine, "once")["status"], _next(engine)) == ("fired", None) + + engine.register({"triggers": [_time("later", at="PT5H")]}) + clock.clear() + + assert _next(engine) is None + + +# ---- resumes_at ----------------------------------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_a_delayed_action_resumes_at_its_mark_plus_the_delay_and_is_the_next_event(): + engine, clock, _ = _engine() + engine.register({"triggers": [{"id": "reply", "when": {"type": "time", "at": "2026-03-03T09:00:00Z"}, + "actions": [{**_perm(), "delay": "PT2H"}]}]}) + clock.advance("2026-03-03T09:00:00Z") + assert engine.clock_advanced() == ["reply"] + + await _until(lambda: _row(engine, "reply")["waiting"]) + + assert _row(engine, "reply")["resumes_at"] == "2026-03-03T11:00:00Z" + assert _next(engine) == "2026-03-03T11:00:00Z" + await engine.cancel_scheduled() + + +@pytest.mark.asyncio +async def test_a_recurring_trigger_waiting_out_a_delay_is_next_due_when_its_action_resumes_not_at_its_next_mark(): + engine, clock, _ = _engine() + engine.register({"triggers": [{"id": "beat", "when": {"type": "time", "every": "PT1H"}, + "actions": [{**_perm(), "delay": "PT2H"}]}]}) + clock.advance("2026-03-02T01:00:00Z") + engine.clock_advanced() + await _until(lambda: _row(engine, "beat")["waiting"]) + + assert (_row(engine, "beat")["next_mark"], _next(engine)) == ("2026-03-02T02:00:00Z", "2026-03-02T03:00:00Z") + await engine.cancel_scheduled() + + +@pytest.mark.asyncio +async def test_an_action_delay_resumes_from_the_provoking_call(): + engine, clock, now = _engine(rate=1.0) + engine.register({"watch_roles": ["default"], "triggers": [ + {"id": "ack", "when": {"type": "action", "tool": "send_email"}, "actions": [{**_perm(), "delay": "PT30M"}]}]}) + now.tick(60) + engine.on_tool_call("default", "send_email", {}, _result()) + + await _until(lambda: _row(engine, "ack")["waiting"]) + + assert _row(engine, "ack")["resumes_at"] == _next(engine) == "2026-03-02T00:31:00Z" + await engine.cancel_scheduled() + + +@pytest.mark.asyncio +async def test_an_action_waiting_for_its_executor_s_hours_resumes_when_they_open(monkeypatch): + async def reply(self, executor, instruction, context_id): + return "done" + + monkeypatch.setattr(TriggerEngine, "_call_executor", reply) + engine, _, _ = _engine() + engine.register({"watch_roles": ["default"], + "executors": {"bob": {"a2a_url": "http://bob", "role": "bob", + "hours": {"start": "09:00", "end": "17:00"}}}, + "triggers": [{"id": "ask-bob", "when": {"type": "action", "tool": "send_email"}, + "actions": [{"type": "nl", "executor": "bob", "instruction": "reply"}]}]}) + engine.on_tool_call("default", "send_email", {}, _result()) + + await _until(lambda: _row(engine, "ask-bob")["waiting"]) + + assert _row(engine, "ask-bob")["resumes_at"] == _next(engine) == "2026-03-02T09:00:00Z" + await engine.cancel_scheduled() + + +# ---- what an advance makes due -------------------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_an_advance_names_the_time_triggers_it_reaches_and_they_are_firing_when_it_returns(): + engine, clock, _ = _engine() + engine.register({"triggers": [_time("tue", at="2026-03-03T09:00:00Z"), _time("wed", at="2026-03-04T09:00:00Z")]}) + clock.advance("2026-03-03T09:00:00Z") + + assert engine.clock_advanced() == ["tue"] + assert (_row(engine, "tue")["status"], _row(engine, "wed")["status"]) == ("firing", "armed") + await _settle(engine) + + +@pytest.mark.asyncio +async def test_an_advance_past_a_waiting_action_names_it_and_wakes_it_at_once(): + engine, clock, _ = _engine() + engine.register({"triggers": [{"id": "reply", "when": {"type": "time", "at": "PT1M"}, + "actions": [{**_perm(), "delay": "PT2H"}]}]}) + clock.advance("2026-03-02T00:01:00Z") + engine.clock_advanced() + await _until(lambda: _row(engine, "reply")["waiting"]) + + clock.advance("2026-03-02T02:01:00Z") + started = time.monotonic() + assert engine.clock_advanced() == ["reply"] + await _until(lambda: _row(engine, "reply")["status"] == "fired") + + assert time.monotonic() - started < 1.0 + assert _next(engine) is None + + +@pytest.fixture +def gateway(tmp_path, monkeypatch) -> Gateway: + monkeypatch.setattr(gw_mod, "GATEWAY_TRAJECTORY_FILE", str(tmp_path / "trajectory.jsonl")) + gw = Gateway(host="127.0.0.1", port=0, server_name="AgentEnvGateway", internal_mcp_servers=[]) + gw._clock = Clock(now_fn=FakeNow()) + gw._tool_server_urls = {"send_email": "http://svc/mcp"} + + async def call_child_tool(server_url, tool_name, arguments, *, caller=None): + return _result() + + gw._call_child_tool = call_child_tool + yield gw + gw._close_trajectory_file() + + +async def _request(gw: Gateway, method: str, path: str, body: dict, role: str | None = None) -> httpx.Response: + transport = httpx.ASGITransport(app=gw._mcp.streamable_http_app()) + headers = {"AgentEnv-Role": role} if role else {} + async with httpx.AsyncClient(transport=transport, base_url="http://gateway") as client: + return await client.request(method, path, json=body, headers=headers) + + +@pytest.mark.asyncio +async def test_the_advance_route_returns_what_came_due(gateway): + gateway._clock.set_time(MONDAY, rate=0) + gateway._trigger_engine.register({"triggers": [_time("tue", at="2026-03-03T09:00:00Z")]}) + + resp = await _request(gateway, "PUT", "/clock/advance", {"virtual_time": "2026-03-03T09:30:00Z"}) + + assert resp.status_code == 200 + assert (resp.json()["virtual_time"], resp.json()["due"]) == ("2026-03-03T09:30:00Z", ["tue"]) + + +# ---- the /step source tag ------------------------------------------------------------------------------------------- + +def _trajectory(gw: Gateway) -> list[dict]: + with open(gw_mod.GATEWAY_TRAJECTORY_FILE) as f: + return [json.loads(line) for line in f] + + +@pytest.mark.asyncio +async def test_a_step_call_a_hook_makes_is_recorded_as_the_hooks(gateway): + body = {"action": "call_tool", "tool_name": "send_email", "arguments": {"to": "a@example.com"}, + "source": "post_turn_hook", "hook_artifact_id": "next-env-event"} + + resp = await _request(gateway, "POST", "/step", body, role="assistant") + + assert resp.status_code == 200 + assert [(e["event_type"], e["source"], e["hook_artifact_id"]) for e in _trajectory(gateway)] == [ + ("tool_call", "post_turn_hook", "next-env-event"), ("tool_call_result", "post_turn_hook", "next-env-event")] + assert _trajectory(gateway)[0]["role"] == "assistant" + + +@pytest.mark.asyncio +async def test_a_step_call_naming_no_source_is_still_step_api(gateway): + resp = await _request(gateway, "POST", "/step", {"action": "call_tool", "tool_name": "send_email"}) + + assert resp.status_code == 200 + assert {e["source"] for e in _trajectory(gateway)} == {"step_api"} + assert not any("hook_artifact_id" in e for e in _trajectory(gateway)) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("tag", [{"source": "trigger_engine", "hook_artifact_id": "h"}, {"source": "post_turn_hook"}, + {"source": "post_turn_hook", "hook_artifact_id": ""}]) +async def test_a_step_call_naming_another_source_or_no_hook_is_refused(gateway, tag): + resp = await _request(gateway, "POST", "/step", {"action": "call_tool", "tool_name": "send_email", **tag}) + + assert resp.status_code == 400 and "'source' may only be 'post_turn_hook'" in resp.json()["error"] + assert _trajectory(gateway) == [] + + +def test_the_card_declares_next_event_at_and_the_step_source_tag(): + extensions = {e["uri"]: e for e in GATEWAY_EXTENSIONS} + assert TRIGGER_NEXT_EVENT_AT in extensions[EXT_TRIGGERS_URI]["params"]["features"] + assert extensions[EXT_STEP_URI]["params"]["features"] == [STEP_SOURCE] From d5af210bc64a7b5b70281cfcd2f5a09d4282e3e9 Mon Sep 17 00:00:00 2001 From: Sarah Chitty Date: Mon, 26 Oct 2026 08:56:23 -0700 Subject: [PATCH 11/15] review: the next-env-event example hook, which runs a conversation from 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 --- pyproject.toml | 1 + .../examples/next-env-event/README.md | 33 +++ .../artifacts/next-env-event/hook.py | 160 +++++++++++ .../next-env-event/envs/inbox/Dockerfile | 8 + .../envs/inbox/requirements.txt | 3 + .../next-env-event/envs/inbox/server.py | 39 +++ .../examples/next-env-event/tasks/week.json | 20 ++ tst/installer/test_container_journey.py | 2 +- .../test_next_env_event_hook_local.py | 126 +++++++++ .../test_prompt_agent_between_turns_local.py | 84 ------ tst/unit/bundle/installed_test.py | 3 +- tst/unit/bundle/shipped_bundles_test.py | 2 +- .../task_step/test_next_env_event_hook.py | 248 ++++++++++++++++++ tst/unit/test_clean_install_wheel.py | 2 +- 14 files changed, 643 insertions(+), 88 deletions(-) create mode 100644 src/agent_env/examples/next-env-event/README.md create mode 100644 src/agent_env/examples/next-env-event/artifacts/next-env-event/hook.py create mode 100644 src/agent_env/examples/next-env-event/envs/inbox/Dockerfile create mode 100644 src/agent_env/examples/next-env-event/envs/inbox/requirements.txt create mode 100644 src/agent_env/examples/next-env-event/envs/inbox/server.py create mode 100644 src/agent_env/examples/next-env-event/tasks/week.json create mode 100644 tst/integration/task_step/test_next_env_event_hook_local.py delete mode 100644 tst/integration/task_step/test_prompt_agent_between_turns_local.py create mode 100644 tst/unit/task_step/test_next_env_event_hook.py diff --git a/pyproject.toml b/pyproject.toml index b0a39e3b0..cb4a3efd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -119,6 +119,7 @@ agent-env = "agent_env.cli:cli" agents = "agent_env" hello = "agent_env.examples" echo-mcp = "agent_env.examples" +next-env-event = "agent_env.examples" [project.urls] Homepage = "https://www.agentenvframework.com" diff --git a/src/agent_env/examples/next-env-event/README.md b/src/agent_env/examples/next-env-event/README.md new file mode 100644 index 000000000..e6ec975f0 --- /dev/null +++ b/src/agent_env/examples/next-env-event/README.md @@ -0,0 +1,33 @@ +A prompt_agent post-turn hook that runs one conversation from env event to env event on simulated time. + +`artifacts/next-env-event/hook.py` is the hook. After each agent turn it first waits for what the agent's turn +set off in the env to finish, then lets a firing agent trigger answer. Otherwise it reads `next_event_at` from +each env whose clock is armed: the earliest simulated time that env's triggers act on their own, whether a time +trigger's mark, a delayed action resuming, or an executor's hours opening. It advances every clock that is behind +it, waits for the triggers that came due to settle, and asks the agent triggers again, or else wakes the agent +with a nudge naming the time. It ends the conversation when nothing is pending or the next event is past +`end_time`. + +`tasks/week.json` runs it over a week of mail in the `inbox` env, with the default agent. It needs Docker and +a model endpoint: `agent-env run next-env-event`. + +Reference the hook from a `prompt_agent` step: + +```json +{"id": "week", "type": "prompt_agent", "prompt": "Look after my inbox this week.", "max_conversation_turns": 12, + "post_turn_hooks": [{"file_artifact_id": "@local/agentenv-framework/next-env-event/next-env-event", + "params": {"end_time": "P5D"}}]} +``` + +Its params, all optional: + +- `end_time`: an RFC 3339 instant with an offset, or an ISO 8601 duration from the clock's t0 (`P5D`). +- `nudge`: the message that wakes the agent; `{virtual_time}` is its one placeholder. +- `settle_timeout_seconds`: how long to wait for triggers to settle before going on anyway, 300 by default. + +It is made for a frozen clock (`virtual_seconds_per_real_second: 0`): on a running clock a trigger that recurs +faster than a turn keeps firing, and each settle waits out its timeout. The envs need a gateway image that offers +clock/v1 `advance` and triggers/v1 `next_event_at`; on an older one the hook logs a warning naming the env and +ends the conversation without jumping. A user agent with agent triggers needs a triggers/v1 `decide` that takes +`record_unfired`, since the hook asks its triggers twice a turn. A hook runs in the control-plane process with the +trust of an `env_outcome_verifier` script. diff --git a/src/agent_env/examples/next-env-event/artifacts/next-env-event/hook.py b/src/agent_env/examples/next-env-event/artifacts/next-env-event/hook.py new file mode 100644 index 000000000..023589415 --- /dev/null +++ b/src/agent_env/examples/next-env-event/artifacts/next-env-event/hook.py @@ -0,0 +1,160 @@ +"""A prompt_agent post-turn hook that jumps env clocks to the next event their triggers act on, then wakes the agent.""" +import asyncio +import logging +import re +import time +from datetime import datetime, timedelta, timezone + +CLOCK = "urn:agentenv:clock/v1" +TRIGGERS = "urn:agentenv:triggers/v1" +IN_FLIGHT = ("firing", "queued") +NUDGE = "[{virtual_time}] Time has passed. Check for anything new and handle it." +PARAMS = ("end_time", "nudge", "settle_timeout_seconds") +POLL_SECONDS = 0.5 +DURATION = re.compile(r"P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?") + +logger = logging.getLogger(__name__) + + +async def post_turn(turn, params): + """A firing agent trigger answers first; else jump to the next env event, let it settle, and wake the agent.""" + if turn.last: + return None + end_time, nudge, settle_timeout = _params(params) + envs = await _armed(turn) + old = [env.env_id for env, _, state in envs if "next_event_at" not in state or not env.supports(CLOCK, "advance")] + if envs and not old: + await _settle(turn, envs, settle_timeout) + answer = await turn.user_triggers() + if answer is not None: + return answer + if not envs: + return _end(turn, "no_armed_env_clock") + if old: + logger.warning(f"env(s) {old} have no clock advance or next_event_at; rebuild their gateway image to jump " + "to the next event") + return _end(turn, "gateway_without_advance", envs=old) + envs = await _armed(turn) + pending = [_utc(state["next_event_at"]) for _, _, state in envs if state["next_event_at"]] + if not pending: + return _end(turn, "no_pending_events") + event_at = min(pending) + end = _end_instant(end_time, envs) + if end is not None and event_at > end: + return _end(turn, "end_time", next_event_at=_iso(event_at)) + due = [await _advance(env, event_at) if _utc(clock["virtual_time"]) < event_at else [] for env, clock, _ in envs] + settled = await _settle(turn, envs, settle_timeout) + turn.record({"event_at": _iso(event_at), "envs": [ + {"env_id": env.env_id, "env_step_id": env.env_step_id, "due": d, "settled": ok} + for (env, _, _), d, ok in zip(envs, due, settled)]}) + answer = await turn.user_triggers() + return answer if answer is not None else {"message": nudge.format(virtual_time=_iso(event_at))} + + +def _params(params): + unknown = sorted(set(params) - set(PARAMS)) + if unknown: + raise ValueError(f"next-env-event takes {list(PARAMS)}, not {unknown}") + end_time = params.get("end_time") + if end_time is not None: + _end_spec(end_time) + nudge = params.get("nudge", NUDGE) + try: + nudge.format(virtual_time="") + except (AttributeError, KeyError, IndexError, ValueError) as e: + raise ValueError(f"nudge is text whose one placeholder is {{virtual_time}}: {e!r}") from e + timeout = params.get("settle_timeout_seconds", 300) + if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or timeout <= 0: + raise ValueError(f"settle_timeout_seconds must be a positive number, got {timeout!r}") + return end_time, nudge, timeout + + +async def _armed(turn): + """Each env offering clock and triggers state whose clock is armed, with its clock and triggers state.""" + armed = [] + for env in turn.envs: + if not (env.supports(CLOCK, "state") and env.supports(TRIGGERS, "state")): + continue + clock = await env.invoke(CLOCK, "state") + if clock.get("armed"): + armed.append((env, clock, await env.invoke(TRIGGERS, "state"))) + return armed + + +async def _advance(env, target): + """Advance to ``target``, running the env's time check: what it made due, none if a running clock got there.""" + try: + return (await env.invoke(CLOCK, "advance", {"virtual_time": _iso(target)})).get("due", []) + except Exception: + clock = await env.invoke(CLOCK, "state") + if clock.get("armed") and _utc(clock["virtual_time"]) >= target: + return [] + raise + + +async def _settle(turn, envs, timeout): + """Wait until no trigger on each env still fires, bar one waiting for later; whether each settled in time.""" + deadline = time.monotonic() + timeout + waiting = set(range(len(envs))) + while True: + for i in sorted(waiting): + env = envs[i][0] + clock, state = await env.invoke(CLOCK, "state"), await env.invoke(TRIGGERS, "state") + now = _utc(clock["virtual_time"]) + if state["next_event_at"] is not None and _utc(state["next_event_at"]) <= now: + await _advance(env, now) # an event already reached waits for the time check, so run it now + elif not any(_busy(row, now) for row in state["triggers"]): + waiting.discard(i) + if not waiting or time.monotonic() >= deadline: + break + turn.heartbeat() + await asyncio.sleep(POLL_SECONDS) + if waiting: + logger.warning(f"env(s) {sorted(envs[i][0].env_id for i in waiting)} still had triggers firing {timeout}s " + "on; waking the agent anyway") + return [i not in waiting for i in range(len(envs))] + + +def _busy(row, now): + """A trigger is still firing, and not just waiting for a later time.""" + if row["status"] not in IN_FLIGHT and not row.get("pending"): + return False + return not (row.get("waiting") and row.get("resumes_at") and _utc(row["resumes_at"]) > now) + + +def _end_spec(end_time): + """``end_time`` as ("at", instant) for RFC 3339 with an offset, or ("after", timedelta) for an ISO 8601 duration.""" + if isinstance(end_time, str) and end_time.startswith("P"): + match = DURATION.fullmatch(end_time) + if match is not None and any(match.groups()): + weeks, days, hours, minutes, seconds = (float(g or 0) for g in match.groups()) + return "after", timedelta(weeks=weeks, days=days, hours=hours, minutes=minutes, seconds=seconds) + elif isinstance(end_time, str): + try: + at = datetime.fromisoformat(end_time.replace("Z", "+00:00")) + except ValueError: + at = None + if at is not None and at.tzinfo is not None: + return "at", at.astimezone(timezone.utc) + raise ValueError(f"end_time {end_time!r} is neither an RFC 3339 instant with an offset nor a duration like 'P4D'") + + +def _end_instant(end_time, envs): + """``end_time`` as an instant, a duration counting from the earliest env t0.""" + if end_time is None: + return None + kind, value = _end_spec(end_time) + return value if kind == "at" else min(_utc(clock["t0"]) for _, clock, _ in envs) + value + + +def _end(turn, reason, **detail): + turn.record({"end": reason, **detail}) + return {"end": reason} + + +def _utc(text): + return datetime.fromisoformat(text.replace("Z", "+00:00")).astimezone(timezone.utc) + + +def _iso(value): + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") diff --git a/src/agent_env/examples/next-env-event/envs/inbox/Dockerfile b/src/agent_env/examples/next-env-event/envs/inbox/Dockerfile new file mode 100644 index 000000000..f7aeb5dd1 --- /dev/null +++ b/src/agent_env/examples/next-env-event/envs/inbox/Dockerfile @@ -0,0 +1,8 @@ +FROM public.ecr.aws/docker/library/python:3.11-slim@sha256:c20888b6acdd1e63e1c433a185bf3ad162c0288fe484616ce062e0d28add2900 +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY server.py . +RUN useradd --create-home --uid 10001 appuser +USER appuser +CMD ["python", "server.py"] diff --git a/src/agent_env/examples/next-env-event/envs/inbox/requirements.txt b/src/agent_env/examples/next-env-event/envs/inbox/requirements.txt new file mode 100644 index 000000000..2f5494844 --- /dev/null +++ b/src/agent_env/examples/next-env-event/envs/inbox/requirements.txt @@ -0,0 +1,3 @@ +# A release pins agentenv-framework-protocol to the version it was released with. +agentenv-framework-protocol +mcp>=1.25,<2 diff --git a/src/agent_env/examples/next-env-event/envs/inbox/server.py b/src/agent_env/examples/next-env-event/envs/inbox/server.py new file mode 100644 index 000000000..9306f3e80 --- /dev/null +++ b/src/agent_env/examples/next-env-event/envs/inbox/server.py @@ -0,0 +1,39 @@ +"""The inbox env: mail the task's triggers deliver over the week, for the agent to read and answer.""" +from typing import Annotated + +from pydantic import Field + +from agentenv_protocol import AgentEnvEnvironment, environment_card, tool + + +@environment_card(name="inbox") +class InboxEnv(AgentEnvEnvironment): + def __init__(self) -> None: + super().__init__() + self.inbox: list[dict] = [] + self.sent: list[dict] = [] + + @tool(name="deliver_email", hidden_by_default=True) + def deliver_email(self, sender: Annotated[str, Field(description="Who it is from.")], + subject: Annotated[str, Field(description="Its subject.")], + body: Annotated[str, Field(description="Its text.")]) -> str: + """Deliver an email to the inbox; the task's triggers call it, and the agent never sees it.""" + self.inbox.append({"id": len(self.inbox) + 1, "from": sender, "subject": subject, "body": body}) + return "delivered" + + @tool(name="list_inbox") + def list_inbox(self) -> list[dict]: + """Every email in the inbox, oldest first.""" + return self.inbox + + @tool(name="send_email") + def send_email(self, to: Annotated[str, Field(description="Who to send it to.")], + subject: Annotated[str, Field(description="Its subject.")], + body: Annotated[str, Field(description="Its text.")]) -> str: + """Send an email.""" + self.sent.append({"to": to, "subject": subject, "body": body}) + return "sent" + + +if __name__ == "__main__": + InboxEnv().serve() diff --git a/src/agent_env/examples/next-env-event/tasks/week.json b/src/agent_env/examples/next-env-event/tasks/week.json new file mode 100644 index 000000000..1d57a1d72 --- /dev/null +++ b/src/agent_env/examples/next-env-event/tasks/week.json @@ -0,0 +1,20 @@ +[ + {"id": "env", "type": "deploy_env", "env_id": "inbox", "sandbox_type": "local"}, + {"id": "clock", "type": "sync_env_clock", "env_id": "inbox", "virtual_time": "2026-03-02T08:00:00Z", + "virtual_seconds_per_real_second": 0}, + {"id": "mail", "type": "register_env_triggers", "env_id": "inbox", "watch_roles": ["default"], "triggers": [ + {"id": "school-pickup", "when": {"type": "time", "at": "2026-03-03T09:00:00Z"}, + "actions": [{"type": "tool", "tool": "deliver_email", "args": {"sender": "office@school.example", + "subject": "Thursday pick-up", "body": "Pick-up moves to 15:00 on Thursday. Please reply to confirm."}}]}, + {"id": "school-thanks", "when": {"type": "action", "tool": "send_email", "where": {"to": {"regex": "school"}}}, + "actions": [{"type": "tool", "tool": "deliver_email", "delay": "PT2H", "args": { + "sender": "office@school.example", "subject": "Re: Thursday pick-up", "body": "Thanks, noted."}}]}, + {"id": "dentist", "when": {"type": "time", "at": "P3DT6H"}, + "actions": [{"type": "tool", "tool": "deliver_email", "args": {"sender": "desk@dentist.example", + "subject": "Your appointment", "body": "Your check-up is moved to Friday at 10:00. Reply if that doesn't work."}}]} + ]}, + {"id": "agent", "type": "deploy_agent", "env_ids": ["inbox"]}, + {"id": "week", "type": "prompt_agent", "max_conversation_turns": 8, + "prompt": "You look after my inbox this week. Each time you're woken, read the inbox and handle anything new: answer what needs an answer, and tell me in one line what you did.", + "post_turn_hooks": [{"file_artifact_id": "next-env-event", "params": {"end_time": "P5D"}}]} +] diff --git a/tst/installer/test_container_journey.py b/tst/installer/test_container_journey.py index 2a61d7966..02aa69ad2 100644 --- a/tst/installer/test_container_journey.py +++ b/tst/installer/test_container_journey.py @@ -133,7 +133,7 @@ def test_a_new_user_installs_agent_env_adds_plugins_runs_a_task_and_removes_them container.ok("python", "-m", "pip", "install", "-q", "--no-index", "--find-links", "/wheels", "uv") container.ok("uv", "tool", "install", "agentenv-framework") rows = container.ok("agent-env", "plugin", "list").stdout.split("STATUS\n", 1)[1] - assert re.fullmatch(r"agentenv-framework +\S+ +bundle agents, bundle echo-mcp, bundle hello +ok\n", rows), rows + assert re.fullmatch(r"agentenv-framework +\S+ +4 bundles +ok\n", rows), rows added = container.ok("agent-env", "plugin", "add", browser, grader, "--yes") assert "toy_navigate active" in added.stdout and "toy_grade active" in added.stdout diff --git a/tst/integration/task_step/test_next_env_event_hook_local.py b/tst/integration/task_step/test_next_env_event_hook_local.py new file mode 100644 index 000000000..de63dc0d7 --- /dev/null +++ b/tst/integration/task_step/test_next_env_event_hook_local.py @@ -0,0 +1,126 @@ +"""The next-env-event example hook on a real gateway: one conversation, event to event over simulated days.""" + +import json +import time +import uuid +from datetime import datetime +from pathlib import Path + +import httpx +import pytest + +import agent_env +from agent_env.a2a_agent import conversation_store +from agent_env.artifact import FileArtifact +from agent_env.env.gateway import AGENT_ENV_ROLE_HEADER +from agent_env.task_step.context import TaskStepContext +from agent_env.task_step.task_steps.prompt_agent import PromptAgentTaskStep +from agent_env.task_step.task_steps.register_env_triggers import RegisterEnvTriggersStep +from agent_env.task_step.task_steps.sync_env_clock import SyncEnvClockTaskStep +from tst.integration.store.local_parallel_deploy_test import _seed_bootstrap_envs +from tst.integration.task_step.test_prompt_agent_live_trajectory_local import _deploy, local_registry as local_registry +from tst.integration.task_step.test_register_env_triggers_local import _deploy_items +from tst.util.a2a_test_agent import put_test_agent + +pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] + +HOOK = Path(agent_env.__file__).parent / "examples" / "next-env-event" / "artifacts" / "next-env-event" / "hook.py" +MONDAY = "2026-03-02T13:00:00Z" +TUESDAY = "2026-03-03T16:40:00Z" +TUESDAY_LATER = "2026-03-03T18:40:00Z" +THURSDAY = "2026-03-05T03:15:00Z" +FRIDAY = "2026-03-06T12:00:00Z" +NUDGE = "[{}] Time has passed. Check for anything new and handle it." + + +def _vt(text: str) -> datetime: + return datetime.fromisoformat(text.replace("Z", "+00:00")) + + +def _add(item: str, **action) -> list[dict]: + return [{"type": "tool", "tool": "items_add_item", "args": {"item": item}, **action}] + + +async def _run(label: str, triggers: list[dict], params: dict) -> tuple: + """Run the echo agent over the items env with these triggers and the hook; its context, env, state and wall time.""" + _seed_bootstrap_envs() + env, deployed, _ = await _deploy_items(label) + agent = put_test_agent(f"{label}-agent-{uuid.uuid4().hex[:8]}") + hook = FileArtifact.put(f"{label}-hook-{uuid.uuid4().hex[:8]}", description="next-env-event", file_path=str(HOOK)) + context = TaskStepContext(deployed_envs=[deployed], deployed_agents=[], metadata={}) + try: + await SyncEnvClockTaskStep(id="clock", version=None, env_id=env.id, virtual_time=MONDAY, + virtual_seconds_per_real_second=0).execute(context) + await RegisterEnvTriggersStep(id="events", version=None, env_id=env.id, triggers=triggers, + watch_roles=["default"]).execute(context) + await _deploy(agent, "solver").execute(context) + started = time.monotonic() + await PromptAgentTaskStep( + id="watch", version=None, prompt="Watch the inbox.", agent_name="solver", max_conversation_turns=8, + poll_interval_seconds=1, + post_turn_hooks=[{"file_artifact_id": hook.id, "file_artifact_version": hook.version, "params": params}], + ).execute(context) + wall = time.monotonic() - started + async with httpx.AsyncClient(base_url=deployed.gateway_url, timeout=60) as client: + state = (await client.get("/triggers/state")).raise_for_status().json() + clock = (await client.get("/clock/state")).raise_for_status().json() + stored = await client.post("/step", json={"action": "call_tool", "tool_name": "list_items", "arguments": {}}, + headers={AGENT_ENV_ROLE_HEADER: "harness"}) + finally: + await env.close() + items = json.loads(stored.json()["content"][0]["text"])["items"] + return context, env, state, clock, items, wall + + +def _due(record: dict) -> list | None: + return [e["due"] for e in record["envs"]] if "envs" in record else None + + +def _conversation(context: TaskStepContext) -> list[dict]: + return conversation_store.get_conversation(context.metadata["a2a_conversations"]["watch"])["messages"] + + +@pytest.mark.asyncio +async def test_each_event_days_apart_wakes_the_agent_once_in_one_run(local_registry): + triggers = [{"id": "school-email", "when": {"type": "time", "at": TUESDAY}, "actions": _add("school email")}, + {"id": "calendar-clash", "when": {"type": "time", "at": "P2DT14H15M"}, + "actions": _add("calendar clash")}] + + context, env, state, clock, items, wall = await _run("multiday", triggers, {"end_time": FRIDAY}) + + messages = _conversation(context) + assert [m["parts"][0]["text"] for m in messages if m["role"] == "user"] == [ + "Watch the inbox.", NUDGE.format(TUESDAY), NUDGE.format(THURSDAY)] + assert [(c["env_id"], c["env_step_id"], c["virtual_time"]) for m in messages for c in m["env_clock"]] == [ + (env.id, None, at) for at in (MONDAY, MONDAY, TUESDAY, TUESDAY, THURSDAY, THURSDAY)] + assert {t["id"]: (t["status"], t["fire_count"]) for t in state["triggers"]} == { + "school-email": ("fired", 1), "calendar-clash": ("fired", 1)} + fired = {e["trigger_id"]: _vt(e["virtual_time"]) for e in state["events"] if e["kind"] == "fired"} + assert fired == {"school-email": _vt(TUESDAY), "calendar-clash": _vt(THURSDAY)} + assert "reanchored" not in {e["kind"] for e in state["events"]} + assert items[-2:] == ["school email", "calendar clash"] + assert _vt(clock["virtual_time"]) == _vt(THURSDAY) and clock["t0"] == MONDAY and state["next_event_at"] is None + records = context.metadata["post_turn_hooks"]["watch"] + assert [(r.get("event_at"), _due(r), r.get("end")) for r in records] == [ + (TUESDAY, [["school-email"]], None), (THURSDAY, [["calendar-clash"]], None), (None, None, "no_pending_events")] + assert all(e["env_id"] == env.id and e["settled"] for r in records[:2] for e in r["envs"]) + assert wall < 90, f"four simulated days took {wall:.0f}s of wall time" + + +@pytest.mark.asyncio +async def test_a_delayed_action_lands_at_its_mark_plus_the_delay_and_wakes_the_agent_then(local_registry): + triggers = [{"id": "school-email", "when": {"type": "time", "at": TUESDAY}, + "actions": _add("school email", delay="PT2H")}, + {"id": "calendar-clash", "when": {"type": "time", "at": THURSDAY}, "actions": _add("calendar clash")}] + + context, env, state, clock, items, wall = await _run("delayed", triggers, {}) + + assert [m["parts"][0]["text"] for m in _conversation(context) if m["role"] == "user"] == [ + "Watch the inbox.", NUDGE.format(TUESDAY), NUDGE.format(TUESDAY_LATER), NUDGE.format(THURSDAY)] + acted = [_vt(e["virtual_time"]) for e in state["events"] if e["kind"] == "action_ok"] + assert acted == [_vt(TUESDAY_LATER), _vt(THURSDAY)] + assert items[-2:] == ["school email", "calendar clash"] + records = context.metadata["post_turn_hooks"]["watch"] + assert [r.get("event_at") for r in records] == [TUESDAY, TUESDAY_LATER, THURSDAY, None] + assert _due(records[1]) == [["school-email"]] and records[-1]["end"] == "no_pending_events" + assert wall < 90, f"three simulated days took {wall:.0f}s of wall time" diff --git a/tst/integration/task_step/test_prompt_agent_between_turns_local.py b/tst/integration/task_step/test_prompt_agent_between_turns_local.py deleted file mode 100644 index f29ce54ce..000000000 --- a/tst/integration/task_step/test_prompt_agent_between_turns_local.py +++ /dev/null @@ -1,84 +0,0 @@ -"""An event-driven multi-day run on the local defaults: a gateway env whose frozen clock starts on a Monday, two time -triggers days apart that write into its items server, and the echo agent (tst/data/a2a_agent) prompted with -between_turns next_env_event. Between turns prompt_agent advances the gateway clock to each mark, the trigger writes -land, the agent is woken once per event with no user side, and every message carries the env clock, all in seconds of -wall time. Needs Docker and a throwaway local registry.""" - -import json -import time -import uuid -from datetime import datetime - -import httpx -import pytest - -from agent_env.a2a_agent import conversation_store -from agent_env.env.gateway import AGENT_ENV_ROLE_HEADER -from agent_env.task_step.context import TaskStepContext -from agent_env.task_step.task_steps.prompt_agent import DEFAULT_WAKE_MESSAGE, PromptAgentTaskStep -from agent_env.task_step.task_steps.register_env_triggers import RegisterEnvTriggersStep -from agent_env.task_step.task_steps.sync_env_clock import SyncEnvClockTaskStep -from tst.integration.store.local_parallel_deploy_test import _seed_bootstrap_envs -from tst.integration.task_step.test_prompt_agent_live_trajectory_local import _deploy, local_registry as local_registry -from tst.integration.task_step.test_register_env_triggers_local import _deploy_items -from tst.util.a2a_test_agent import put_test_agent - -pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] - -MONDAY = "2026-03-02T13:00:00Z" -TUESDAY = "2026-03-03T16:40:00Z" -THURSDAY = "2026-03-05T03:15:00Z" -FRIDAY = "2026-03-06T12:00:00Z" - - -def _vt(text: str) -> datetime: - return datetime.fromisoformat(text.replace("Z", "+00:00")) - - -@pytest.mark.asyncio -async def test_each_event_days_apart_wakes_the_agent_once_in_one_run(local_registry): - _seed_bootstrap_envs() - env, deployed, _ = await _deploy_items("multiday") - agent = put_test_agent(f"multiday-agent-{uuid.uuid4().hex[:8]}") - triggers = [{"id": "school-email", "when": {"type": "time", "at": TUESDAY}, - "actions": [{"type": "tool", "tool": "items_add_item", "args": {"item": "school email"}}]}, - {"id": "calendar-clash", "when": {"type": "time", "at": "P2DT14H15M"}, - "actions": [{"type": "tool", "tool": "items_add_item", "args": {"item": "calendar clash"}}]}] - context = TaskStepContext(deployed_envs=[deployed], deployed_agents=[], metadata={}) - try: - await SyncEnvClockTaskStep(id="clock", version=None, env_id=env.id, virtual_time=MONDAY, - virtual_seconds_per_real_second=0).execute(context) - await RegisterEnvTriggersStep(id="events", version=None, env_id=env.id, triggers=triggers, - watch_roles=["default"]).execute(context) - await _deploy(agent, "solver").execute(context) - started = time.monotonic() - await PromptAgentTaskStep(id="watch", version=None, prompt="Watch the inbox.", agent_name="solver", - max_conversation_turns=6, poll_interval_seconds=1, - between_turns={"mode": "next_env_event", "end_time": FRIDAY}).execute(context) - wall = time.monotonic() - started - async with httpx.AsyncClient(base_url=deployed.gateway_url, timeout=60) as client: - state = (await client.get("/triggers/state")).raise_for_status().json() - clock = (await client.get("/clock/state")).raise_for_status().json() - stored = await client.post("/step", json={"action": "call_tool", "tool_name": "list_items", "arguments": {}}, - headers={AGENT_ENV_ROLE_HEADER: "harness"}) - finally: - await env.close() - - conversation = conversation_store.get_conversation(context.metadata["a2a_conversations"]["watch"]) - sent = [m["parts"][0]["text"] for m in conversation["messages"] if m["role"] == "user"] - assert sent == ["Watch the inbox.", DEFAULT_WAKE_MESSAGE.format(virtual_time=TUESDAY), - DEFAULT_WAKE_MESSAGE.format(virtual_time=THURSDAY)] - assert [m["env_clock"][env.id] for m in conversation["messages"]] == [MONDAY, MONDAY, TUESDAY, TUESDAY, - THURSDAY, THURSDAY] - assert {t["id"]: (t["status"], t["fire_count"]) for t in state["triggers"]} == { - "school-email": ("fired", 1), "calendar-clash": ("fired", 1)} - fired = {e["trigger_id"]: e["virtual_time"] for e in state["events"] if e["kind"] == "fired"} - assert {k: _vt(v) for k, v in fired.items()} == {"school-email": _vt(TUESDAY), "calendar-clash": _vt(THURSDAY)} - assert "reanchored" not in {e["kind"] for e in state["events"]} - assert json.loads(stored.json()["content"][0]["text"])["items"][-2:] == ["school email", "calendar clash"] - assert _vt(clock["virtual_time"]) == _vt(FRIDAY) and clock["t0"] == MONDAY - advances = context.metadata["env_event_advances"]["watch"] - assert [a.get("trigger_ids") for a in advances[:2]] == [["school-email"], ["calendar-clash"]] - assert all(a["moved"] == {env.id: "advance"} for a in advances[:2]) - assert advances[2] == {"turn": 3, "stopped": "no_pending_time_triggers", "virtual_time": FRIDAY} - assert wall < 90, f"four simulated days took {wall:.0f}s of wall time" diff --git a/tst/unit/bundle/installed_test.py b/tst/unit/bundle/installed_test.py index cc2ceca00..31d167c46 100644 --- a/tst/unit/bundle/installed_test.py +++ b/tst/unit/bundle/installed_test.py @@ -127,7 +127,8 @@ def test_the_plugin_report_counts_the_bundles_agent_env_ships(monkeypatch, quiet assert result.exit_code == 0, result.output (core,) = json.loads(result.stdout)["plugins"] assert [(c["group"], c["name"], c["status"]) for c in core["contributions"]] == [ - (BUNDLES, "agents", "active"), (BUNDLES, "echo-mcp", "active"), (BUNDLES, "hello", "active")] + (BUNDLES, "agents", "active"), (BUNDLES, "echo-mcp", "active"), (BUNDLES, "hello", "active"), + (BUNDLES, "next-env-event", "active")] assert "\n agent-env itself\n" in shown and "requires:" not in shown diff --git a/tst/unit/bundle/shipped_bundles_test.py b/tst/unit/bundle/shipped_bundles_test.py index d30514664..8d9ba4d7b 100644 --- a/tst/unit/bundle/shipped_bundles_test.py +++ b/tst/unit/bundle/shipped_bundles_test.py @@ -25,7 +25,7 @@ def _shipped() -> dict: def test_agentenv_framework_ships_its_bundles(): - assert SHIPPED == ["agents", "echo-mcp", "hello"] + assert SHIPPED == ["agents", "echo-mcp", "hello", "next-env-event"] @pytest.mark.parametrize("name", SHIPPED) diff --git a/tst/unit/task_step/test_next_env_event_hook.py b/tst/unit/task_step/test_next_env_event_hook.py new file mode 100644 index 000000000..a42d1ae90 --- /dev/null +++ b/tst/unit/task_step/test_next_env_event_hook.py @@ -0,0 +1,248 @@ +"""The next-env-event example hook, turn by turn, against an in-process gateway's real clock and trigger engine.""" + +from __future__ import annotations + +import asyncio +import importlib.util +from pathlib import Path + +import httpx +import pytest +from mcp.types import CallToolResult, TextContent + +import agent_env +from agent_env.env.env import DeployedGatewayEnv +from agent_env.env.gateway import gateway as gw_mod +from agent_env.env.gateway.clock import Clock +from agent_env.env.gateway.constants import EXT_CLOCK_URI, GATEWAY_EXTENSIONS, WELL_KNOWN_PATH +from agent_env.env.gateway.gateway import Gateway +from agent_env.task_step.context import TaskStepContext +from agent_env.task_step.task_steps.post_turn_hooks import PostTurnHook, Turn, _Answer + +HOOK = Path(agent_env.__file__).parent / "examples" / "next-env-event" / "artifacts" / "next-env-event" / "hook.py" +GATEWAY = "http://gateway.test" +MONDAY = "2026-03-02T08:00:00Z" + + +def _load_hook(): + spec = importlib.util.spec_from_file_location("next_env_event_hook", HOOK) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def hook(monkeypatch): + module = _load_hook() + monkeypatch.setattr(module, "POLL_SECONDS", 0.01) + return module + + +@pytest.fixture +def gateway(tmp_path, monkeypatch): + """A gateway on a frozen clock armed Monday 08:00 whose tool calls record the virtual time they ran at.""" + monkeypatch.setattr(gw_mod, "GATEWAY_TRAJECTORY_FILE", str(tmp_path / "trajectory.jsonl")) + gw = Gateway(host="127.0.0.1", port=0, server_name="AgentEnvGateway", internal_mcp_servers=[]) + gw._clock = Clock() + gw._clock.set_time(MONDAY, rate=0) + gw._tool_server_urls = {"items_add_item": "http://items/mcp"} + gw.calls = [] + + async def call_child_tool(server_url, tool_name, arguments, *, caller=None): + gw.calls.append((arguments["item"], gw._clock.read()["virtual_time"])) + return CallToolResult(content=[TextContent(type="text", text="ok")], isError=False) + + gw._call_child_tool = call_child_tool + real_client = httpx.AsyncClient + transport = httpx.ASGITransport(app=gw._mcp.streamable_http_app()) + monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: real_client(*a, **{**kw, "transport": transport})) + yield gw + gw._close_trajectory_file() + + +def _context(*extensions: dict) -> TaskStepContext: + card = {"capabilities": {"extensions": list(extensions or GATEWAY_EXTENSIONS)}} + env = DeployedGatewayEnv(env_id="items", env_version=1, gateway_url=GATEWAY, mcp_url="", sandbox_id="sb", + environment_card_url=f"{GATEWAY}{WELL_KNOWN_PATH}", environment_card=card, + metadata={"deploy_step_id": "deploy_items"}) + return TaskStepContext(deployed_envs=[env]) + + +async def _turns(hook, context: TaskStepContext, params: dict | None = None, triggers: list | None = None) -> list: + """Each turn's decision until the hook ends the conversation, with agent triggers answering from ``triggers``.""" + answers = list(triggers or []) + + async def user_triggers(): + return answers.pop(0) if answers else None + + async def ask_user(): + raise AssertionError("the hook never asks the user side") + + decisions = [] + while not decisions or "end" not in decisions[-1]: + assert len(decisions) < 10, decisions + number = len(decisions) + 1 + turn = Turn(context=context, step_id="week", hook=PostTurnHook("next-env-event", None, {}, hook.post_turn), + answer=_Answer(ask_user, user_triggers), number=number, last=False, agent_reply="ok", + agent_parts=[{"kind": "text", "text": "ok"}]) + decisions.append(await hook.post_turn(turn, dict(params or {}))) + return decisions + + +def _nudge(at: str) -> dict: + return {"message": f"[{at}] Time has passed. Check for anything new and handle it."} + + +def _register(gw: Gateway, *triggers: dict) -> None: + gw._trigger_engine.register({"watch_roles": ["default"], "triggers": list(triggers)}) + + +def _add(item: str, **action) -> list[dict]: + return [{"type": "tool", "tool": "items_add_item", "args": {"item": item}, **action}] + + +@pytest.mark.asyncio +async def test_a_delayed_action_gets_a_wake_of_its_own_at_its_mark_plus_the_delay(hook, gateway): + _register(gateway, + {"id": "school", "when": {"type": "time", "at": "2026-03-03T09:00:00Z"}, + "actions": _add("school email", delay="PT2H")}, + {"id": "calendar", "when": {"type": "time", "at": "2026-03-05T09:00:00Z"}, "actions": _add("clash")}) + context = _context() + + decisions = await _turns(hook, context) + + assert decisions == [_nudge("2026-03-03T09:00:00Z"), _nudge("2026-03-03T11:00:00Z"), + _nudge("2026-03-05T09:00:00Z"), {"end": "no_pending_events"}] + assert gateway.calls == [("school email", "2026-03-03T11:00:00Z"), ("clash", "2026-03-05T09:00:00Z")] + records = context.metadata["post_turn_hooks"]["week"] + assert [(r["event_at"], r["envs"]) for r in records[:3]] == [ + (at, [{"env_id": "items", "env_step_id": "deploy_items", "due": [due], "settled": True}]) + for at, due in (("2026-03-03T09:00:00Z", "school"), ("2026-03-03T11:00:00Z", "school"), + ("2026-03-05T09:00:00Z", "calendar"))] + assert records[-1] == {"hook": "next-env-event", "turn": 4, "end": "no_pending_events"} + + +@pytest.mark.asyncio +async def test_a_delayed_action_with_no_later_event_and_no_end_time_still_runs(hook, gateway): + _register(gateway, {"id": "school", "when": {"type": "time", "at": "2026-03-03T09:00:00Z"}, + "actions": _add("school email", delay="PT2H")}) + + decisions = await _turns(hook, _context()) + + assert decisions[-1] == {"end": "no_pending_events"} and len(decisions) == 3 + assert gateway.calls == [("school email", "2026-03-03T11:00:00Z")] + + +@pytest.mark.asyncio +async def test_a_recurring_trigger_whose_action_outlasts_its_interval_still_moves_on(hook, gateway): + _register(gateway, {"id": "beat", "when": {"type": "time", "every": "PT1H", "count": 2}, + "actions": _add("beat", delay="PT2H")}) + + decisions = await _turns(hook, _context()) + + assert decisions[-1] == {"end": "no_pending_events"} + assert gateway.calls == [("beat", "2026-03-02T11:00:00Z"), ("beat", "2026-03-02T12:00:00Z")] + + +@pytest.mark.asyncio +async def test_a_fire_the_agents_turn_set_off_lands_before_the_clock_jumps(hook, gateway, monkeypatch): + real = gateway._call_child_tool + + async def slow(server_url, tool_name, arguments, *, caller=None): + await asyncio.sleep(0.3) + return await real(server_url, tool_name, arguments, caller=caller) + + gateway._call_child_tool = slow + _register(gateway, {"id": "ack", "when": {"type": "action", "tool": "send_email"}, "actions": _add("ack")}, + {"id": "tue", "when": {"type": "time", "at": "2026-03-03T09:00:00Z"}, "actions": _add("tue")}) + gateway._trigger_engine.on_tool_call("default", "send_email", {}, CallToolResult(content=[], isError=False)) + + decisions = await _turns(hook, _context()) + + assert decisions[0] == _nudge("2026-03-03T09:00:00Z") + assert gateway.calls == [("ack", MONDAY), ("tue", "2026-03-03T09:00:00Z")] + + +@pytest.mark.asyncio +async def test_an_agent_trigger_reply_is_sent_first_at_the_current_time_with_no_jump(hook, gateway): + _register(gateway, {"id": "school", "when": {"type": "time", "at": "2026-03-03T09:00:00Z"}, + "actions": _add("school email")}) + reply = {"message": [{"kind": "text", "text": "[Julia]: thanks, that works"}]} + + decisions = await _turns(hook, _context(), triggers=[reply]) + + assert decisions[0] == reply + assert decisions[1] == _nudge("2026-03-03T09:00:00Z") + assert gateway.calls == [("school email", "2026-03-03T09:00:00Z")] + + +@pytest.mark.asyncio +async def test_an_agent_trigger_that_answers_the_event_speaks_instead_of_the_nudge(hook, gateway): + _register(gateway, {"id": "school", "when": {"type": "time", "at": "2026-03-03T09:00:00Z"}, + "actions": _add("school email")}) + reply = {"message": [{"kind": "text", "text": "[school]: did you see our email?"}]} + + decisions = await _turns(hook, _context(), triggers=[None, reply]) + + assert decisions[0] == reply and gateway._clock.read()["virtual_time"] == "2026-03-03T09:00:00Z" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("end_time", ["2026-03-04T00:00:00Z", "P1DT16H"]) +async def test_an_event_past_end_time_ends_the_conversation_without_jumping_to_it(hook, gateway, end_time): + _register(gateway, {"id": "tue", "when": {"type": "time", "at": "2026-03-03T09:00:00Z"}, "actions": _add("a")}, + {"id": "thu", "when": {"type": "time", "at": "2026-03-05T09:00:00Z"}, "actions": _add("b")}) + context = _context() + + decisions = await _turns(hook, context, {"end_time": end_time}) + + assert decisions == [_nudge("2026-03-03T09:00:00Z"), {"end": "end_time"}] + assert gateway._clock.read()["virtual_time"] == "2026-03-03T09:00:00Z" + assert context.metadata["post_turn_hooks"]["week"][-1]["next_event_at"] == "2026-03-05T09:00:00Z" + + +@pytest.mark.asyncio +async def test_a_custom_nudge_names_the_time(hook, gateway): + _register(gateway, {"id": "tue", "when": {"type": "time", "at": "PT1H"}, "actions": _add("a")}) + + decisions = await _turns(hook, _context(), {"nudge": "It is now {virtual_time}."}) + + assert decisions[0] == {"message": "It is now 2026-03-02T09:00:00Z."} + + +@pytest.mark.asyncio +async def test_with_no_armed_clock_the_conversation_ends(hook, gateway): + gateway._clock.clear() + + assert await _turns(hook, _context()) == [{"end": "no_armed_env_clock"}] + + +@pytest.mark.asyncio +async def test_a_gateway_without_advance_ends_the_conversation_naming_the_env_and_never_re_arms(hook, gateway, caplog): + _register(gateway, {"id": "tue", "when": {"type": "time", "at": "PT1H"}, "actions": _add("a")}) + old_clock = next(e for e in GATEWAY_EXTENSIONS if e["uri"] == EXT_CLOCK_URI) + methods = {k: v for k, v in old_clock["params"]["methods"].items() if k != "advance"} + extensions = [e for e in GATEWAY_EXTENSIONS if e["uri"] != EXT_CLOCK_URI] + context = _context(*extensions, {**old_clock, "params": {**old_clock["params"], "methods": methods}}) + + decisions = await _turns(hook, context) + + assert decisions == [{"end": "gateway_without_advance"}] + assert context.metadata["post_turn_hooks"]["week"][-1]["envs"] == ["items"] + assert "rebuild their gateway image" in caplog.text + assert gateway._clock.read()["virtual_time"] == MONDAY + + +@pytest.mark.asyncio +@pytest.mark.parametrize("params, fragment", [ + ({"wake": "decide"}, "not ['wake']"), + ({"end_time": "2026-03-04T00:00:00"}, "RFC 3339 instant with an offset"), + ({"end_time": 4}, "RFC 3339 instant with an offset"), + ({"end_time": "P"}, "a duration like 'P4D'"), + ({"nudge": "{trigger_ids}"}, "one placeholder is {virtual_time}"), + ({"settle_timeout_seconds": 0}, "positive number"), +]) +async def test_params_the_hook_cannot_use_are_refused(hook, gateway, params, fragment): + with pytest.raises(ValueError) as e: + await _turns(hook, _context(), params) + assert fragment in str(e.value) diff --git a/tst/unit/test_clean_install_wheel.py b/tst/unit/test_clean_install_wheel.py index 41313f38e..42f67d0e6 100644 --- a/tst/unit/test_clean_install_wheel.py +++ b/tst/unit/test_clean_install_wheel.py @@ -63,4 +63,4 @@ def test_the_gate_checks_the_folder_of_every_shipped_bundle(): roots = {name: root.relative_to(SCRIPT.parents[2]).as_posix() for name, root in clean_install.bundle_roots().items()} assert roots == {"agents": "src/agent_env/agents", "echo-mcp": "src/agent_env/examples/echo-mcp", - "hello": "src/agent_env/examples/hello"} + "hello": "src/agent_env/examples/hello", "next-env-event": "src/agent_env/examples/next-env-event"} From fd2dbf99b798aca74675720cc05ac4d6e8912fe6 Mon Sep 17 00:00:00 2001 From: Sarah Chitty Date: Mon, 26 Oct 2026 09:22:44 -0700 Subject: [PATCH 12/15] review: the user-side test pins the user sim's output schema itself, so a change to it fails the test Co-Authored-By: Claude Opus 5.5 --- tst/unit/task_step/test_prompt_agent_user_side.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tst/unit/task_step/test_prompt_agent_user_side.py b/tst/unit/task_step/test_prompt_agent_user_side.py index fa2a14a0d..55b759b60 100644 --- a/tst/unit/task_step/test_prompt_agent_user_side.py +++ b/tst/unit/task_step/test_prompt_agent_user_side.py @@ -12,7 +12,7 @@ from agent_env.env.env import DeployedGatewayEnv from agent_env.env.gateway.constants import EXT_TRIGGERS_URI, GATEWAY_EXTENSIONS, WELL_KNOWN_PATH from agent_env.task_step.context import DeployedAgent, TaskStepContext -from agent_env.task_step.task_steps.prompt_agent import _DEFAULT_USER_SIM_OUTPUT_FORMAT, PromptAgentTaskStep +from agent_env.task_step.task_steps.prompt_agent import PromptAgentTaskStep AGENT = "http://agent.test" USER = "http://user.test" @@ -122,7 +122,11 @@ def exchange(origin: str, parts: list[dict], message_id: str, context_id: str, t return [send(origin, parts, message_id, context_id, timeout), poll(origin, task_id)] -USER_SIM_CONFIG = ("POST", f"{USER}/ext/agent-config", {"output_format": _DEFAULT_USER_SIM_OUTPUT_FORMAT}, 60) +USER_SIM_OUTPUT_FORMAT = {"type": "json_schema", "schema": {"type": "object", "properties": { + "message": {"type": "string", "description": "Your next reply"}, + "done": {"type": "boolean", "description": "Set true when you're satisfied and the conversation should end."}, +}, "required": ["message", "done"]}} +USER_SIM_CONFIG = ("POST", f"{USER}/ext/agent-config", {"output_format": USER_SIM_OUTPUT_FORMAT}, 60) ENV_STATE = ("GET", f"{ENV}/triggers/state", None, 15) FIRING_LOG = ("GET", f"{USER}/ext/triggers", None, 15) From 59b6f25d4960c13c2a525fc300046c9b93e9ed2a Mon Sep 17 00:00:00 2001 From: Sarah Chitty Date: Mon, 26 Oct 2026 15:30:22 -0700 Subject: [PATCH 13/15] review: an advance past the close of an executor's hours waits for the 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 --- src/agent_env/env/gateway/triggers.py | 21 +++++++-------- tst/unit/env/gateway/next_event_test.py | 34 ++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/src/agent_env/env/gateway/triggers.py b/src/agent_env/env/gateway/triggers.py index 38d35b915..63d5f0de0 100644 --- a/src/agent_env/env/gateway/triggers.py +++ b/src/agent_env/env/gateway/triggers.py @@ -462,7 +462,7 @@ def register(self, body: Any) -> dict: "failure_count": 0, "last_failure_at": None, "rng": _make_rng(spec), "mark_gen": None, "pending": deque(), "task": None, "delay_rngs": _delay_rngs(spec), - "attempts": 0, "waiting": False, "resumes": None} + "attempts": 0, "waiting": False, "resumes": None, "hours": None} self._emit("added", tid) self._recompute_time_flag() return {"ok": True, "added": batch_order, "all": list(self._triggers)} @@ -1034,7 +1034,8 @@ def clock_advanced(self) -> list[str]: due = self._eval_time_triggers() now = self._gw._clock.now() ended = [tid for tid, t in self._triggers.items() - if now is not None and (at := self._resumes_at(t)) is not None and at <= now] + if now is not None and (at := self._resumes_at(t)) is not None and at <= now + and (t["hours"] is None or _next_opening(now, t["hours"]) is None)] self._clock_moved.set() self._clock_moved = asyncio.Event() return list(dict.fromkeys(due + ended)) @@ -1437,27 +1438,27 @@ async def _delay(self, tid: str, trig: dict, index: int, action: dict, since: fl trig["resumes"] = None async def _await_hours(self, tid: str, trig: dict, index: int, hours: dict, named: dict) -> None: - """Wait until the executor's working hours are open, reckoned again after a re-arm; they don't apply while the - clock is unarmed.""" + """Wait until the executor's working hours are open now, reckoned each pass; not while the clock is unarmed.""" clock = self._gw._clock gen = opens = None trig["waiting"] = True + trig["hours"] = hours try: while True: current = clock.generation # read before now(), so a re-arm in between is caught on the next pass if (now := clock.now()) is None: return - if current != gen: - gen, opens = current, _next_opening(now, hours) - trig["resumes"] = opens - if opens is not None: - self._emit("deferred", tid, action_index=index, until=_iso(opens), **named) - if opens is None or now >= opens: + if (opening := _next_opening(now, hours)) is None: return + if current != gen or opening != opens: + gen, opens = current, opening + trig["resumes"] = opens + self._emit("deferred", tid, action_index=index, until=_iso(opens), **named) await self._sleep_virtual((opens - now).total_seconds(), generation=gen) finally: trig["waiting"] = False trig["resumes"] = None + trig["hours"] = None async def _sleep_virtual(self, seconds: float, generation: int | None = None) -> None: """Wait out ``seconds`` of virtual time as the clock runs (real time at its rate, 1 while unarmed, none while diff --git a/tst/unit/env/gateway/next_event_test.py b/tst/unit/env/gateway/next_event_test.py index 8edcc819e..7391daae0 100644 --- a/tst/unit/env/gateway/next_event_test.py +++ b/tst/unit/env/gateway/next_event_test.py @@ -9,7 +9,7 @@ import pytest from agent_env.env.gateway import gateway as gw_mod -from agent_env.env.gateway.clock import Clock +from agent_env.env.gateway.clock import Clock, _iso from agent_env.env.gateway.constants import ( EXT_STEP_URI, EXT_TRIGGERS_URI, GATEWAY_EXTENSIONS, STEP_SOURCE, TRIGGER_NEXT_EVENT_AT, ) @@ -196,6 +196,38 @@ async def reply(self, executor, instruction, context_id): await engine.cancel_scheduled() +@pytest.mark.asyncio +async def test_an_advance_past_the_close_of_an_executors_hours_waits_for_the_next_opening(monkeypatch): + calls = [] + + async def reply(self, executor, instruction, context_id): + calls.append(_iso(self._gw._clock.now())) + return "done" + + monkeypatch.setattr(TriggerEngine, "_call_executor", reply) + engine, clock, _ = _engine() + clock.advance("2026-03-02T08:00:00Z") + engine.register({"watch_roles": ["default"], + "executors": {"bob": {"a2a_url": "http://bob", "role": "bob", + "hours": {"start": "09:00", "end": "17:00"}}}, + "triggers": [{"id": "ask-bob", "when": {"type": "action", "tool": "send_email"}, + "actions": [{"type": "nl", "executor": "bob", "instruction": "reply"}]}]}) + engine.on_tool_call("default", "send_email", {}, _result()) + await _until(lambda: _row(engine, "ask-bob")["waiting"]) + + clock.advance("2026-03-02T18:00:00Z") + assert engine.clock_advanced() == [] + await _until(lambda: _row(engine, "ask-bob")["resumes_at"] == "2026-03-03T09:00:00Z") + assert calls == [] and _next(engine) == "2026-03-03T09:00:00Z" + + clock.advance("2026-03-03T09:00:00Z") + assert engine.clock_advanced() == ["ask-bob"] + await _until(lambda: _row(engine, "ask-bob")["status"] == "fired" and calls) + assert calls == ["2026-03-03T09:00:00Z"] + assert [e["until"] for e in engine.state()["events"] if e["kind"] == "deferred"] == [ + "2026-03-02T09:00:00Z", "2026-03-03T09:00:00Z"] + + # ---- what an advance makes due -------------------------------------------------------------------------------------- @pytest.mark.asyncio From d4337c4c6b17feafbeac8dd9848035498c0fab9a Mon Sep 17 00:00:00 2001 From: Sarah Chitty Date: Tue, 27 Oct 2026 10:13:35 -0700 Subject: [PATCH 14/15] fix(triggers): a state check still running counts as busy; the installer 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 --- src/agent_env/env/gateway/triggers.py | 2 +- .../artifacts/next-env-event/hook.py | 4 +++- tst/installer/test_container_journey.py | 2 +- tst/unit/task_step/test_next_env_event_hook.py | 18 ++++++++++++++++++ 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/agent_env/env/gateway/triggers.py b/src/agent_env/env/gateway/triggers.py index 63d5f0de0..3b6da14c5 100644 --- a/src/agent_env/env/gateway/triggers.py +++ b/src/agent_env/env/gateway/triggers.py @@ -855,7 +855,7 @@ def state(self) -> dict: "fired_at": t["fired_at"], "fire_count": t["fire_count"], "failure_count": t["failure_count"], "last_failure_at": t["last_failure_at"], "barrier": t["spec"].get("barrier"), - "pending": len(t["pending"]), "waiting": t["waiting"], + "pending": len(t["pending"]), "waiting": t["waiting"], "evaluating": t["evaluating"], "resumes_at": _iso(resumes[tid]) if resumes[tid] else None, "next_mark": _iso(t["next_mark"]) if t["next_mark"] else None} for tid, t in self._triggers.items() diff --git a/src/agent_env/examples/next-env-event/artifacts/next-env-event/hook.py b/src/agent_env/examples/next-env-event/artifacts/next-env-event/hook.py index 023589415..e581e343b 100644 --- a/src/agent_env/examples/next-env-event/artifacts/next-env-event/hook.py +++ b/src/agent_env/examples/next-env-event/artifacts/next-env-event/hook.py @@ -116,7 +116,9 @@ async def _settle(turn, envs, timeout): def _busy(row, now): - """A trigger is still firing, and not just waiting for a later time.""" + """A trigger is still firing or checking its state, and not just waiting for a later time.""" + if row.get("evaluating"): + return True if row["status"] not in IN_FLIGHT and not row.get("pending"): return False return not (row.get("waiting") and row.get("resumes_at") and _utc(row["resumes_at"]) > now) diff --git a/tst/installer/test_container_journey.py b/tst/installer/test_container_journey.py index 579721710..0c475c11d 100644 --- a/tst/installer/test_container_journey.py +++ b/tst/installer/test_container_journey.py @@ -133,7 +133,7 @@ def test_a_new_user_installs_agent_env_adds_plugins_runs_a_task_and_removes_them container.ok("python", "-m", "pip", "install", "-q", "--no-index", "--find-links", "/wheels", "uv") container.ok("uv", "tool", "install", "agentenv-framework") rows = container.ok("agent-env", "plugin", "list").stdout.split("STATUS\n", 1)[1] - assert re.fullmatch(r"agentenv-framework +\S+ +5 bundles +ok\n", rows), rows + assert re.fullmatch(r"agentenv-framework +\S+ +6 bundles +ok\n", rows), rows added = container.ok("agent-env", "plugin", "add", browser, grader, "--yes") assert "toy_navigate active" in added.stdout and "toy_grade active" in added.stdout diff --git a/tst/unit/task_step/test_next_env_event_hook.py b/tst/unit/task_step/test_next_env_event_hook.py index a42d1ae90..fee8fa400 100644 --- a/tst/unit/task_step/test_next_env_event_hook.py +++ b/tst/unit/task_step/test_next_env_event_hook.py @@ -163,6 +163,24 @@ async def slow(server_url, tool_name, arguments, *, caller=None): assert gateway.calls == [("ack", MONDAY), ("tue", "2026-03-03T09:00:00Z")] +@pytest.mark.asyncio +async def test_a_state_check_the_agents_turn_set_off_finishes_before_the_clock_jumps(hook, gateway, monkeypatch): + async def slow_check(check, ctx=None, role=None): + await asyncio.sleep(0.3) + return True + + monkeypatch.setattr(gateway._trigger_engine, "_run_check", slow_check) + _register(gateway, {"id": "seen", "when": {"type": "state", "check": {"tool": "items_list", "predicate": {"exists": True}}}, + "actions": _add("seen")}, + {"id": "tue", "when": {"type": "time", "at": "2026-03-03T09:00:00Z"}, "actions": _add("tue")}) + gateway._trigger_engine.on_tool_call("default", "send_email", {}, CallToolResult(content=[], isError=False)) + assert gateway._trigger_engine.state()["triggers"][0]["evaluating"] is True + + await _turns(hook, _context()) + + assert gateway.calls[0] == ("seen", MONDAY) + + @pytest.mark.asyncio async def test_an_agent_trigger_reply_is_sent_first_at_the_current_time_with_no_jump(hook, gateway): _register(gateway, {"id": "school", "when": {"type": "time", "at": "2026-03-03T09:00:00Z"}, From 5832188bb5e74dee8c31be687ae3159bf25b90e7 Mon Sep 17 00:00:00 2001 From: Sarah Chitty Date: Tue, 27 Oct 2026 14:37:00 -0700 Subject: [PATCH 15/15] fix(post-turn-hooks): register a hook's module while it loads, so dataclasses 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 --- .../task_step/task_steps/post_turn_hooks.py | 10 +++++++++ .../test_prompt_agent_post_turn_hooks.py | 22 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/agent_env/task_step/task_steps/post_turn_hooks.py b/src/agent_env/task_step/task_steps/post_turn_hooks.py index 691063466..6d1d69da5 100644 --- a/src/agent_env/task_step/task_steps/post_turn_hooks.py +++ b/src/agent_env/task_step/task_steps/post_turn_hooks.py @@ -7,7 +7,9 @@ import importlib.util import inspect import os +import sys import tempfile +import uuid from collections.abc import Awaitable, Callable, Mapping from dataclasses import dataclass from types import MappingProxyType @@ -71,6 +73,7 @@ class PostTurnHooks: def __init__(self) -> None: self.hooks: list[PostTurnHook] = [] self._paths: list[str] = [] + self._modules: list[str] = [] @classmethod async def load(cls, specs: list[dict], step_id: str) -> PostTurnHooks: @@ -90,8 +93,12 @@ async def _load(self, spec: dict, module_name: str) -> PostTurnHook: self._paths.append(path) os.write(fd, source) os.close(fd) + module_name = f"{module_name}_{uuid.uuid4().hex[:8]}" # concurrent runs may share a step id module_spec = importlib.util.spec_from_file_location(module_name, path) module = importlib.util.module_from_spec(module_spec) + # registered while it runs, as an import would be: dataclasses and typing look a module up by name + sys.modules[module_name] = module + self._modules.append(module_name) module_spec.loader.exec_module(module) post_turn = getattr(module, "post_turn", None) if not inspect.iscoroutinefunction(post_turn): @@ -105,6 +112,9 @@ def close(self) -> None: if os.path.exists(path): os.unlink(path) self._paths.clear() + for name in self._modules: + sys.modules.pop(name, None) + self._modules.clear() class HookEnv: diff --git a/tst/unit/task_step/test_prompt_agent_post_turn_hooks.py b/tst/unit/task_step/test_prompt_agent_post_turn_hooks.py index 68a775303..af66395a4 100644 --- a/tst/unit/task_step/test_prompt_agent_post_turn_hooks.py +++ b/tst/unit/task_step/test_prompt_agent_post_turn_hooks.py @@ -4,6 +4,7 @@ import copy import json +import sys import textwrap import httpx @@ -21,6 +22,7 @@ EXT_CLOCK_URI, EXT_STEP_URI, EXT_TRIGGERS_URI, GATEWAY_EXTENSIONS, WELL_KNOWN_PATH, ) from agent_env.task_step.context import HEARTBEAT_KEY +from agent_env.task_step.task_steps.post_turn_hooks import PostTurnHooks from agent_env.task_step.task_steps.prompt_agent import PromptAgentTaskStep from tst.unit.task_step.test_prompt_agent_user_side import ( AGENT, ENV, USER, USER_SIM_CONFIG, _TRIGGERS_CARD, _USER_SIM_CARD, Peers, _context, _stored, decide, exchange, @@ -146,6 +148,26 @@ async def test_a_hook_that_is_not_an_async_post_turn_fails_the_step_before_the_a assert peers.raw == [] +@pytest.mark.asyncio +async def test_a_hook_with_a_dataclass_and_postponed_annotations_loads_and_close_unregisters_it(): + source = """ + from __future__ import annotations + from dataclasses import dataclass + + @dataclass + class Plan: + at: str + + async def post_turn(turn, params): + return {"message": Plan("tue").at} + """ + loaded = await PostTurnHooks.load([{"file_artifact_id": _hook("dataclass", source)}], "week") + [name] = loaded._modules + assert name in sys.modules and await loaded.hooks[0].post_turn(None, {}) == {"message": "tue"} + loaded.close() + assert name not in sys.modules + + # ---- what a hook decides -------------------------------------------------------------------------------------------- @pytest.mark.asyncio