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 5a044b655..0ddb47183 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 PROXY_TOKEN_MIN_LENGTH = 22 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/pyproject.toml b/pyproject.toml index 48010176e..bd2424f40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -122,6 +122,7 @@ drivers = "agent_env" hello = "agent_env.examples" echo-mcp = "agent_env.examples" mail-mcp = "agent_env.examples" +next-env-event = "agent_env.examples" [project.urls] Homepage = "https://www.agentenvframework.com" diff --git a/src/agent_env/a2a_agent/conversation_store.py b/src/agent_env/a2a_agent/conversation_store.py index d9ad7afb2..8721e4bf4 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[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 + return message + + def add_a2a_task( conversation_id: str, parts: list[dict], a2a_task_id: str, role: str, + env_clock: Optional[list[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[list[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 e4392dfab..cceb14364 100644 --- a/src/agent_env/env/gateway/clock.py +++ b/src/agent_env/env/gateway/clock.py @@ -134,6 +134,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: @@ -164,10 +165,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, 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: + 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: @@ -175,12 +193,12 @@ 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.""" + """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 @@ -189,7 +207,14 @@ 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 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.""" @@ -204,7 +229,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.""" @@ -212,5 +237,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 4ab0bf77a..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"]}, @@ -197,10 +205,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, @@ -214,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 a0d7af367..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): @@ -278,6 +280,20 @@ 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) + 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: + return self._json_response({"ok": False, "error": str(e)}, 400) + 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: self._unregister_get_time_tool() @@ -975,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) @@ -996,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) @@ -1012,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), }) @@ -1025,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, @@ -1049,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), }) @@ -1072,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, @@ -1319,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..3b6da14c5 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, "hours": 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, @@ -851,10 +855,12 @@ 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() ], + "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,66 @@ 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 + 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)) 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 +1109,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,31 +1430,35 @@ 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 - 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) - 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 @@ -1418,7 +1467,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 +1518,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/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..e581e343b --- /dev/null +++ b/src/agent_env/examples/next-env-event/artifacts/next-env-event/hook.py @@ -0,0 +1,162 @@ +"""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 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) + + +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/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..6d1d69da5 --- /dev/null +++ b/src/agent_env/task_step/task_steps/post_turn_hooks.py @@ -0,0 +1,269 @@ +"""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 sys +import tempfile +import uuid +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] = [] + self._modules: 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_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): + 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() + for name in self._modules: + sys.modules.pop(name, None) + self._modules.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 069a06d56..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,6 +3,7 @@ import asyncio import copy +import functools import json import logging import time @@ -12,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 @@ -27,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, @@ -102,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": { @@ -118,7 +124,11 @@ def _file_uris(parts: list[dict]) -> list[str]: 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"), @@ -159,6 +169,7 @@ def __init__( *, snapshot_config: Optional[SnapshotConfig] = None, live_trajectory: 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: @@ -172,6 +183,8 @@ def __init__( self._validate_parts(parts) if max_conversation_turns < 1: raise ValueError("max_conversation_turns must be >= 1") + 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: @@ -202,6 +215,7 @@ def __init__( self.user_model = user_model self.snapshot_config = snapshot_config self.live_trajectory = live_trajectory + self.post_turn_hooks = post_turn_hooks @staticmethod def _validate_parts(parts: list[dict]) -> None: @@ -277,6 +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.post_turn_hooks is not None: + base["post_turn_hooks"] = self.post_turn_hooks return base @classmethod @@ -313,6 +329,7 @@ def from_dict(cls, data: dict) -> PromptAgentTaskStep: else None ), live_trajectory=data.get("live_trajectory", False), + post_turn_hooks=data.get("post_turn_hooks"), ) def _trajectory_prefix(self) -> str: @@ -410,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: @@ -419,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: + # 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() @@ -474,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") @@ -506,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. @@ -523,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): @@ -530,6 +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(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. @@ -540,6 +581,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 @@ -572,6 +614,7 @@ def record_turn() -> None: conversation_id=conversation_id, parts=agent_response_parts, role="agent", + **(await self._env_clock(clock_envs)), ) if traj_ext_cached: @@ -592,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) @@ -602,76 +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 trig_ext and triggers_registered: - 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") - 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) - - 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") + 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_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") - conversation_store.mark_closed(conversation_id) + if last: 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) - 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: @@ -816,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 = {} @@ -837,9 +845,11 @@ 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: @@ -995,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/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/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/unit/agents/claude_code/test_card_contract.py b/tst/unit/agents/claude_code/test_card_contract.py index 1b48c9104..510c8965b 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 0f3432057..f5904d1bd 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 6d28f35aa..eea71acca 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/bundle/installed_test.py b/tst/unit/bundle/installed_test.py index 151c08b1d..0bf4e629d 100644 --- a/tst/unit/bundle/installed_test.py +++ b/tst/unit/bundle/installed_test.py @@ -127,8 +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, "drivers", "active"), (BUNDLES, "echo-mcp", "active"), (BUNDLES, "hello", "active"), - (BUNDLES, "mail-mcp", "active")] + (BUNDLES, "agents", "active"), (BUNDLES, "drivers", "active"), (BUNDLES, "echo-mcp", "active"), + (BUNDLES, "hello", "active"), (BUNDLES, "mail-mcp", "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 44495505c..058861fce 100644 --- a/tst/unit/bundle/shipped_bundles_test.py +++ b/tst/unit/bundle/shipped_bundles_test.py @@ -26,7 +26,7 @@ def _shipped() -> dict: def test_agentenv_framework_ships_its_bundles(): - assert SHIPPED == ["agents", "drivers", "echo-mcp", "hello", "mail-mcp"] + assert SHIPPED == ["agents", "drivers", "echo-mcp", "hello", "mail-mcp", "next-env-event"] @pytest.mark.parametrize("name", SHIPPED) 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..0a76031f9 --- /dev/null +++ b/tst/unit/env/gateway/clock_advance_test.py @@ -0,0 +1,167 @@ +"""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"), + ("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) + 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/env/gateway/next_event_test.py b/tst/unit/env/gateway/next_event_test.py new file mode 100644 index 000000000..7391daae0 --- /dev/null +++ b/tst/unit/env/gateway/next_event_test.py @@ -0,0 +1,337 @@ +"""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, _iso +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() + + +@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 +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] 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..fee8fa400 --- /dev/null +++ b/tst/unit/task_step/test_next_env_event_hook.py @@ -0,0 +1,266 @@ +"""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_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"}, + "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/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..af66395a4 --- /dev/null +++ b/tst/unit/task_step/test_prompt_agent_post_turn_hooks.py @@ -0,0 +1,564 @@ +"""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 sys +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.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, + 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 == [] + + +@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 +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/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 diff --git a/tst/unit/test_clean_install_wheel.py b/tst/unit/test_clean_install_wheel.py index 26177b536..45afc9cc1 100644 --- a/tst/unit/test_clean_install_wheel.py +++ b/tst/unit/test_clean_install_wheel.py @@ -62,5 +62,7 @@ def test_bundle_entry_points_that_differ_from_pyproject_are_named(tmp_path, entr 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", "drivers": "src/agent_env/drivers", "echo-mcp": "src/agent_env/examples/echo-mcp", - "hello": "src/agent_env/examples/hello", "mail-mcp": "src/agent_env/examples/mail-mcp"} + assert roots == {"agents": "src/agent_env/agents", "drivers": "src/agent_env/drivers", + "echo-mcp": "src/agent_env/examples/echo-mcp", "hello": "src/agent_env/examples/hello", + "mail-mcp": "src/agent_env/examples/mail-mcp", + "next-env-event": "src/agent_env/examples/next-env-event"}