diff --git a/src/agent_env/explorer/app.py b/src/agent_env/explorer/app.py index d23c801c..658c897a 100644 --- a/src/agent_env/explorer/app.py +++ b/src/agent_env/explorer/app.py @@ -24,6 +24,7 @@ from agent_env.explorer.routers import conversations as conversations_router from agent_env.explorer.routers import objects as objects_router from agent_env.explorer.routers import runs as runs_router +from agent_env.explorer.routers import triggers as triggers_router from agent_env.explorer.routers.common import versioned_router from agent_env.store import NotFoundError from agent_env.store.routing import configured_store @@ -207,6 +208,7 @@ async def _not_found(request, exc: NotFoundError): app.include_router(versioned_router(prefix=f"{API}/agents", tag="agents", collection="a2a_agents", noun="agent")) app.include_router(versioned_router(prefix=f"{API}/evals", tag="evals", collection="evals", noun="eval")) app.include_router(runs_router.router) + app.include_router(triggers_router.router) app.include_router(objects_router.router) app.include_router(conversations_router.router) diff --git a/src/agent_env/explorer/routers/triggers.py b/src/agent_env/explorer/routers/triggers.py new file mode 100644 index 00000000..a1c44148 --- /dev/null +++ b/src/agent_env/explorer/routers/triggers.py @@ -0,0 +1,168 @@ +"""A run's trigger evidence, the feed the run viewer's Triggers tab polls.""" + +from __future__ import annotations + +import asyncio +import json +import logging +from datetime import datetime, timezone + +import httpx +from fastapi import APIRouter, HTTPException + +from agent_env.config import get_config +from agent_env.env.store import ENV_INSTANCES_COLLECTION +from agent_env.explorer.entity_ids import EntityId +from agent_env.explorer.routers.common import docs +from agent_env.store import Filter, In +from agent_env.task.store import TASK_INSTANCES_COLLECTION + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/tasks", tags=["runs"]) + +TRIGGER_STATE_TIMEOUT_SECONDS = 2.0 +# Both fan-outs are driven by the run context, which a run request can supply. +MAX_TRIGGER_ENVS = 20 +TRIGGER_METADATA_KEYS = ( + "env_trigger_registrations", + "agent_trigger_registrations", + "agent_trigger_firings", + "agent_trigger_state", + "env_trigger_snapshots", + "usersim_turn_outputs", + "env_trigger_state", + "env_trajectory", +) +# Envelope keys the metadata ledger has no copy of. +_ENVELOPE_META_KEYS = ("clock", "clock_read_at_utc") + + +@router.get("/{task_id}/instances/{instance_id}/triggers") +async def instance_triggers(task_id: EntityId, instance_id: EntityId) -> dict: + """The run's trigger metadata, plus each env's verbatim ``/triggers/state`` under ``state``: + from the live gateway while the run is running, else from the envelope ``prompt_agent`` saved. + An unreachable source degrades to the metadata; ``state_meta`` carries the gateway clock the + events' ``virtual_time`` is measured on.""" + doc = await asyncio.to_thread( + docs().find_one, TASK_INSTANCES_COLLECTION, Filter.of(task_id=task_id, instance_id=instance_id) + ) + if doc is None: + raise HTTPException(status_code=404, detail=f"instance {instance_id} not found for task {task_id}") + + context = doc.get("context") or {} + metadata = context.get("metadata") or {} + ledger = {key: metadata.get(key) for key in TRIGGER_METADATA_KEYS} + captures = ledger["env_trigger_state"] if isinstance(ledger["env_trigger_state"], dict) else {} + ledger["env_trigger_state"] = captures + payload = { + "status": doc.get("status"), + "source": "metadata", + **ledger, + "state": {}, + "state_sources": {}, + "state_meta": {}, + } + + # Per env: one env's live gateway must not suppress another's envelope. + if payload["status"] == "running": + live, live_meta = await _live_trigger_state(context.get("deployed_envs") or []) + for env_id, state in live.items(): + payload["state_sources"][env_id] = "live" if state is not None else "unavailable" + if state is not None: + payload["state"][env_id] = state + payload["state_meta"].update(live_meta) + + pending = { + env_id: entry + for env_id, entry in list(captures.items())[:MAX_TRIGGER_ENVS] + if isinstance(entry, dict) and env_id not in payload["state"] + } + if pending: + saved, saved_meta = await asyncio.to_thread(_saved_trigger_state, pending, instance_id) + payload["state"].update(saved) + payload["state_meta"].update(saved_meta) + payload["state_sources"].update({env_id: "artifact" for env_id in saved}) + + sources = set(payload["state_sources"].values()) + if sources & {"live", "artifact"}: + payload["source"] = "live" if "live" in sources else "artifact" + return payload + + +def _live_gateways(deployed_envs: list) -> list[tuple[str, str | None]]: + """``(env_id, gateway_url or None)`` per deployed env, from the env-instance records the deploy + writes rather than the run context's own copy, which a run request can supply.""" + pairs = [ + (env["env_id"], env["instance_id"]) + for env in deployed_envs + if isinstance(env, dict) and isinstance(env.get("env_id"), str) and isinstance(env.get("instance_id"), str) + ][:MAX_TRIGGER_ENVS] + if not pairs: + return [] + records = docs().query(ENV_INSTANCES_COLLECTION, Filter().where("instance_id", In([iid for _, iid in pairs]))) + urls = {record["instance_id"]: (record.get("gateway_url") or "").rstrip("/") or None for record in records} + return [(env_id, urls.get(instance_id)) for env_id, instance_id in pairs] + + +async def _live_trigger_state(deployed_envs: list) -> tuple[dict, dict]: + """Each env's ``/triggers/state`` and gateway clock, read concurrently. An env with no reachable + gateway maps to None, so "unreachable" never reads as "no triggers".""" + targets = await asyncio.to_thread(_live_gateways, deployed_envs) + state: dict = {env_id: None for env_id, _ in targets} + meta: dict = {} + reachable = [(env_id, url) for env_id, url in targets if url] + if not reachable: + return state, meta + + async with httpx.AsyncClient(timeout=TRIGGER_STATE_TIMEOUT_SECONDS) as client: + + async def _get(url: str, env_id: str, what: str): + try: + response = await client.get(url) + response.raise_for_status() + return response.json() + except Exception: + logger.warning("live %s unavailable for %s", what, env_id, exc_info=True) + return None + + async def _one(env_id: str, gateway_url: str): + triggers, clock = await asyncio.gather( + _get(f"{gateway_url}/triggers/state", env_id, "trigger state"), + _get(f"{gateway_url}/clock/state", env_id, "clock state"), + ) + # Stamped at the read: at a high clock rate a second of drift is a virtual day. + return env_id, triggers, {"clock": clock, "clock_read_at_utc": datetime.now(timezone.utc).isoformat()} + + for env_id, triggers, env_meta in await asyncio.gather(*(_one(e, u) for e, u in reachable)): + state[env_id] = triggers + if triggers is not None: + meta[env_id] = env_meta + return state, meta + + +def _saved_trigger_state(entries: dict, instance_id: str) -> tuple[dict, dict]: + """Each env's ``/triggers/state`` from the envelope ``prompt_agent`` saved, with its capture-time + clock. Skips a url outside the configured object store, an unreadable envelope, and one another + instance captured (a resumed run's context carries the earlier attempt's pointers).""" + state: dict = {} + meta: dict = {} + for env_id, entry in entries.items(): + object_url = entry.get("object_url") + if not isinstance(object_url, str): + continue + store = get_config().get_object_store_at(object_url) + if not store.owns(object_url): + logger.warning("refusing a trigger-state url outside the object store for %s", env_id) + continue + try: + envelope = json.loads(store.get(object_url)) + captured_by = envelope.get("instance_id") + if captured_by and captured_by != instance_id: + logger.warning("skipping the trigger state for %s captured by %s", env_id, captured_by) + continue + state[env_id] = envelope.get("state") or {} + meta[env_id] = {key: envelope.get(key) for key in _ENVELOPE_META_KEYS} + except Exception: + logger.warning("trigger-state envelope unreadable for %s", env_id, exc_info=True) + return state, meta diff --git a/src/agent_env/explorer/ui/src/components/task-instance-viewer.tsx b/src/agent_env/explorer/ui/src/components/task-instance-viewer.tsx index 5b3aa993..19dc2624 100644 --- a/src/agent_env/explorer/ui/src/components/task-instance-viewer.tsx +++ b/src/agent_env/explorer/ui/src/components/task-instance-viewer.tsx @@ -31,7 +31,7 @@ import { type ServerConfigChange, type ServerConfigSkip, } from './server-config-panel'; -import { TriggerTurnStrip } from './triggers-panel'; +import { TriggersPanel, TriggerTurnStrip } from './triggers-panel'; import { parseTriggerRuntime } from '../lib/parse-trigger-runtime'; import { indexAuthoredTriggers } from '../lib/parse-triggers'; import { @@ -1042,6 +1042,11 @@ export function TaskInstanceViewer({ Server Config )} + {triggerRuntime && ( + + Triggers + + )} {hasPeerAgents && ( Peer Q&A @@ -1806,6 +1811,17 @@ export function TaskInstanceViewer({ )} + {triggerRuntime && ( + + + + )} + {hasPeerAgents && (