diff --git a/.agents/skills/agent-release-gate/SKILL.md b/.agents/skills/agent-release-gate/SKILL.md index aa01fe2a51c..95619a3286a 100644 --- a/.agents/skills/agent-release-gate/SKILL.md +++ b/.agents/skills/agent-release-gate/SKILL.md @@ -106,6 +106,45 @@ name the k. A commit-lock race test skipping for want of a reachable Postgres is one-line syntax error (`SET LOCAL lock_timeout` with a bind parameter, which Postgres rejects outright) survived 1911 green tests before a human hit it as his first live action. +**The two journeys that run many things at once: `burst` and `crosstalk`.** Every other journey +drives one run at a time, so the gate only ever saw faults that reproduce on a quiet deployment. +The credential-delivery fault of AGE-4249 does not: about one production first message in five +failed because some fresh Daytona sandboxes start without their Secret substitution wiring, and +per cold sandbox that is roughly an 8 percent fault. `burst` sends 16 first messages at the same +time on 16 brand new sessions, so the run buys 16 cold starts instead of one. `crosstalk` runs 3 +two-turn conversations with long output beside 2 approval flows, and checks that no stream carries +another session's nonce, except on the codex harness, where the gate rides a platform tool with +empty arguments, so the approval command carries no nonce and isolation is not checked there +(`nonce_checked=false`). Both are Daytona-only by default and skip elsewhere; both report the +runner's stable error code per run, so a `credential_delivery_failed` names itself. + +```bash +uv run resources/qa_product.py --cell C4 --only burst --only crosstalk +uv run resources/qa_product.py --cell C4 --only burst --burst-size 24 # more cold starts +uv run resources/qa_product.py --cell C3 --only crosstalk --concurrency-everywhere # local too +uv run resources/test_qa_product_concurrency.py # offline tests +``` + +**Read a green burst honestly.** At an 8 percent per-cold-start fault rate, 8 runs miss the fault +51 percent of the time, 16 miss it 26 percent, and two Daytona cells at 16 miss it about 7 percent. +A PASS is a sample, not an all-clear, and the result says so in its own reason line. A FAIL is +proof. + +Each concurrent run holds its own Daytona sandbox, about 5 GiB of the organization's disk, and a +parked sandbox keeps counting until its auto-delete window closes, so a burst of 16 is about 80 +GiB in flight. The counts are `--burst-size` (default 16), `--crosstalk-conversations` (default 3) +and `--crosstalk-approvals` (default 2). The cap is 32 concurrent runs: 32 for the burst size, and +32 for the two crosstalk counts TOGETHER, because what costs disk is what runs at once. When the +provider refuses on capacity the journey reports SKIP with a loud reason, never a PASS or a FAIL, +because nothing about the product was measured. `--concurrency-timeout` (default 300s) bounds one +TURN and rides into the stream as an absolute deadline, so a two-turn crosstalk run gets twice +that and a stream that never ends is abandoned rather than followed. + +A release that changes `services/runner/src/engines/sandbox_agent/**` or +`services/runner/src/providers/daytona*` makes the Daytona cells C2, C4 and X2 mandatory through +`path_triggers.py`, and forces `burst` and `crosstalk` into the run even when `--only` named +something else. That is how these journeys reach a release that needs them. + **Before a human gets a deployment URL, run `resources/qa_commit_approval.py` too.** It is not part of `qa_product.py`'s cell × journey matrix — none of that matrix's journeys drive a live turn against a REAL, saved workflow revision (the `commit` journey only exercises the REST API; `chat`, @@ -142,6 +181,62 @@ cell, promoted after the platform-guidance fix closed that exact gap; it reuses the same way the separate one-shot benchmark (Tier B) does — check there before writing a new mechanism-blind cell from scratch, to avoid duplicating scaffolding. +## Session control cells + +`resources/session_control.py` is a second, standalone driver: sixteen cells that cover Stop, +durable commands, and the runner's recovery paths (owner release, park/resume, watchdog +quarantine). It drives the same product endpoint and asserts on the same wire, but it needs its +own account bootstrap, so it runs as a separate process rather than as `qa_product.py` cells. See +`resources/path_triggers.py` for the exact mandatory-cell mechanism. + +**These cells are MANDATORY** — run them, not just the standing gate — whenever the release diff +touches any of: + +- `services/runner/src/sessions/**` +- `services/runner/src/engines/sandbox_agent/**` +- `api/oss/src/core/sessions/**` +- `api/oss/src/tasks/asyncio/sessions/**` +- `api/oss/src/apis/fastapi/sessions/**` + +Run every cell with one line: + +```bash +uv run resources/session_control.py --cells all --harness pi_core --sandbox local +``` + +Add `--project ` to run the eight cells that need direct Docker and +Postgres access (`sandbox-gone`, `records-outage`, `restart-after-stop`, `runner-gone`, +`runner-gone-late`, `post-stop-row`, `codex-child`, `stale-tail`) and the abort-log subcheck inside +`stop-after-finish`. Without `--project`, those eight cells SKIP with a named reason. The +`stop-after-finish` HTTP check still runs, but only its abort-log subcheck is unavailable. The +other eight cells +(`stop-warm`, `double-send`, `stale-stop`, `stop-approval`, `stop-after-finish`, +`repeat-stop`, `concurrent-stops`, `stop-during-completion`) run over HTTP alone against any +deployment. Add +`--resume ` to pick a lost run back up: any cell already +recorded there is loaded instead of re-run. + +Results land in a timestamped folder under `~/agenta-qa-evidence/` (override with +`AGENTA_QA_RUNS_DIR`), as `results.json` and `summary.md` — the same PASS/FAIL/SKIP shape as the +rest of the gate. When a release path makes session control mandatory, pass that artifact to the +standing gate with `--session-control-results `: a missing or incomplete artifact stops the +gate before the matrix runs, and a recorded FAIL makes the final gate exit nonzero. + +**Environment, by name.** Same three-variable discipline as the rest of the gate, no env-file +fallback: + +- `AGENTA_BASE` — the deployment origin. +- `AGENTA_ADMIN_KEY` — mints the ephemeral account this driver runs under. Lives in + `~/.agenta-qa-secrets.env`. +- `QA_OPENAI_API_KEY` — stocked into that account's vault so the `pi_core` and `codex` harnesses + have a provider key. Lives in `~/.agenta-qa-openai.env`. +- `ANTHROPIC_API_KEY` — only required for `--harness claude`, stocked into the same vault the + same way. Lives in `~/.agenta-qa-secrets.env`. A pi_core- or codex-only run does not need it. + +A Daytona run additionally needs a Secrets-capable Daytona key on the runner; the key in most +session env files returns 403 on the Secrets endpoint, so check that before trusting a Daytona +result. + ## When results lie The runtime **fails open**: a component can break, get logged, and the turn still succeeds with a diff --git a/.agents/skills/agent-release-gate/resources/coverage.md b/.agents/skills/agent-release-gate/resources/coverage.md index 0234e936432..ec61b1b51a0 100644 --- a/.agents/skills/agent-release-gate/resources/coverage.md +++ b/.agents/skills/agent-release-gate/resources/coverage.md @@ -55,8 +55,8 @@ cell — keep them in sync if a cell changes. | `chat` | Create an agent, send one message. | The turn completes with a `finish` frame, not an `error`. | | `mount` | Write a file in turn 1, read it back in turn 2. | The file survives across turns — proof the durable mount is real, not a throwaway `/tmp` cwd. | | `tool` | Call a tool whose return bakes in an unguessable token. | The token appears in the reply, so the tool provably ran (the model cannot guess it). | -| `approve` | Raise an approval, then approve it. | The approved tool call continues via the in-band approval protocol the browser uses. | -| `deny` | Raise an approval, then deny it. | The denied path is handled cleanly (no phantom failure, no re-parking forever). | +| `approve` | Raise an approval, then approve it. | The approved tool call continues via the in-band approval protocol the browser uses. The turn must also pause with `finish=other`, resume with `finish=stop`, carry no coded error on either turn, and neither turn may have been abandoned at a deadline. | +| `deny` | Raise an approval, then deny it. | The denied path is handled cleanly (no phantom failure, no re-parking forever): the wire outcome is exactly `denied`, the resume ends with `finish=stop`, neither turn carries a coded error, and neither was abandoned at a deadline. | | `commit` | Save an agent config as a new workflow revision, then fetch it back. | The changed parameter survives the round trip and the version bumps (v0 seed → v1; see LESSONS #14). Harness-agnostic — it drives the config REST API, not a turn. | | `warm` | Continuity tier 1: three turns on one live daemon, over a store-backed cwd. | The durable token written in turn 1 comes back in the last turn, and the turn ledger shows one harness session and one sandbox (a second id means the turn was not warm). | | `cold1` | Continuity tier 2: the pooled session is **evicted** (the client changes the agent's instructions, which changes the config fingerprint) and the runner rebuilds it — unmounting and remounting the durable cwd. | The token survives the store round trip AND the agent can read a file the client wrote directly into the object store. | @@ -69,6 +69,8 @@ cell — keep them in sync if a cell changes. | `builtin_grep` | Policy `allow_reads`. Write a file with bash, then grep it. | A `grep` call executes with no approval card — grep is one of the three built-ins Pi does not activate on its own, and it is read-only, so it runs unattended. **Pi only.** | | `secret_opaque` | Ask the sandbox to classify its own provider key variable and echo back a verdict word carrying a nonce this run invented. | The verdict says the value begins `dtn_secret_`, so the agent holds a Daytona Secret placeholder and not the real key. **Daytona only** (C2, C4, P3, X2); it `SKIP`s on every local cell, where the harness runs inside the runner container and there is nothing to hide it from. | | `rotate` | Change the provider key in the vault **mid-conversation** to a decoy no provider accepts, send a turn, then put the real key back and keep talking. | The turn under the decoy must FAIL (a success means the runner kept serving the old credential), and the turn after the restore must succeed with the durable working directory intact. Skips on subscription cells, which have no vault key, and custom-provider cells, whose write-only key cannot be safely restored. The vault is restored in a `finally`. | +| `burst` | Send N first messages at the same time, each on a brand new session and therefore a cold sandbox. Default N is 16 (`--burst-size`, capped at 32 concurrent runs). | Every run finishes with a stop reason and no error frame, and every reply carries its OWN nonce and no other run's. **Daytona only** unless `--concurrency-everywhere`. Each run records its session id, phase, start and end offsets, finish reason, runner error code, redacted error text and duration, so a `credential_delivery_failed` names itself instead of hiding in prose. | +| `crosstalk` | Run K two-turn conversations that ask for a long deterministic output and M approval flows, all at the same time. Defaults are 3 and 2 (`--crosstalk-conversations`, `--crosstalk-approvals`), capped at 32 concurrent runs between them. | Every turn arrives as more than one `text-delta` frame AND carries a reply of the size the prompt asked for (100 lines or 600 characters); every turn ends with the nonce that belongs to that turn and with no other nonce in the journey, including the other turn of the same conversation; every approval pauses, resumes, and returns output carrying its own nonce and no other, except on the codex harness, whose gate rides a platform tool with empty arguments and so carries no nonce (`nonce_checked=false`, isolation not claimed there). Warm reuse is RECORDED per conversation, never required: a preflight rebuild legitimately produces two sandbox ids, and the `warm` journey owns that claim. **Daytona only** unless `--concurrency-everywhere`. | The four rule journeys are the only coverage of `harness.permissions`. Built-in tools are always active and are never listed in `tools`, so those three lists are the only lever over them: if they @@ -91,6 +93,57 @@ session config fingerprint and live only in a separate credential epoch, so that only thing standing between a rotated key and a warm sandbox that goes on using the old one. Nothing else in the gate would notice if it stopped working, because a stale key still answers. +`burst` and `crosstalk` are the only journeys that run more than one thing at a time. Every other +journey drives one run, so the gate could only ever see faults that reproduce on a quiet +deployment. The fault that made these journeys necessary does not. In production about one first +message in five failed with "A temporary issue kept this run's credentials from reaching the +model": some fresh Daytona sandboxes start without their Secret substitution wiring, the runner's +one retry is stuck again more often than not, and on a provider that does not echo the key +(OpenRouter, Anthropic) the preflight is blind, so the first model call comes back 401. Per cold +sandbox it is about an 8 percent fault, which is why a sequential matrix stayed green through the +whole incident (AGE-4249 / #6485). + +**A PASS here is probabilistic. A FAIL is proof.** At that 8 percent rate: + +| Cold starts in the run | Chance the run misses the fault | +|---|---| +| 8 | 51 percent | +| 16 | 26 percent | +| 32 (two Daytona cells at 16) | 7 percent | + +The default is 16 for that reason, and a passing result says so in its own `why` line. One green +run is not evidence that the fault is gone. One red run is evidence that it is not. + +Both journeys are **Daytona only** by default, because the fault lives in the remote credential +path and a local sandbox has no Secrets to lose. Pass `--concurrency-everywhere` to run them on +local cells too, which is cheap and exercises the journeys themselves. Both record the runner's +stable error CODE per run, read off the `data-agent-error` frame, so triage starts from +`credential_delivery_failed` or `rate_limited` rather than from a message that changes with the +copy. Error text and driver exceptions are masked before they reach the results file, because a +provider's refusal can quote the credential it refused. + +The frame counts and reply sizes ride in the evidence, but the streaming bar stays at "the reply +arrived in more than one frame", because chunking is a harness property: measured on staging, Pi +sends the same 150-line reply in about 312 frames and Claude sends it in 4 to 7. The SIZE bar is +what holds the long-output claim. + +No run can hang the gate. `--concurrency-timeout` (default 300s) bounds each TURN twice over: the +client passes it to `invoke` as an absolute deadline, so a stream that keeps emitting bytes is +abandoned rather than followed forever, and the journey waits that many turns plus a margin before +recording a straggler as hung. Jobs run on daemon threads, so an abandoned one cannot hold the +process open at exit. + +**Capacity is not a verdict.** Each concurrent run holds its own sandbox, about 5 GiB of the +Daytona organization's disk, and a parked sandbox keeps counting until its auto-delete window +closes. A burst of 16 is therefore about 80 GiB in flight. When the provider refuses on capacity +("Total disk limit exceeded"), the journey reports **SKIP** with a loud reason rather than PASS or +FAIL, because nothing about the product was measured. That match is deliberately narrow and never +covers `rate_limited`: an internal rate limit under a load the product is supposed to support is a +real finding, and hiding it behind a SKIP would delete the only signal the gate has. + +Offline tests for both journeys live in `test_qa_product_concurrency.py` and need no deployment: +`uv run resources/test_qa_product_concurrency.py` from the skill root, or under pytest. + Triggers are deliberately **out of scope** for this gate. ## Continuity: the third dimension (warm / cold 1 / cold 2) @@ -173,6 +226,12 @@ not a harness, sandbox, or provider model axis. | `matrix_i1_settlement.py` | coached, mechanism-level | The 3 card kinds x complete/decline/walk-away table against the live API. Answered form/connect rows must be `responded` with their exact resolution; approvals must be `resolved` with a strict verdict; abandoned rows must be swept from `pending` to `cancelled` without an invented answer; non-approval `resolved` attempts must return 409. The script sends the atomic transition itself because that write belongs to the browser. | none beyond the three gate environment variables | | `matrix_gw1_gateway_tools.py` | coached, one mechanism-blind leg | The gateway tool surface against a real provider, in three legs. **search**: `search_tools` offers the allowed and ask tools and the DENIED key never appears in the payload the model reads. **allow_run**: the allowed tool executes unattended and returns a genuine provider result, asserted on the wire rather than on the model's word for it. **ask_run**: the SAME tool, re-gated to `ask` so policy is the only variable, parks; its stored row names the right integration and tool key; it is answered through the **interactions API**, the durable plane a reloaded browser uses and the one no other cell exercises; the row ends `resolved`/`approved`. Every leg folds `check_no_silent_turn`. | one valid Composio connection (defaults to the no-auth `text_to_pdf`; `--integration` / `--connection` to move it) and a working model provider | | `matrix_i2_card_journeys.py` | coached, mechanism-level | The six scripted journeys from `docs/design/client-tool-interaction-lifecycle/qa.md`: compound form/reload/connect-decline/schedule, form then connect, two connects, close/reopen, real Telegram create/remove/re-create, and decline/retry. Reload and reopen are fresh row/record reads, not browser automation; each journey names its wire-level limit. The Telegram journey validates a real bot against Telegram's own API and drives Agenta's connection lifecycle, but STOPS before entering the credential on the provider's hosted page — that step is browser-only, so the connection never reaches `is_valid` and the journey reports the gap in `not_covered`. Run qa.md journey 5 by hand in exploratory QA. | a funded model connection for the two same-session/record probes; `TELEGRAM_BOT_TOKEN` for the real Telegram journey | +| `matrix_s1_custom_secrets.py` | coached | Creates a disposable write-only text secret, attaches it to a persisted revision, and verifies local and Daytona sandbox delivery through durable SHA-256 side effects. The same session then proves value rotation and binding removal at the next execution boundary. It rejects any plaintext occurrence in SSE frames and deletes only its own secret during cleanup. | Store-backed deployment, funded OpenAI connection, and Daytona configuration for the Daytona leg | + +`matrix_s1_custom_secrets.py` does not claim the browser-owned `request_secret` setup interaction. The wire driver can +observe and settle a client-tool row, but it cannot prove the host created the secret and committed +the binding through the real form. Run that pause, configure, resume, cancel, and retry flow in the +host UI using [the custom-secret browser checklist](../../../../docs/design/agent-custom-secrets/qa-browser-checklist.md) during exploratory QA until a browser automation cell owns it. I2 reports an unset `TELEGRAM_BOT_TOKEN` as a loud journey `SKIP` and makes the aggregate cell `SKIP`. Five passing wire-level journeys must never make the untested real-provider claim look @@ -200,20 +259,28 @@ exclude any turn it deliberately aborted or interrupted, which legitimately ends add `and not silent["violations"]` to its verdict — `resources/test_qa_matrix_lib_silent_turns.py` fails if a wired cell drops it. -## Path-scoped cells: coverage the release's own diff demands +## Path-scoped cells and journeys: coverage the release's own diff demands Everything above is fixed. It runs identically for every release, which means a release that rewrote a subsystem gets the same coverage as one that never touched it — and the cell that would have caught the regression sits unrun, because running it depends on somebody remembering. -`path_triggers.py` removes the remembering. It is one dict of path glob to cells. When the driver -is given the release's diff (`--release-base `, or `--changed-path` for a checkout that is -not the release branch), every rule whose glob matches a changed path contributes its cells, and -those cells are MANDATORY for that release. +`path_triggers.py` removes the remembering. It is two dicts of path glob: one to cells +(`PATH_TRIGGERS`), one to journeys (`PATH_TRIGGER_JOURNEYS`). When the driver is given the +release's diff (`--release-base `, or `--changed-path` for a checkout that is not the release +branch), every rule whose glob matches a changed path contributes what it names, and those cells +and journeys are MANDATORY for that release. + +A rule that names only a cell is not enough on its own. `--release-base … --only chat` would run +`chat` on the mandatory Daytona cells and report a green release while the coverage the rule +exists for never ran. So a journey a rule demands is FORCED into the selection, overriding +`--only`, and the driver prints one line saying which journeys it added and why. -| Rule | Cells it makes mandatory | Why this subsystem needs its own cell | +| Rule | What it makes mandatory | Why this subsystem needs its own coverage | |---|---|---| | `api/oss/src/core/tools/**`, `sdks/python/agenta/sdk/agents/platform/gateway.py`, `sdks/python/agenta/sdk/agents/tools/gateway_policy.py`, `services/runner/src/tools/**`, `services/runner/src/engines/sandbox_agent/gateway-gate.ts` | `matrix_gw1_gateway_tools.py` | The gateway chain — the API's catalog and resolve, the SDK's two model-facing tools and its permission compiler, the runner's policy and semantic gate. `tool`, `approve`, and `deny` prove the approval machinery with a BUILTIN, never with a gateway tool, so nothing in the fixed matrix notices when a compiled policy and an enforced policy drift apart. Proposed in [`docs/design/composio-tools-rework/release-gate-changes.md`](../../../../docs/design/composio-tools-rework/release-gate-changes.md). | +| Custom-secret vault/workflow paths, `sdks/python/agenta/sdk/agents/{sandbox_credentials.py,wire_models.py,utils/wire.py}`, and runner credential validation/composition/identity/redaction paths | `matrix_s1_custom_secrets.py` | A normal model turn can stay green while the credential is missing, stale, leaked, or injected into only one sandbox provider. This cell checks the saved-reference boundary, both providers, secret rotation, removal, and SSE non-disclosure through durable side effects. | +| `services/runner/src/engines/sandbox_agent/**`, `services/runner/src/providers/daytona*` | Cells `C2`, `C4`, `X2`; journeys `burst` and `crosstalk` | The sandbox engine and the Daytona provider: sandbox creation, the secret plan, the credential preflight, and the retry the runner does when a first model call is refused. A fault here appears only when many sandboxes start at once, which no other journey does. Production hit it as one first message in five failing with a credential error (AGE-4249 / #6485) while the sequential gate stayed green. `P3` is a Daytona cell too but is deliberately not named: it needs `--custom-slug` and `--custom-name`, and the driver exits when a selected custom cell has no slug, so the rule would stop every release run that did not pass them. | What the driver does with a mandatory cell depends on which kind it is: @@ -225,6 +292,10 @@ What the driver does with a mandatory cell depends on which kind it is: - **A cell that does not exist** stops the run before a single journey, naming the rule. The release changed code the rule protects and the coverage was never written; a SKIP there would be the exact false green this mechanism exists to prevent. +- **A mandatory journey** is added to the selection even against an explicit `--only`, printed at + the start with the path that demanded it, and recorded in `mandatory-journeys.json` beside the + results. A journey a rule names that does not exist stops the run, for the same reason a + missing cell does. Rules are data and unordered: matches are unioned, so two rules naming the same cell is fine. Matching is `fnmatch` over the whole repo-relative path, which means `*` crosses directory diff --git a/.agents/skills/agent-release-gate/resources/matrix_s1_custom_secrets.py b/.agents/skills/agent-release-gate/resources/matrix_s1_custom_secrets.py new file mode 100644 index 00000000000..1ad6a00f94a --- /dev/null +++ b/.agents/skills/agent-release-gate/resources/matrix_s1_custom_secrets.py @@ -0,0 +1,349 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["httpx>=0.27"] +# /// +"""TIER: coached (the prompt names the environment binding and exact shell side effect). + +S1: persisted custom-secret bindings across local and Daytona sandboxes. The cell creates its own +write-only text secret, attaches its slug to a saved agent revision, and asks the harness to write +only the SHA-256 digest to the durable cwd. It reads that file through the object-store API, then +rotates the vault value and verifies the next turn observes the new digest. Finally it commits a +revision with the binding removed and verifies the next turn observes an absent variable. + +The plaintext value is generated in memory, sent only to the vault create/update endpoints, and +never printed, placed in a prompt, or included in the result. Cleanup archives only the workflow +and deletes only the secret created by this invocation. + +Requires a store-backed deployment and a funded OpenAI connection. Daytona additionally requires +the runner's Daytona provider configuration. + + uv run matrix_s1_custom_secrets.py + uv run matrix_s1_custom_secrets.py --only local + uv run matrix_s1_custom_secrets.py --only daytona +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import pathlib +import sys +import time +import uuid + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from qa_matrix_lib import ( + api_call, + archive, + check_no_silent_turn, + commit_direct, + create_workflow, + refs, + run_until_settled, + user_msg, +) + +ENV_NAME = "AGENTA_QA_CUSTOM_SECRET" +STORE_SETTLE_SECONDS = 20.0 + + +def agent_config(sandbox: str, secret_slug: str | None, harness: str) -> dict: + sandbox_config: dict = {"kind": sandbox} + if secret_slug: + sandbox_config["credentials"] = [ + { + "secret": {"slug": secret_slug}, + "binding": {"type": "env", "name": ENV_NAME}, + } + ] + runtime = ( + { + "model": "gpt-5.6-luna", + "provider": "openai-codex", + "connection": {"mode": "self_managed", "slug": None}, + "kind": "pi_core", + } + if harness == "pi" + else { + "model": "gpt-5.6-luna", + "provider": "openai", + "connection": {"mode": "agenta", "slug": None}, + "kind": "codex", + } + ) + return { + "instructions": { + "agents_md": ( + "Follow the requested shell verification exactly. Never print, inspect, enumerate, " + "or include credential values in messages or tool output." + ) + }, + "llm": { + "model": runtime["model"], + "provider": runtime["provider"], + "connection": runtime["connection"], + "extras": {}, + }, + "tools": [], + "mcps": [], + "skills": [], + "harness": {"kind": runtime["kind"]}, + "sandbox": sandbox_config, + } + + +def create_secret(name: str, slug: str, value: str) -> tuple[str, str]: + response = api_call( + "POST", + "/vault/v1/secrets/", + json={ + "header": { + "name": name, + "description": "Release-gate disposable credential", + }, + "slug": slug, + "write_only": True, + "secret": { + "kind": "custom_secret", + "data": { + "secret": { + "format": "text", + "content": value, + "default_env_var": ENV_NAME, + } + }, + }, + }, + ) + if response.status_code != 200: + raise RuntimeError(f"custom-secret create HTTP {response.status_code}") + body = response.json() + return str(body["id"]), str(body["slug"]) + + +def rotate_secret(secret_id: str, value: str) -> None: + response = api_call( + "PUT", + f"/vault/v1/secrets/{secret_id}", + json={ + "secret": { + "kind": "custom_secret", + "data": {"secret": {"format": "text", "content": value}}, + } + }, + ) + if response.status_code != 200: + raise RuntimeError(f"custom-secret rotation HTTP {response.status_code}") + + +def delete_secret(secret_id: str) -> None: + try: + response = api_call("DELETE", f"/vault/v1/secrets/{secret_id}") + if response.status_code not in (200, 204, 404): + print( + f"custom-secret cleanup HTTP {response.status_code} (non-fatal)", + file=sys.stderr, + ) + except Exception as error: # noqa: BLE001 + print( + f"custom-secret cleanup failed (non-fatal): {type(error).__name__}", + file=sys.stderr, + ) + + +def commit_config(workflow_id: str, variant_id: str, config: dict, label: str) -> dict: + response = commit_direct( + workflow_id, + variant_id, + {"agent": config}, + label, + f"qa-custom-secret-{label}-{uuid.uuid4().hex[:8]}", + ) + if response.status_code != 200: + raise RuntimeError(f"revision commit HTTP {response.status_code}") + return response.json()["workflow_revision"] + + +def cwd_mount_id(session_id: str) -> str: + response = api_call("GET", "/sessions/mounts/", params={"session_id": session_id}) + if response.status_code == 503: + raise RuntimeError("deployment has no object store configured") + if response.status_code != 200: + raise RuntimeError(f"session mounts HTTP {response.status_code}") + mount = next( + ( + item + for item in response.json().get("mounts", []) + if item.get("name") == "cwd" + ), + None, + ) + if not mount: + raise RuntimeError("session has no durable cwd mount") + return str(mount["id"]) + + +def read_store_file(mount_id: str, path: str) -> str: + deadline = time.time() + STORE_SETTLE_SECONDS + while True: + response = api_call("GET", f"/mounts/{mount_id}/files", params={"read": path}) + if response.status_code == 200: + return str(response.json().get("content") or "").strip() + if time.time() >= deadline: + raise RuntimeError( + f"durable result file unavailable: HTTP {response.status_code}" + ) + time.sleep(2) + + +def invoke_and_read( + *, + session_id: str, + messages: list[dict], + config: dict, + references: dict, + result_path: str, +) -> tuple[object, str]: + turns, status = run_until_settled( + session_id, messages, {"agent": config}, references, max_rounds=6 + ) + if not status["settled"]: + raise RuntimeError(f"agent turn did not settle: {status.get('why', status)}") + silent = check_no_silent_turn(turns) + if silent["violations"]: + raise RuntimeError(f"agent turn was silent: {silent['violations']}") + mount_id = cwd_mount_id(session_id) + return turns[-1], read_store_file(mount_id, result_path) + + +def cell(sandbox: str, harness: str) -> dict: + token = uuid.uuid4().hex + workflow_id, variant_id = create_workflow(token[:8], f"qa-custom-secret-{sandbox}") + secret_id: str | None = None + session_id = str(uuid.uuid4()) + value_one = f"qa-secret-{uuid.uuid4().hex}-{uuid.uuid4().hex}" + value_two = f"qa-secret-{uuid.uuid4().hex}-{uuid.uuid4().hex}" + expected_one = hashlib.sha256(value_one.encode()).hexdigest() + expected_two = hashlib.sha256(value_two.encode()).hexdigest() + digest_path = f"qa-custom-secret-{token}.sha256" + # A new path per digest: the store read returns on its first 200, so a reused path + # can hand back the previous content before the overwrite becomes visible. + rotated_path = f"qa-custom-secret-{token}.rotated.sha256" + absent_path = f"qa-custom-secret-{token}.absent" + try: + secret_id, secret_slug = create_secret( + f"QA custom secret {token[:8]}", f"qa-custom-secret-{token}", value_one + ) + attached = agent_config(sandbox, secret_slug, harness) + revision = commit_config(workflow_id, variant_id, attached, "attached") + references = refs(workflow_id, variant_id, revision["id"]) + + messages = [ + user_msg( + f"Use your shell to compute SHA-256 of the configured {ENV_NAME} variable and " + f"write only the 64 lowercase hex characters to {digest_path}. Do not print the " + "variable or its value. Reply only DONE after the file is closed." + ) + ] + turn_one, digest_one = invoke_and_read( + session_id=session_id, + messages=messages, + config=attached, + references=references, + result_path=digest_path, + ) + + rotate_secret(secret_id, value_two) + if value_one in json.dumps(turn_one.raw_frames): + raise RuntimeError("initial credential appeared in the SSE stream") + messages.extend( + [ + turn_one.assistant_message(), + user_msg( + f"The credential was rotated. Recompute SHA-256 of {ENV_NAME} into " + f"{rotated_path} without printing the variable or value. Reply only DONE." + ), + ] + ) + turn_two, digest_two = invoke_and_read( + session_id=session_id, + messages=messages, + config=attached, + references=references, + result_path=rotated_path, + ) + + detached = agent_config(sandbox, None, harness) + detached_revision = commit_config(workflow_id, variant_id, detached, "detached") + detached_references = refs(workflow_id, variant_id, detached_revision["id"]) + if value_two in json.dumps(turn_two.raw_frames): + raise RuntimeError("rotated credential appeared in the SSE stream") + messages.extend( + [ + turn_two.assistant_message(), + user_msg( + f"Use your shell to test whether {ENV_NAME} is defined. Write only ABSENT to " + f"{absent_path} when it is undefined, otherwise write PRESENT. Do not inspect " + "or print any value. Reply only DONE." + ), + ] + ) + turn_removed, absent = invoke_and_read( + session_id=session_id, + messages=messages, + config=detached, + references=detached_references, + result_path=absent_path, + ) + + passed = ( + digest_one == expected_one + and digest_two == expected_two + and digest_one != digest_two + and absent == "ABSENT" + ) + return { + "status": "PASS" if passed else "FAIL", + "sandbox": sandbox, + "harness": harness, + "workflow_id": workflow_id, + "session_id": session_id, + "initial_digest_matches": digest_one == expected_one, + "rotated_digest_matches": digest_two == expected_two, + "rotation_changed_digest": digest_one != digest_two, + "removed_binding_absent": absent == "ABSENT", + "frames": [turn_one.frames, turn_two.frames, turn_removed.frames], + "raw_secret_exposed_in_result": False, + } + except Exception as error: # noqa: BLE001 + return { + "status": "SKIP" if "no object store" in str(error).lower() else "FAIL", + "sandbox": sandbox, + "harness": harness, + "workflow_id": workflow_id, + "session_id": session_id, + "why": f"{type(error).__name__}: {error}", + "raw_secret_exposed_in_result": False, + } + finally: + archive(workflow_id) + if secret_id: + delete_secret(secret_id) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--only", choices=("local", "daytona")) + parser.add_argument("--harness", choices=("codex", "pi"), default="codex") + args = parser.parse_args() + sandboxes = [args.only] if args.only else ["local", "daytona"] + if args.harness == "pi" and any(sandbox == "daytona" for sandbox in sandboxes): + parser.error("the Pi subscription baseline is local-only; pass --only local") + results = [cell(sandbox, args.harness) for sandbox in sandboxes] + print(json.dumps({"cell": "S1-custom-secrets", "results": results}, indent=2)) + return 0 if all(result["status"] == "PASS" for result in results) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/agent-release-gate/resources/path_triggers.py b/.agents/skills/agent-release-gate/resources/path_triggers.py index 848a4f3b361..687b3fe3df2 100644 --- a/.agents/skills/agent-release-gate/resources/path_triggers.py +++ b/.agents/skills/agent-release-gate/resources/path_triggers.py @@ -32,6 +32,30 @@ # matrix cell (`matrix_gw1_gateway_tools.py`). The driver runs the first kind itself and records # the second kind as required, because a standalone cell is a separate process it cannot observe. GATEWAY_TOOLS = ("matrix_gw1_gateway_tools.py",) +CUSTOM_SECRETS = ("matrix_s1_custom_secrets.py",) + +# The standing session-control regression cells: Stop, durable commands, and the runner's +# recovery paths (owner release, park/resume, watchdog quarantine). A separate standalone driver +# because it needs its own account bootstrap and, for most cells, a docker-compose project name — +# see resources/session_control.py and SKILL.md "Session control cells". +SESSION_CONTROL = ("session_control.py",) + +# The cells that run a REMOTE sandbox and need no extra flag. A release that touches the sandbox +# engine or the Daytona provider changes how a cold sandbox gets built and how its credentials are +# delivered, and the `burst` and `crosstalk` journeys are the only ones that see that path under +# load (AGE-4249). Both run in every cell selected here, because a run without `--only` runs every +# journey. +# +# P3 is deliberately NOT in this list even though it is a Daytona cell. It needs --custom-slug and +# --custom-name, and the driver exits when a selected custom cell has no slug, so naming it here +# would stop every release run that did not pass those flags. +DAYTONA_CELLS = ("C2", "C4", "X2") + +# The journeys a rule can demand alongside its cells. A cell without its journey proves nothing: +# `--release-base ... --only chat` would run `chat` on the mandatory Daytona cells and report a +# green release while the coverage the rule exists for never ran. Journeys named here are FORCED +# into the selection, even against an explicit --only. +CONCURRENCY_JOURNEYS = ("burst", "crosstalk") # Glob -> cells. Matching is fnmatch over the whole repo-relative path, so `*` crosses directory # separators: `a/b/*` and `a/b/**` behave the same, and both mean "anything under a/b". Write @@ -49,6 +73,51 @@ "sdks/python/agenta/sdk/agents/tools/gateway_policy.py": GATEWAY_TOOLS, "services/runner/src/tools/**": GATEWAY_TOOLS, "services/runner/src/engines/sandbox_agent/gateway-gate.ts": GATEWAY_TOOLS, + # Custom-secret authoring, resolution, transport, sandbox injection, and lifecycle identity. + "web/packages/agenta-entities/src/secret/**": CUSTOM_SECRETS, + "web/packages/agenta-entities/src/workflow/state/agentCredentials.ts": CUSTOM_SECRETS, + "web/packages/agenta-entity-ui/src/secret/**": CUSTOM_SECRETS, + "web/packages/agenta-entity-ui/src/clientTools/SecretRequest*": CUSTOM_SECRETS, + "web/packages/agenta-chat/src/clientTools/secretInteractions.ts": CUSTOM_SECRETS, + "api/oss/src/core/secrets/**": CUSTOM_SECRETS, + "api/oss/src/apis/fastapi/vault/router.py": CUSTOM_SECRETS, + "api/oss/src/apis/fastapi/workflows/router.py": CUSTOM_SECRETS, + "api/oss/src/core/workflows/static_catalog.py": CUSTOM_SECRETS, + "sdks/python/agenta/sdk/agents/sandbox_credentials.py": CUSTOM_SECRETS, + "sdks/python/agenta/sdk/agents/handler.py": CUSTOM_SECRETS, + "sdks/python/agenta/sdk/agents/wire_models.py": CUSTOM_SECRETS, + "sdks/python/agenta/sdk/agents/utils/wire.py": CUSTOM_SECRETS, + "services/runner/src/engines/sandbox_agent/sandbox-credentials.ts": CUSTOM_SECRETS, + "services/runner/src/engines/sandbox_agent/run-plan.ts": CUSTOM_SECRETS, + "services/runner/src/engines/sandbox_agent/session-identity.ts": CUSTOM_SECRETS, + "services/runner/src/environment/runtime-lifecycle.ts": CUSTOM_SECRETS, + "services/runner/src/lifecycle/desired-state.ts": CUSTOM_SECRETS, + "services/runner/src/redaction.ts": CUSTOM_SECRETS, + # The sandbox engine and the Daytona provider: sandbox creation, the secret plan, the + # credential preflight, and the one retry the runner does when a first model call is refused. + # A fault here shows up only when many sandboxes start at once, which is what `burst` and + # `crosstalk` do on these cells. Production hit it as one first message in five failing with + # a credential error (AGE-4249 / #6485) while the sequential gate stayed green. + # A dict literal keeps only the last value for a repeated key, so a glob that already names + # DAYTONA_CELLS lists SESSION_CONTROL alongside it in the SAME tuple rather than as a second + # entry that would silently drop the Daytona rule. + "services/runner/src/engines/sandbox_agent/**": DAYTONA_CELLS + SESSION_CONTROL, + "services/runner/src/providers/daytona*": DAYTONA_CELLS, + # Session control: Stop, durable commands, park/resume, and the owner-release and watchdog + # sweeps. A change here can silently break a warm resume or leave a command stuck, and + # nothing in the fixed matrix drives Stop at all. See qa-audit-2026-09-03.md section 4. + "services/runner/src/sessions/**": SESSION_CONTROL, + "api/oss/src/core/sessions/**": SESSION_CONTROL, + "api/oss/src/tasks/asyncio/sessions/**": SESSION_CONTROL, + "api/oss/src/apis/fastapi/sessions/**": SESSION_CONTROL, +} + +# Glob -> journeys that MUST run when the rule fires. Same matching as PATH_TRIGGERS, kept as a +# separate table so a rule can demand a cell, a journey, or both, without changing the shape of +# either one. +PATH_TRIGGER_JOURNEYS: dict[str, tuple[str, ...]] = { + "services/runner/src/engines/sandbox_agent/**": CONCURRENCY_JOURNEYS, + "services/runner/src/providers/daytona*": CONCURRENCY_JOURNEYS, } @@ -86,6 +155,22 @@ def mandatory_cells(paths: list[str]) -> dict[str, list[str]]: return {cell: sorted(why) for cell, why in sorted(activated.items())} +def mandatory_journeys(paths: list[str]) -> dict[str, list[str]]: + """Journey -> the changed paths that made it mandatory. + + The driver forces these into the run even when --only named something else. A release that + reworks sandbox credential delivery and then runs `--only chat` is not covered by the fact + that the right CELL was selected. + """ + activated: dict[str, set[str]] = {} + for glob, journeys in PATH_TRIGGER_JOURNEYS.items(): + for path in paths: + if fnmatch.fnmatch(path, glob): + for journey in journeys: + activated.setdefault(journey, set()).add(path) + return {journey: sorted(why) for journey, why in sorted(activated.items())} + + def main() -> int: p = argparse.ArgumentParser(description=__doc__) p.add_argument( @@ -94,13 +179,19 @@ def main() -> int: p.add_argument("--head", default="HEAD", help="git ref under test (default HEAD)") args = p.parse_args() - triggered = mandatory_cells(changed_paths(args.release_base, args.head)) - if not triggered: + paths = changed_paths(args.release_base, args.head) + triggered = mandatory_cells(paths) + journeys = mandatory_journeys(paths) + if not triggered and not journeys: print(f"No path rule matched the diff {args.release_base}...{args.head}.") return 0 print(f"Mandatory for {args.release_base}...{args.head}:") for cell, why in triggered.items(): - print(f" {cell}") + print(f" cell {cell}") + for path in why: + print(f" because this release changed {path}") + for journey, why in journeys.items(): + print(f" journey {journey}") for path in why: print(f" because this release changed {path}") return 0 diff --git a/.agents/skills/agent-release-gate/resources/qa_product.py b/.agents/skills/agent-release-gate/resources/qa_product.py index 8a710af8e07..39916e8c689 100644 --- a/.agents/skills/agent-release-gate/resources/qa_product.py +++ b/.agents/skills/agent-release-gate/resources/qa_product.py @@ -26,12 +26,13 @@ import pathlib import re import subprocess +import threading import time import uuid import httpx -from path_triggers import changed_paths, mandatory_cells +from path_triggers import changed_paths, mandatory_cells, mandatory_journeys HERE = pathlib.Path(__file__).resolve().parent # Results land in the CURRENT working directory, never inside the skill, so repeated runs do not @@ -105,6 +106,59 @@ def resolve_credentials(env_file: str | pathlib.Path | None = None) -> None: MCP_URL = DEFAULT_MCP_URL +# The stable reason a turn carries when `invoke` abandoned its stream at the deadline. One +# spelling, so a result file and an assertion cannot drift apart. +HUNG_AT_DEADLINE = "abandoned by the client at its absolute deadline" + +# HTTPX needs a POSITIVE timeout, so this is the smallest value worth handing it. It is a floor, +# never a grant: an operation with less than this left is not started at all, because starting one +# would hand out time the turn does not have. Measured cost of getting this wrong: a turn with 5ms +# remaining came back 50ms late. +DEADLINE_FLOOR_SECONDS = 0.05 + +# Anything key-shaped, masked before it reaches a result file. The gate writes results to disk and +# commits them as evidence, and an error body from a provider can quote the credential it refused. +# Keep the shape visible (the prefix and the length) and drop the value. +_SECRET_PATTERNS = ( + re.compile(r"\bsk-[A-Za-z0-9_-]{8,}"), + re.compile(r"\bdtn_[A-Za-z0-9_]{8,}"), + re.compile(r"\b(ApiKey|Bearer)\s+[A-Za-z0-9._-]{8,}"), +) + + +def redact(text: object) -> str: + """Mask key-shaped runs. No truncation, so it is safe to apply to anything.""" + out = str(text or "") + out = _SECRET_PATTERNS[0].sub("sk-", out) + out = _SECRET_PATTERNS[1].sub("dtn_", out) + out = _SECRET_PATTERNS[2].sub(lambda m: f"{m.group(1)} ", out) + return out + + +def sanitize(text: object, limit: int = 300) -> str: + """`redact`, then truncate. For a field a journey is about to put in its own evidence.""" + return redact(text)[:limit] + + +def redact_tree(value): + """Every string in a nested result, redacted, right before it is written to disk. + + Journeys redact the fields they build themselves, but they also embed whole `Turn.summary()` + blobs, and a provider's error body can quote the credential it refused ANYWHERE in one. The + results file is committed as release evidence, so the last thing that touches it is a walk + over the entire object. One boundary, one guarantee. + """ + if isinstance(value, str): + return redact(value) + if isinstance(value, dict): + return {k: redact_tree(v) for k, v in value.items()} + if isinstance(value, list): + return [redact_tree(v) for v in value] + if isinstance(value, tuple): + return [redact_tree(v) for v in value] + return value + + def api_call( method: str, path: str, @@ -429,6 +483,19 @@ def __init__(self) -> None: self._segments: list[dict] = [] self.finish_reason: str | None = None self.errors: list[str] = [] + # The CODED failure classes this turn streamed, read off the `data-agent-error` frames. + # The plain `error` frame carries display prose only; the code beside it is the runner's + # stable class (`RunErrorCode` in engines/sandbox_agent/errors.ts — + # `credential_delivery_failed`, `rate_limited`, `runner_error`, ...). A journey that + # records the code can name WHY a run failed instead of matching on a message that + # changes with the copy. Same source as `agent_error_frames` in + # matrix_c5_first_call_race.py. + self.error_codes: list[str] = [] + self.error_texts: list[str] = [] + # Set when invoke() abandoned the stream at its absolute deadline. HTTPX bounds each read, + # never the whole turn, so a stream that keeps emitting bytes would otherwise run forever. + self.hung: bool = False + self.hung_reason: str = "" self.committed_revision: dict | None = None self.http_status: int = 0 self.ms: int = 0 @@ -493,13 +560,82 @@ def summary(self) -> dict: "tools": [t.get("toolName") for t in self.tool_calls], "approval": bool(self.approval), "errors": self.errors, + "error_codes": self.error_codes, + "hung": self.hung, "reply": self.reply[:400], } +def _sse_lines(response, out_of_time): + """Yield SSE lines from the response's byte stream, and `None` the moment time runs out. + + `iter_lines()` cannot be used where a deadline must hold: it only yields on a newline, so a + stream that sends bytes without one (or nothing at all) never gives the caller a chance to + check the clock. Reading chunks moves the check to every chunk boundary. + + `iter_bytes()`, NOT `iter_raw()`. `iter_raw()` hands over the bytes exactly as they arrived, + which for a `Content-Encoding: gzip` (or deflate, or br) response is compressed data: the + frames never parse, the turn ends with nothing, and no error says why. `iter_bytes()` is the + same stream after HTTPX has decoded the content encoding, which is what `iter_lines()` was + reading before. + + The clock is checked BEFORE every read, the first one included, by stepping the iterator by + hand. `for chunk in response.iter_bytes()` starts a read and only then reaches the check, so + setting the request up can eat the whole budget and the driver would still begin a read it + cannot afford. + + Splitting on b"\n" before decoding is safe: a newline byte cannot appear inside a UTF-8 + multi-byte sequence, so no character is ever cut in half. Trailing CR is stripped for CRLF + senders; a bare-CR line ending is not supported, and never was, because the emitter writes LF. + """ + buffer = bytearray() + chunks = iter(response.iter_bytes()) + while True: + if out_of_time(): + yield None + return + try: + chunk = next(chunks) + except StopIteration: + break + buffer.extend(chunk) + while True: + index = buffer.find(b"\n") + if index < 0: + break + line = bytes(buffer[:index]) + del buffer[: index + 1] + yield line.rstrip(b"\r").decode("utf-8", "replace") + if buffer: + yield bytes(buffer).rstrip(b"\r").decode("utf-8", "replace") + + def invoke( - session_id: str, messages: list, params: dict, timeout: float = 300.0 + session_id: str, + messages: list, + params: dict, + timeout: float = 300.0, + deadline: float | None = None, ) -> Turn: + """One turn. `timeout` is HTTPX's per-operation bound; `deadline` is an ABSOLUTE + `time.monotonic()` value that bounds the WHOLE turn. + + The two are not the same guarantee and only the second one holds. HTTPX applies its timeout to + connect, write, read and pool SEPARATELY, so a stream that keeps sending bytes never trips it + and the turn runs forever. A caller that must come back at a known time (the concurrency + journeys) passes a deadline, and three things enforce it: + + - The HTTPX timeout is lowered to whatever time is actually left, so connect, write, an + error-body read, and a silent read cannot each be granted the full configured value when + there are milliseconds left. + - The body is read as RAW CHUNKS with the lines assembled here, and the deadline is checked + per chunk. `iter_lines()` only yields on a newline, so a stream that sends bytes without + one would never reach a check. + - A read that times out past the deadline is recorded as hung rather than raised, because + that is the same event seen from the other side. + + Without a deadline the behaviour is exactly what it always was, including the raise. + """ t = Turn() body = { "session_id": session_id, @@ -512,87 +648,154 @@ def invoke( "Content-Type": "application/json", } start = time.time() - with httpx.Client(timeout=timeout) as client: - with client.stream( - "POST", - f"{BASE}/services/agent/v0/invoke", - params={"project_id": PROJECT}, - json=body, - headers=headers, - ) as r: - t.http_status = r.status_code - if r.status_code >= 400: - t.errors.append(f"HTTP {r.status_code}: {r.read().decode()[:500]}") - t.ms = int((time.time() - start) * 1000) - return t - for line in r.iter_lines(): - if not line or line.startswith(":") or not line.startswith("data: "): - continue - payload = line[6:] - if payload == "[DONE]": - break - try: - f = json.loads(payload) - except json.JSONDecodeError: - continue - ftype = f.get("type", "?") - t.frames.append(ftype) - if ftype == "text-delta": - delta = f.get("delta", "") - t.text.append(delta) - # Coalesce consecutive text-delta frames into ONE running text segment; - # a tool call between two text runs starts a NEW segment (see below), so - # this reproduces the AI SDK's interleaved part order. - if t._segments and t._segments[-1]["kind"] == "text": - t._segments[-1]["text"] += delta - else: - t._segments.append({"kind": "text", "text": delta}) - elif ftype == "tool-input-available": - # CAREFUL: this frame is emitted REPEATEDLY for one tool call, carrying a - # progressively-built PARTIAL input, and `toolName` changes case along the way - # ("bash" while streaming -> "Bash" when complete). Only the LAST frame per - # toolCallId holds the real command. Keeping the first one approves a - # truncated command under the wrong name, the runner's decision key - # (name+args) misses the parked gate, and the approval re-parks forever. - call = { - "toolCallId": f.get("toolCallId"), - "toolName": f.get("toolName"), - "input": f.get("input"), - } - is_new_call = not any( - c["toolCallId"] == call["toolCallId"] for c in t.tool_calls - ) - t.tool_calls = [ - c for c in t.tool_calls if c["toolCallId"] != call["toolCallId"] - ] + [call] - # Segment position is fixed at FIRST appearance (when the call starts), - # never moved by later partial-input updates — that's when the AI SDK - # would have inserted the tool part into UIMessage.parts. - if is_new_call: - t._segments.append({"kind": "tool", "id": call["toolCallId"]}) - elif ftype == "tool-approval-request": - t.approval = { - "approvalId": f.get("approvalId"), - "toolCallId": f.get("toolCallId"), - } - elif ftype in ( - "tool-output-available", - "tool-output-error", - "tool-output-denied", - ): - tcid = f.get("toolCallId") - if tcid: - t.tool_outcomes[tcid] = ftype.replace("tool-output-", "") - if ftype == "tool-output-available": - t.tool_payloads[tcid] = {"output": f.get("output")} - elif ftype == "tool-output-error": - t.tool_payloads[tcid] = {"errorText": f.get("errorText")} - elif ftype == "data-committed-revision": - t.committed_revision = f.get("data") - elif ftype == "error": - t.errors.append(json.dumps(f)[:300]) - elif ftype == "finish": - t.finish_reason = f.get("finishReason") + + def _remaining() -> float | None: + return None if deadline is None else deadline - time.monotonic() + + def _out_of_time() -> bool: + # At or below the floor counts as out of time. An operation that cannot fit in what is + # left must not be started, rather than be given the floor as a grant. + left = _remaining() + return left is not None and left <= DEADLINE_FLOOR_SECONDS + + def _mark_hung() -> None: + t.hung = True + t.hung_reason = HUNG_AT_DEADLINE + + # Never START an operation the turn has no time for, and never grant one more time than the + # turn has left. + if _out_of_time(): + _mark_hung() + t.ms = int((time.time() - start) * 1000) + return t + left = _remaining() + effective = timeout if left is None else min(timeout, left) + try: + with httpx.Client(timeout=effective) as client: + with client.stream( + "POST", + f"{BASE}/services/agent/v0/invoke", + params={"project_id": PROJECT}, + json=body, + headers=headers, + ) as r: + t.http_status = r.status_code + if r.status_code >= 400: + # Reading the error body is a read like any other, and setting the request up + # may already have spent the budget. Check before it, not after. + if _out_of_time(): + _mark_hung() + r.close() + t.ms = int((time.time() - start) * 1000) + return t + t.errors.append(f"HTTP {r.status_code}: {r.read().decode()[:500]}") + t.ms = int((time.time() - start) * 1000) + return t + for line in _sse_lines(r, _out_of_time): + if line is None: + # The generator ran out of time. Abandon the stream where it is; the turn + # carries what it received plus the reason it stopped. + _mark_hung() + r.close() + break + if ( + not line + or line.startswith(":") + or not line.startswith("data: ") + ): + continue + payload = line[6:] + if payload == "[DONE]": + break + try: + f = json.loads(payload) + except json.JSONDecodeError: + continue + ftype = f.get("type", "?") + t.frames.append(ftype) + if ftype == "text-delta": + delta = f.get("delta", "") + t.text.append(delta) + # Coalesce consecutive text-delta frames into ONE running text segment; + # a tool call between two text runs starts a NEW segment (see below), so + # this reproduces the AI SDK's interleaved part order. + if t._segments and t._segments[-1]["kind"] == "text": + t._segments[-1]["text"] += delta + else: + t._segments.append({"kind": "text", "text": delta}) + elif ftype == "tool-input-available": + # CAREFUL: this frame is emitted REPEATEDLY for one tool call, carrying a + # progressively-built PARTIAL input, and `toolName` changes case along the way + # ("bash" while streaming -> "Bash" when complete). Only the LAST frame per + # toolCallId holds the real command. Keeping the first one approves a + # truncated command under the wrong name, the runner's decision key + # (name+args) misses the parked gate, and the approval re-parks forever. + call = { + "toolCallId": f.get("toolCallId"), + "toolName": f.get("toolName"), + "input": f.get("input"), + } + is_new_call = not any( + c["toolCallId"] == call["toolCallId"] for c in t.tool_calls + ) + t.tool_calls = [ + c + for c in t.tool_calls + if c["toolCallId"] != call["toolCallId"] + ] + [call] + # Segment position is fixed at FIRST appearance (when the call starts), + # never moved by later partial-input updates — that's when the AI SDK + # would have inserted the tool part into UIMessage.parts. + if is_new_call: + t._segments.append( + {"kind": "tool", "id": call["toolCallId"]} + ) + elif ftype == "tool-approval-request": + t.approval = { + "approvalId": f.get("approvalId"), + "toolCallId": f.get("toolCallId"), + } + elif ftype in ( + "tool-output-available", + "tool-output-error", + "tool-output-denied", + ): + tcid = f.get("toolCallId") + if tcid: + t.tool_outcomes[tcid] = ftype.replace("tool-output-", "") + if ftype == "tool-output-available": + t.tool_payloads[tcid] = {"output": f.get("output")} + elif ftype == "tool-output-error": + t.tool_payloads[tcid] = { + "errorText": f.get("errorText") + } + elif ftype == "data-committed-revision": + t.committed_revision = f.get("data") + elif ftype == "data-agent-error": + # The coded twin of the `error` frame below. The SDK emits both for one + # failure (`_error_parts`, adapters/vercel/stream.py): this one carries the + # runner's stable code, the next one carries the prose. Keep the code, so a + # journey can report the CLASS of a failure. + data = f.get("data") or {} + code = data.get("code") + if code: + t.error_codes.append(str(code)) + text = data.get("errorText") + if text: + t.error_texts.append(str(text)) + elif ftype == "error": + t.errors.append(json.dumps(f)[:300]) + elif ftype == "finish": + t.finish_reason = f.get("finishReason") + except httpx.TimeoutException: + # A read that ran out of time IS the deadline, seen from HTTPX's side: the effective + # timeout above was lowered to the time remaining. Record it the same way, so a turn + # that produced no bytes at all is not a driver crash. With no deadline the caller + # asked for the old behaviour and still gets the raise. + if deadline is None: + raise + t.hung = True + t.hung_reason = HUNG_AT_DEADLINE t.ms = int((time.time() - start) * 1000) return t @@ -655,7 +858,14 @@ def j3_tool(cell: dict) -> dict: } -def _approval_flow(cell: dict, approved: bool) -> dict: +def _approval_flow( + cell: dict, + approved: bool, + timeout: float = 300.0, + deadline: float | None = None, + session_id: str | None = None, + prompt: str | None = None, +) -> dict: """J4: with permission `ask`, a tool call must PAUSE with a tool-approval-request, then resume on the user's decision — the same in-band protocol the browser uses. @@ -668,8 +878,15 @@ def _approval_flow(cell: dict, approved: bool) -> dict: `list_connections` platform tool with per-tool `permission: "ask"` — empty arguments, so the resume matches on input exactly. Verified across {local, daytona} x {warm, cold} in docs/design/codex-harness/reports/warm-approvals-qa.md. + + `timeout` bounds EACH of the two turns, so a caller that runs this beside other work has to + budget two of them, and `deadline` bounds the whole flow in absolute time. The `crosstalk` + journey passes both, plus its own `session_id` (so a record exists even if the flow never + returns) and its own `prompt` (a mutating command carrying a nonce, so the resumed output can + be told apart from every other flow in the journey). `prompt` is ignored on codex, whose gate + rides a platform tool rather than the shell. """ - s = str(uuid.uuid4()) + s = session_id or str(uuid.uuid4()) if cell["harness"] == "codex": params = template( cell, @@ -685,13 +902,14 @@ def _approval_flow(cell: dict, approved: bool) -> dict: instructions="Use the bash tool when asked to run a command. Report only its stdout.", permission_default="ask", ) - msgs = [user_msg(MUTATE_PROMPT)] - t1 = invoke(s, msgs, params) + msgs = [user_msg(prompt or MUTATE_PROMPT)] + t1 = invoke(s, msgs, params, timeout=timeout, deadline=deadline) if not t1.approval: return { "pass": False, "why": "expected a tool-approval-request frame; the gate never fired", + "session_id": s, "turn": t1.summary(), } # A paused turn finishes with reason "other", not "stop". @@ -703,27 +921,74 @@ def _approval_flow(cell: dict, approved: bool) -> dict: ) gated_input = gated_call.get("input") or {} msgs = msgs + [approval_reply(t1, approved)] - t2 = invoke(s, msgs, params) + t2 = invoke(s, msgs, params, timeout=timeout, deadline=deadline) outcome = outcome_for_input(t2, gated_input) # Require the turn to have actually paused (paused_ok) and the resume to have reached a # definite, error-free, non-re-parked state (not t2.errors, not t2.approval) before trusting # `outcome` at all — otherwise an indeterminate resume (outcome=None from a failed resume or # a re-parked gate) reads as a silent PASS on the deny branch below. + # + # A CODED error on either turn fails the flow whatever the outcome says. A run whose + # credentials never arrived can still park a gate and still report a tool outcome, and + # reading that as a healthy approval is how a credential fault hides inside a green + # approval journey. The resume must also END, with `finish=stop`: a resume that stopped for + # any other reason did not complete the decision the user made. + clean = not t1.error_codes and not t2.error_codes and not t1.hung and not t2.hung + resumed_stop = t2.finish_reason == "stop" if approved: - ok = paused_ok and outcome == "available" and not t2.errors and not t2.approval - why = f"approved: the gated command executed after approval (outcome={outcome}, paused finish=other: {paused_ok})" + ok = ( + paused_ok + and outcome == "available" + and not t2.errors + and not t2.approval + and clean + and resumed_stop + ) + why = ( + f"approved: the gated command executed after approval (outcome={outcome}, " + f"paused finish=other: {paused_ok}, resumed finish=stop: {resumed_stop}, " + f"no coded error and neither turn hung: {clean})" + ) else: # Denied: the gated COMMAND must never have executed. Assert the WIRE, never the reply — # a denied model will happily hallucinate the output it never received. Require the # precise "denied" outcome (not merely "not available") so an indeterminate or errored # resume can't be misread as a successful deny. - ok = paused_ok and outcome == "denied" and not t2.errors and not t2.approval - why = f"denied: the gated command never executed (outcome={outcome})" + ok = ( + paused_ok + and outcome == "denied" + and not t2.errors + and not t2.approval + and clean + and resumed_stop + ) + why = ( + f"denied: the gated command never executed (outcome={outcome}, " + f"resumed finish=stop: {resumed_stop}, no coded error and neither turn hung: " + f"{clean})" + ) + # Everything the resumed turn produced, reply and tool output alike. A caller that gave this + # flow a nonce reads its proof here: the model does not always repeat a command's stdout in + # prose, so the tool payload has to count too. + resumed_output = " ".join( + [t2.reply] + + [ + str(payload.get("output") or payload.get("errorText") or "") + for payload in t2.tool_payloads.values() + ] + ) return { "pass": ok, "why": why, "paused_finish_other": paused_ok, + "resumed_finish": t2.finish_reason, + "hung": bool(t1.hung or t2.hung), + "error_codes": sorted(set(t1.error_codes + t2.error_codes)), + # The session id rides the result so a caller that runs several approval flows at the + # same time (the `crosstalk` journey) can name which conversation each record belongs to. + "session_id": s, + "resumed_output": resumed_output[:600], "turn_paused": t1.summary(), "turn_resumed": t2.summary(), } @@ -2161,6 +2426,675 @@ def j_builtin_grep(cell: dict) -> dict: } +# -------------------------------------------------------------------------------------------- +# Concurrency: many runs at the same time (AGE-4249 / #6485) +# -------------------------------------------------------------------------------------------- +# Every other journey in this file drives ONE run at a time, so the gate can only ever see faults +# that reproduce on a quiet deployment. The production failure these two journeys exist for does +# not: about one first message in five failed with "A temporary issue kept this run's credentials +# from reaching the model", because a fresh Daytona sandbox sometimes starts without its Secret +# substitution wiring. The fault needs MANY cold starts to show up, and it is stochastic. +# +# SO A PASS HERE IS PROBABILISTIC AND A FAIL IS PROOF. At the 8 percent per-cold-start rate the +# incident measured, a burst of 8 misses the fault 51 percent of the time (0.92 ** 8), a burst of +# 16 misses it 26 percent (0.92 ** 16), and two Daytona cells at 16 miss it about 7 percent +# (0.92 ** 32). The default is 16 for that reason. One green run is not evidence that the fault is +# gone; one red run is evidence that it is not. +# +# `burst` starts N fresh sessions at once. `crosstalk` runs long conversations and approval flows +# side by side and checks that no stream carries another session's data. Both are Daytona-only by +# default: the fault lives in the remote credential path, and a local sandbox has no Secrets to +# lose. `--concurrency-everywhere` runs them on local cells too, which is cheap and proves the +# journeys themselves. +# +# COST. Each concurrent run holds its own Daytona sandbox, about 5 GiB of the organization's disk +# quota, and a parked sandbox keeps counting until its auto-delete window closes. A burst of 16 is +# therefore about 80 GiB in flight, and back-to-back bursts on several cells can exhaust the +# organization's total disk before the first ones are reclaimed. Plan a release run accordingly. +BURST_SIZE = 16 +CROSSTALK_CONVERSATIONS = 3 +CROSSTALK_APPROVALS = 2 +# One cell must not be able to ask for an unbounded number of sandboxes by typo. +CONCURRENCY_MAX_JOBS = 32 +CONCURRENCY_EVERYWHERE = False +# Per TURN. `invoke` also gets this as an absolute deadline, so a stream that keeps emitting bytes +# is abandoned rather than followed forever. +CONCURRENCY_TURN_TIMEOUT_SECONDS = 300.0 +CONCURRENCY_WAIT_MARGIN_SECONDS = 120.0 +# How many lines the `crosstalk` conversations ask for, how many text-delta frames a reply must +# have arrived in, and how big the reply itself must be. +# +# The frame bar is "the reply STREAMED", never a claim about a harness's chunking: measured on +# staging, Pi sends this reply in about 312 frames and Claude sends the same reply in 4 to 7. A +# threshold of 10 read as a Claude failure while every product property held. +# +# The SIZE bar is what makes this a long-output journey rather than a two-word one. 150 numbered +# lines are about 500 characters, so the check passes on either shape: at least 100 lines, or at +# least 600 characters. The real counts stay in the evidence. +CROSSTALK_LINES = 150 +CROSSTALK_MIN_TEXT_DELTAS = 2 +CROSSTALK_MIN_REPLY_LINES = 100 +CROSSTALK_MIN_REPLY_CHARS = 600 +# The turn ledger is written after the stream closes. matrix_c5_first_call_race.py waits the same +# second before reading it; the concurrency journeys then poll a few more, because they record the +# rows as evidence rather than asserting on them. +LEDGER_SETTLE_SECONDS = 1.0 +LEDGER_POLL_SECONDS = 5.0 + +# A failure whose text says the model refused the credentials. The CODE is the primary evidence +# (`credential_delivery_failed`), and this regex is the backstop for a deployment whose runner is +# older than the coded frame, so an auth failure can never read as an unexplained error. +AUTH_FAILURE_RE = re.compile( + r"authentication failed|invalid[_ ]api[_ ]key|unauthorized|\b401\b|credentials", + re.I, +) +CREDENTIAL_DELIVERY_CODE = "credential_delivery_failed" + +# The sandbox provider ran out of room. This is the environment refusing to give the journey what +# it asked for, not the product failing, so it is a SKIP with a loud reason rather than a FAIL. +# +# The match is deliberately narrow and quotes Daytona's own create-path refusal. It must NEVER +# grow to cover `rate_limited`: an internal rate limit under a load the product is supposed to +# support is a real finding, and hiding it behind a SKIP would delete the only signal the gate has. +CAPACITY_REFUSAL_RE = re.compile( + r"total disk limit exceeded|disk quota exceeded|sandbox quota exceeded", re.I +) + + +def _concurrency_skip(cell: dict, what: str) -> dict | None: + """Concurrency journeys are Daytona-only unless the operator asks for more.""" + if cell["sandbox"] == "daytona" or CONCURRENCY_EVERYWHERE: + return None + return { + "skip": True, + "why": ( + f"{what} targets the remote credential path, which only a cloud sandbox has " + f"(cell sandbox={cell['sandbox']}). Run --cell C2, C4, P3 or X2, or pass " + "--concurrency-everywhere to run it on local cells too." + ), + } + + +def _run_record(label: str, session_id: str, t: "Turn", **extra) -> dict: + """One run's evidence, in the shape both concurrency journeys report.""" + text = " ".join(t.error_texts + t.errors + ([t.hung_reason] if t.hung else [])) + return { + "label": label, + "session_id": session_id, + "finish_reason": t.finish_reason, + "error_code": t.error_codes[0] if t.error_codes else None, + "error_codes": t.error_codes, + "error_text": sanitize(text), + "hung": t.hung, + "ms": t.ms, + "http": t.http_status, + **extra, + } + + +def _run_concurrently( + jobs: list, + turns_per_job: int = 1, + progress: dict | None = None, + journey_start: float | None = None, +) -> list: + """Run every job at the same time, on daemon threads, and always come back. + + A job is `(label, session_id, callable)`. The session id is allocated by the CALLER, before + the job starts, so a job that never returns still has a record naming the session a human can + go and look at. + + Threads are daemons on purpose. `ThreadPoolExecutor` cannot cancel a thread that is already + running, and the interpreter JOINS its worker threads at exit, so one abandoned turn would + hold the whole gate open. A daemon thread cannot. The turn itself is bounded too: every job + receives the same absolute deadline and passes it to `invoke`, which abandons the stream there + rather than reading it forever. + + `progress` is a dict a job writes its current phase into, keyed by label. It is read only + when a job never returns, which is exactly when nothing else can say how far it got. + + `journey_start` is the monotonic instant the journey began, so a record this function has to + invent — a hung job, a crashed one — still carries the offsets at which it started and + stopped. Overlap is only checkable if every job reports both, including the ones that failed. + + `turns_per_job` is how many SEQUENTIAL turns one job runs, and the bound is that many per-turn + timeouts plus one margin. A two-turn job judged against a one-turn bound would be called hung + for a limit it never had, which reports a product fault that is really an arithmetic error in + the check. + """ + span = max(1, turns_per_job) * CONCURRENCY_TURN_TIMEOUT_SECONDS + wait_for = span + CONCURRENCY_WAIT_MARGIN_SECONDS + started = time.monotonic() + origin = journey_start if journey_start is not None else started + end_by = started + wait_for + done: dict = {} + submitted: dict = {} + threads = [] + for label, session_id, job in jobs: + submitted[label] = round(time.monotonic() - origin, 2) + + def target(label=label, job=job): + try: + done[label] = job() + except Exception as e: # a crash is one run's result, not the journey's + done[label] = { + "ok": False, + "phase": (progress or {}).get(label, "unknown"), + "started_s": submitted.get(label), + "ended_s": round(time.monotonic() - origin, 2), + "why": sanitize(f"driver exception: {type(e).__name__}: {e}"), + } + + thread = threading.Thread(target=target, name=f"qa-{label}", daemon=True) + thread.start() + threads.append(thread) + for thread in threads: + thread.join(max(0.0, end_by - time.monotonic())) + gave_up_at = round(time.monotonic() - origin, 2) + records = [] + for label, session_id, _ in jobs: + record = done.get(label) + if record is None: + record = { + "ok": False, + "hung": True, + "phase": (progress or {}).get(label, "unknown"), + "started_s": submitted.get(label), + "ended_s": gave_up_at, + "why": ( + f"still running after {wait_for:.0f}s " + f"({max(1, turns_per_job)} turn(s) at " + f"{CONCURRENCY_TURN_TIMEOUT_SECONDS:.0f}s plus a " + f"{CONCURRENCY_WAIT_MARGIN_SECONDS:.0f}s margin)" + ), + } + record.setdefault("label", label) + record.setdefault("session_id", session_id) + record.setdefault("started_s", submitted.get(label)) + record.setdefault("ended_s", gave_up_at) + records.append(record) + return sorted(records, key=lambda r: r["label"]) + + +def _is_capacity_refusal(run: dict) -> str | None: + """The provider's own capacity refusal on THIS run, or None.""" + text = str(run.get("error_text") or "") + " " + str(run.get("why") or "") + hit = CAPACITY_REFUSAL_RE.search(text) + return hit.group(0) if hit else None + + +def _concurrency_verdict(runs: list, n: int, headline: str) -> dict: + """The shared summary: what failed, and the codes that say why.""" + failed = [r for r in runs if not r.get("ok")] + codes = sorted({c for r in runs for c in (r.get("error_codes") or [])}) + texts = " ".join(str(r.get("error_text") or "") for r in runs) + hung = [r["label"] for r in runs if r.get("hung")] + # Both sets, always. A capacity refusal excuses ONLY the runs it actually refused: if one run + # died on disk and another came back `credential_delivery_failed`, the journey found the fault + # it exists to find, and a SKIP would delete that result. + capacity = { + r["label"]: reason + for r in failed + if (reason := _is_capacity_refusal(r)) is not None + } + product = [r for r in failed if r["label"] not in capacity] + if capacity and not product: + return { + "skip": True, + "why": ( + f"ENVIRONMENT, NOT THE PRODUCT: the sandbox provider refused " + f"{len(capacity)}/{n} runs on capacity ({sorted(capacity.values())[0]}), and no " + "run failed for any other reason. A burst holds about 5 GiB per sandbox and a " + "parked sandbox keeps counting until it is deleted, so free the organization's " + "disk or lower --burst-size, then run this cell again. Nothing about the product " + "was measured." + ), + "capacity_refusals": sorted(capacity), + "failed": len(failed), + "total": n, + "runs": runs, + } + why = f"{headline}: {len(failed)}/{n} failed" + if capacity: + why += ( + f" ({len(product)} product failure(s) and {len(capacity)} capacity refusal(s) " + f"{sorted(capacity)}; the capacity refusals excuse themselves and nothing else)" + ) + if codes: + why += f", runner error codes {codes}" + if CREDENTIAL_DELIVERY_CODE in codes: + count = sum( + 1 for r in runs if CREDENTIAL_DELIVERY_CODE in (r.get("error_codes") or []) + ) + why += ( + f". CREDENTIAL DELIVERY FAILED on {count} run(s): the provider key never reached the " + "model on a cold sandbox. This is AGE-4249, and it is the fault this journey exists " + "to catch" + ) + elif failed and AUTH_FAILURE_RE.search(texts): + why += ( + ". At least one run failed on an AUTHENTICATION refusal. Read error_text per run: a " + "cold sandbox that never got its credential wiring fails exactly this way" + ) + if hung: + why += f". Abandoned at the deadline: {hung}" + if not failed: + why += ( + ". A PASS here is probabilistic: at the incident's 8 percent per-cold-start rate, " + f"{n} runs miss the fault {0.92**n:.0%} of the time" + ) + return { + "pass": not failed, + "why": why, + "failed": len(failed), + "product_failures": len(product), + "capacity_refusals": sorted(capacity), + "total": n, + "error_codes": codes, + "hung": hung, + "runs": runs, + } + + +def _warm_reuse_evidence(session_id: str) -> dict: + """The turn ledger's view of one session, polled briefly. EVIDENCE, never a verdict. + + `crosstalk` records this and does not fail on it. Two sandbox ids across a session can be + perfectly correct here: a preflight rebuild replaces the sandbox on purpose (LESSONS.md, the + credential-preflight entry). The `warm` journey owns the warm-reuse claim, on a quiet + deployment where a second id really does mean the turn was not served warm. + """ + deadline = time.monotonic() + LEDGER_POLL_SECONDS + agents: list = [] + sandboxes: list = [] + polls = 0 + while True: + polls += 1 + agents, sandboxes = _ledger_ids(session_id) + if (agents or sandboxes) or time.monotonic() >= deadline: + break + time.sleep(LEDGER_SETTLE_SECONDS) + return { + "agent_session_ids": agents, + "sandbox_ids": sandboxes, + "ids_stable": len(agents) == 1 and len(sandboxes) == 1, + "ledger_rows_seen": bool(agents or sandboxes), + "polls": polls, + "note": ( + "evidence only; a preflight rebuild legitimately yields two sandbox ids, and the " + "`warm` journey owns the warm-reuse verdict" + ), + } + + +def j_burst(cell: dict) -> dict: + """N first messages, sent to N brand new sessions at the same time. + + Every run must finish normally and reply with its OWN nonce. A fresh session id is a pool key + the runner has never seen, so each run creates a sandbox from cold. That is what makes this + journey a credential-delivery probe rather than a load test: N cold starts in one burst, + against a fault that only some cold starts hit. + + The nonce does double duty. It proves the reply belongs to this run (the model cannot guess + it), and a nonce from another run appearing here would prove the streams crossed. + """ + if skip := _concurrency_skip(cell, "a burst of cold starts"): + return skip + n = BURST_SIZE + if n < 1: + return {"pass": False, "why": "--burst-size must be at least 1"} + nonces = {i: f"QA-BURST-{uuid.uuid4().hex[:12].upper()}" for i in range(n)} + params = template( + cell, + instructions="Be terse. Reply with exactly what is asked and nothing else.", + ) + journey_start = time.monotonic() + deadline = journey_start + CONCURRENCY_TURN_TIMEOUT_SECONDS + sessions = {i: str(uuid.uuid4()) for i in range(n)} + progress: dict = {} + + def one(i: int) -> dict: + started = time.monotonic() - journey_start + progress[f"burst-{i:02d}"] = "turn" + nonce = nonces[i] + t = invoke( + sessions[i], + [user_msg(f"Reply with exactly: {nonce}")], + params, + timeout=CONCURRENCY_TURN_TIMEOUT_SECONDS, + deadline=deadline, + ) + mine = nonce in t.reply + theirs = sorted(v for k, v in nonces.items() if k != i and v in t.reply) + record = _run_record( + f"burst-{i:02d}", + sessions[i], + t, + phase="done", + started_s=round(started, 2), + ended_s=round(time.monotonic() - journey_start, 2), + own_nonce_in_reply=mine, + other_nonces_in_reply=theirs, + reply=sanitize(t.reply, 120), + ) + record["ok"] = bool( + t.finish_reason == "stop" + and not t.errors + and not t.error_codes + and not t.hung + and mine + and not theirs + ) + return record + + runs = _run_concurrently( + [(f"burst-{i:02d}", sessions[i], lambda i=i: one(i)) for i in range(n)], + progress=progress, + journey_start=journey_start, + ) + result = _concurrency_verdict( + runs, n, f"{n} first messages sent at the same time on {n} fresh sessions" + ) + bleed = [r["label"] for r in runs if r.get("other_nonces_in_reply")] + if bleed and not result.get("skip"): + result["pass"] = False + result["why"] += f". Nonce bleed between sessions: {bleed}" + result["nonce_bleed"] = bleed + return result + + +def j_crosstalk(cell: dict) -> dict: + """Long conversations and approval flows, all running at the same time. + + Two shapes share the deployment. K conversations ask for a long deterministic output over two + turns on ONE session each, and M approval flows pause and resume beside them. Together they + hold several sandboxes, several streams and several parked gates open at once, which is the + state a single-run gate never reaches. + + What this asserts: + + - Every turn arrives as more than one text-delta frame AND carries a reply of the size the + prompt asked for (at least 100 lines, or 600 characters). "It streamed" and "it answered + at length" are different claims and this journey makes both. + - Every turn ends with the nonce that belongs to THAT turn, and with no other nonce in the + journey. Exclusivity covers the other turn of the same conversation and every approval, + so a replayed or crossed stream cannot pass. + - Every approval pauses and resumes, and the resumed output carries that flow's own nonce + and no other. A gate that parks under load and never comes back is the same defect class + as a stream that never finishes. + + NOT ON CODEX. The codex gate rides a platform tool with empty arguments rather than the + shell, so its approval command cannot carry a nonce. Those records set + `nonce_checked=false` and the isolation claim is simply not made there, rather than being + faked. Everything else about a codex approval is asserted as usual. + - Warm reuse is RECORDED, not required. See `_warm_reuse_evidence`. + + Every job reports the offsets, from the journey's start, at which it began and ended, so a + reader can confirm afterwards that the runs really did overlap. There is no barrier: the point + is a realistic pile-up, not a synchronised stress test. + """ + if skip := _concurrency_skip(cell, "concurrent conversations and approvals"): + return skip + conversations = CROSSTALK_CONVERSATIONS + approvals = CROSSTALK_APPROVALS + if conversations < 1 and approvals < 1: + return { + "pass": False, + "why": "crosstalk needs at least one conversation or one approval", + } + # Every nonce in the journey, so each job can check its own AND everyone else's. + nonces = { + (i, turn): f"QA-XT{i:02d}{turn}-{uuid.uuid4().hex[:10].upper()}" + for i in range(conversations) + for turn in ("A", "B") + } + approval_nonces = { + i: f"QA-XTAP{i:02d}-{uuid.uuid4().hex[:10].upper()}" for i in range(approvals) + } + all_nonces = dict(nonces) + all_nonces.update({("approval", i): v for i, v in approval_nonces.items()}) + params = template( + cell, + instructions=( + "Be terse. Answer directly in text. Do not use any tool. Do exactly what is " + "asked and nothing more." + ), + ) + journey_start = time.monotonic() + # Both halves run two sequential turns, so a job may take two per-turn timeouts. + deadline = journey_start + 2 * CONCURRENCY_TURN_TIMEOUT_SECONDS + conv_sessions = {i: str(uuid.uuid4()) for i in range(conversations)} + appr_sessions = {i: str(uuid.uuid4()) for i in range(approvals)} + # How far each job got, so a job that never returns still says where it stopped. + progress: dict = {} + + def foreign(mine: list, text: str) -> list: + """Every nonce in the journey that is NOT this turn's and appears in the text.""" + return sorted({v for v in all_nonces.values() if v not in mine and v in text}) + + def long_prompt(first: int, last: int, nonce: str) -> str: + return ( + f"Print the numbers {first} to {last}, one per line, then print {nonce} " + "on its own line as the last line. Print nothing else." + ) + + def big_enough(reply: str) -> bool: + return ( + len(reply.splitlines()) >= CROSSTALK_MIN_REPLY_LINES + or len(reply) >= CROSSTALK_MIN_REPLY_CHARS + ) + + def conversation(i: int) -> dict: + started = time.monotonic() - journey_start + session = conv_sessions[i] + label = f"conversation-{i:02d}" + a, b = nonces[(i, "A")], nonces[(i, "B")] + progress[label] = phase = "turn1" + msgs = [user_msg(long_prompt(1, CROSSTALK_LINES, a))] + t1 = invoke( + session, + msgs, + params, + timeout=CONCURRENCY_TURN_TIMEOUT_SECONDS, + deadline=deadline, + ) + # Byte-faithful history, so the runner's history fingerprint still matches and turn 2 is + # genuinely a continuation. A text-only replay would evict the session (see + # assistant_message()). + progress[label] = phase = "turn2" + msgs = msgs + [ + t1.assistant_message(), + user_msg(long_prompt(CROSSTALK_LINES + 1, CROSSTALK_LINES * 2, b)), + ] + t2 = invoke( + session, + msgs, + params, + timeout=CONCURRENCY_TURN_TIMEOUT_SECONDS, + deadline=deadline, + ) + progress[label] = phase = "ledger" + warm = _warm_reuse_evidence(session) + progress[label] = phase = "done" + deltas = [t1.frames.count("text-delta"), t2.frames.count("text-delta")] + lines = [len(t1.reply.splitlines()), len(t2.reply.splitlines())] + chars = [len(t1.reply), len(t2.reply)] + streamed = all(d >= CROSSTALK_MIN_TEXT_DELTAS for d in deltas) + long_enough = big_enough(t1.reply) and big_enough(t2.reply) + mine = a in t1.reply and b in t2.reply + bled = sorted(set(foreign([a], t1.reply) + foreign([b], t2.reply))) + clean = ( + t1.finish_reason == "stop" + and t2.finish_reason == "stop" + and not t1.errors + and not t2.errors + and not t1.error_codes + and not t2.error_codes + and not t1.hung + and not t2.hung + ) + record = _run_record( + label, + session, + t2, + kind="conversation", + phase=phase, + started_s=round(started, 2), + ended_s=round(time.monotonic() - journey_start, 2), + text_deltas=deltas, + reply_lines=lines, + reply_chars=chars, + long_enough=long_enough, + own_nonces_in_replies=mine, + other_nonces_in_replies=bled, + warm_reuse=warm, + turn1=t1.summary(), + turn2=t2.summary(), + ) + # Turn 1's codes belong in the record too: _run_record reads turn 2 only. + record["error_codes"] = sorted(set(t1.error_codes + t2.error_codes)) + record["error_code"] = ( + record["error_codes"][0] if record["error_codes"] else None + ) + record["error_text"] = sanitize( + " ".join(t1.error_texts + t1.errors + t2.error_texts + t2.errors) + ) + record["hung"] = bool(t1.hung or t2.hung) + record["ok"] = bool(clean and streamed and long_enough and mine and not bled) + return record + + def approval(i: int) -> dict: + started = time.monotonic() - journey_start + session = appr_sessions[i] + label = f"approval-{i:02d}" + progress[label] = "paused" + nonce = approval_nonces[i] + # A MUTATING command, so Claude's read-only auto-approval cannot skip the gate, carrying + # this flow's own nonce so its output can be told apart from every other flow's. + prompt = ( + f"Use the bash tool to run exactly: " + f"echo {nonce} > /tmp/qa-{nonce}.txt && cat /tmp/qa-{nonce}.txt " + "and reply with only its stdout." + ) + r = _approval_flow( + cell, + approved=True, + timeout=CONCURRENCY_TURN_TIMEOUT_SECONDS, + deadline=deadline, + session_id=session, + prompt=prompt, + ) + progress[label] = "resumed" + summaries = [ + s for s in (r.get("turn_paused"), r.get("turn_resumed"), r.get("turn")) if s + ] + codes = sorted(set(r.get("error_codes") or [])) + errors = [e for s in summaries for e in (s.get("errors") or [])] + output = str(r.get("resumed_output") or "") + # Codex gates a platform tool with empty arguments, not the shell, so it cannot carry a + # nonce. The isolation claim is not made there rather than being faked. + nonce_checked = cell["harness"] != "codex" + mine = (nonce in output) if nonce_checked else None + bled = foreign([nonce], output) if nonce_checked else [] + return { + "label": label, + "kind": "approval", + "phase": "resumed" if r.get("turn_resumed") else "paused", + "started_s": round(started, 2), + "ended_s": round(time.monotonic() - journey_start, 2), + "ok": bool( + r.get("pass") + and not codes + and not r.get("hung") + and not any(s.get("hung") for s in summaries) + and (mine is not False) + and not bled + ), + "session_id": r.get("session_id") or session, + "finish_reason": r.get("resumed_finish"), + "http": (summaries[-1].get("http") if summaries else None), + "ms": sum(int(s.get("ms") or 0) for s in summaries), + "error_code": codes[0] if codes else None, + "error_codes": codes, + "error_text": sanitize(" ".join(str(e) for e in errors)), + "hung": bool(r.get("hung")) or any(s.get("hung") for s in summaries), + "why": r.get("why"), + "paused_finish_other": r.get("paused_finish_other"), + "nonce_checked": nonce_checked, + "own_nonce_in_output": mine, + "other_nonces_in_output": bled, + "resumed_output": sanitize(output, 200), + "turn_paused": r.get("turn_paused"), + "turn_resumed": r.get("turn_resumed"), + } + + jobs = [ + (f"conversation-{i:02d}", conv_sessions[i], lambda i=i: conversation(i)) + for i in range(conversations) + ] + [ + (f"approval-{i:02d}", appr_sessions[i], lambda i=i: approval(i)) + for i in range(approvals) + ] + runs = _run_concurrently( + jobs, turns_per_job=2, progress=progress, journey_start=journey_start + ) + total = len(jobs) + result = _concurrency_verdict( + runs, + total, + ( + f"{conversations} two-turn conversations with long output and {approvals} approval " + "flows, all at the same time" + ), + ) + if result.get("skip"): + return result + bleed = [ + r["label"] + for r in runs + if r.get("other_nonces_in_replies") or r.get("other_nonces_in_output") + ] + thin = [ + r["label"] + for r in runs + if r.get("kind") == "conversation" + and not all( + d >= CROSSTALK_MIN_TEXT_DELTAS for d in (r.get("text_deltas") or [0]) + ) + ] + short = [ + r["label"] + for r in runs + if r.get("kind") == "conversation" and r.get("long_enough") is False + ] + if bleed: + result["pass"] = False + result["why"] += f". Nonce bleed between sessions: {bleed}" + if thin: + result["why"] += ( + f". These conversations never streamed {CROSSTALK_MIN_TEXT_DELTAS} text-delta " + f"frames on both turns: {thin}" + ) + if short: + result["why"] += ( + f". These conversations answered below the size the prompt asked for " + f"({CROSSTALK_MIN_REPLY_LINES} lines or {CROSSTALK_MIN_REPLY_CHARS} characters): " + f"{short}" + ) + result["nonce_bleed"] = bleed + result["not_streamed"] = thin + result["too_short"] = short + result["overlap_s"] = [ + { + "label": r["label"], + "started_s": r.get("started_s"), + "ended_s": r.get("ended_s"), + } + for r in runs + ] + return result + + JOURNEYS = { "chat": j1_chat, "mount": j2_mount, @@ -2179,10 +3113,78 @@ def j_builtin_grep(cell: dict) -> dict: "builtin_grep": j_builtin_grep, "secret_opaque": j_secret_opaque, "rotate": j_rotate, + "burst": j_burst, + "crosstalk": j_crosstalk, } +def _load_session_control_result(path: str) -> dict: + """Load and summarize a complete standalone session-control result.""" + result_path = pathlib.Path(path).expanduser() + try: + payload = json.loads(result_path.read_text()) + except (OSError, json.JSONDecodeError) as exc: + raise SystemExit( + f"Cannot read --session-control-results {result_path}: {exc}" + ) from exc + + cells = payload.get("cells") + if not isinstance(cells, dict): + raise SystemExit( + f"Invalid session-control result {result_path}: expected a top-level cells object." + ) + + # Import the standalone driver's registry instead of copying its cell names here. A newly + # added session-control cell must become release-mandatory without a second list to update. + from session_control import CELLS as session_control_cells + + missing = sorted(set(session_control_cells) - set(cells)) + if missing: + raise SystemExit( + f"Incomplete session-control result {result_path}: missing cells: " + + ", ".join(missing) + ) + + statuses: dict[str, str] = {} + for name in session_control_cells: + entry = cells.get(name) + verdict = entry.get("verdict") if isinstance(entry, dict) else None + if ( + not isinstance(verdict, dict) + or not isinstance(verdict.get("pass"), bool) + or not isinstance(verdict.get("skip"), bool) + or (verdict["pass"] and verdict["skip"]) + ): + raise SystemExit( + f"Invalid session-control result {result_path}: cell {name!r} has no valid " + "PASS/FAIL/SKIP verdict." + ) + statuses[name] = ( + "SKIP" if verdict["skip"] else ("PASS" if verdict["pass"] else "FAIL") + ) + + failed = sorted(name for name, status in statuses.items() if status == "FAIL") + skipped = sorted(name for name, status in statuses.items() if status == "SKIP") + return { + "path": str(result_path), + "status": "FAIL" if failed else ("INCOMPLETE" if skipped else "PASS"), + "failed": failed, + "skipped": skipped, + } + + +def _session_control_result_label(result: dict) -> str: + label = f"recorded {result['status']}" + if result["skipped"]: + label += "; SKIPPED, UNTESTED: " + ", ".join(result["skipped"]) + return label + + def main() -> int: + # Declared here, not beside the assignments below, because the flag help strings read these + # module defaults and a `global` statement must precede every use of the name in a function. + global BURST_SIZE, CROSSTALK_CONVERSATIONS, CROSSTALK_APPROVALS + global CONCURRENCY_EVERYWHERE, CONCURRENCY_TURN_TIMEOUT_SECONDS p = argparse.ArgumentParser() p.add_argument( "--cell", @@ -2260,6 +3262,53 @@ def main() -> int: "lapses (AGENTA_SESSIONS_REDIS_OWNER_TTL_SECONDS, default 120)" ), ) + p.add_argument( + "--burst-size", + type=int, + default=BURST_SIZE, + help=( + "first messages the `burst` journey sends at the same time, each on a fresh session " + f"and therefore a cold sandbox (default {BURST_SIZE}, maximum " + f"{CONCURRENCY_MAX_JOBS}). At the incident's 8 percent per-cold-start fault rate, 8 " + "runs miss the fault 51 percent of the time and 16 miss it 26 percent. Each run " + "holds about 5 GiB of Daytona disk." + ), + ) + p.add_argument( + "--crosstalk-conversations", + type=int, + default=CROSSTALK_CONVERSATIONS, + help=( + "two-turn conversations with long output the `crosstalk` journey runs at the same " + f"time (default {CROSSTALK_CONVERSATIONS})" + ), + ) + p.add_argument( + "--crosstalk-approvals", + type=int, + default=CROSSTALK_APPROVALS, + help=( + "approval flows the `crosstalk` journey interleaves with those conversations " + f"(default {CROSSTALK_APPROVALS})" + ), + ) + p.add_argument( + "--concurrency-everywhere", + action="store_true", + help=( + "run `burst` and `crosstalk` on local cells too. They target the remote credential " + "path, so they are Daytona-only by default." + ), + ) + p.add_argument( + "--concurrency-timeout", + type=float, + default=CONCURRENCY_TURN_TIMEOUT_SECONDS, + help=( + "seconds one concurrent run may take before it is recorded as hung (default " + f"{CONCURRENCY_TURN_TIMEOUT_SECONDS:.0f})" + ), + ) p.add_argument( "--env-file", help=f"credentials file (fallback when the env vars are unset; default {DEFAULT_ENV_FILE})", @@ -2284,12 +3333,58 @@ def main() -> int: "--repo", help="repository the release diff is read from (default: the current directory)", ) + p.add_argument( + "--session-control-results", + help=( + "results.json written by resources/session_control.py. Required when a path rule " + "makes that standalone driver mandatory; all of its cells must be recorded." + ), + ) args = p.parse_args() resolve_credentials(args.env_file) global REQUIRE_STORE, STORE_SETTLE_SECONDS, COLD2_REPLACE_CMD, OWNER_TTL_SECONDS global PARK_IDLE_TTL_SECONDS, PARK_MARGIN_SECONDS + # A count of zero would make a concurrency journey pass on nothing, which is the one result + # this class of check must never produce. Stop before spending a single run. The two + # crosstalk halves may each be zero, because either half alone is a valid narrower run, but + # not both. + if args.burst_size < 1: + raise SystemExit(f"--burst-size must be at least 1 (got {args.burst_size}).") + # A cap, because every concurrent run holds a sandbox worth about 5 GiB of the Daytona + # organization's disk quota, and a parked sandbox keeps counting until it is deleted. A typo + # here bills real capacity and can take the whole organization down for everyone else. + # The cap is on what runs AT ONCE, so crosstalk counts against its TOTAL: 20 conversations + # and 20 approvals is 40 sandboxes however the two flags are spelled. + capped = ( + ("--burst-size", args.burst_size), + ( + "--crosstalk-conversations plus --crosstalk-approvals", + args.crosstalk_conversations + args.crosstalk_approvals, + ), + ) + for flag, value in capped: + if value > CONCURRENCY_MAX_JOBS: + raise SystemExit( + f"{flag} is capped at {CONCURRENCY_MAX_JOBS} concurrent runs (got {value}). Each " + "run holds its own sandbox, about 5 GiB of the Daytona organization's disk, and a " + "parked sandbox keeps counting until its auto-delete window closes. Run the cell " + "twice instead of asking for more at once." + ) + if min(args.crosstalk_conversations, args.crosstalk_approvals) < 0: + raise SystemExit( + "--crosstalk-conversations and --crosstalk-approvals cannot be negative." + ) + if args.crosstalk_conversations + args.crosstalk_approvals < 1: + raise SystemExit( + "crosstalk needs at least one conversation or one approval; both counts are 0." + ) + BURST_SIZE = args.burst_size + CROSSTALK_CONVERSATIONS = args.crosstalk_conversations + CROSSTALK_APPROVALS = args.crosstalk_approvals + CONCURRENCY_EVERYWHERE = args.concurrency_everywhere + CONCURRENCY_TURN_TIMEOUT_SECONDS = args.concurrency_timeout REQUIRE_STORE = args.require_store STORE_SETTLE_SECONDS = args.store_settle COLD2_REPLACE_CMD = args.cold2_replace_cmd @@ -2307,18 +3402,51 @@ def main() -> int: # fact, and so a rule naming a cell nobody has written yet fails immediately instead of # spending the whole matrix first. triggered: dict = {} + triggered_journeys: dict = {} if args.release_base or args.changed_path: paths = list(args.changed_path or []) if args.release_base: repo = pathlib.Path(args.repo) if args.repo else None paths += changed_paths(args.release_base, repo=repo) triggered = mandatory_cells(paths) + triggered_journeys = mandatory_journeys(paths) + # A rule can demand a JOURNEY as well as a cell, and that demand outranks --only. Selecting + # the right cell and then running `--only chat` on it is not coverage; it is a green run of + # something else. The forced journeys are appended, so an explicit --only still runs too. + unknown_journeys = [j for j in triggered_journeys if j not in JOURNEYS] + if unknown_journeys: + raise SystemExit( + "A path rule makes these journeys mandatory, but they do not exist in " + f"{HERE / 'qa_product.py'}: {', '.join(sorted(unknown_journeys))}. Write the " + "journey, or change the rule in path_triggers.py that demands it." + ) + forced_journeys = [j for j in triggered_journeys if j not in journeys] + if forced_journeys: + journeys = journeys + forced_journeys + print( + "Path-scoped rules ADD these journeys to this run, overriding --only: " + + ", ".join(forced_journeys) + ) + for journey in forced_journeys: + for path in triggered_journeys[journey]: + print(f" {journey}, because this release changed {path}") + print() missing_cells = [ cell for cell in triggered if cell not in CELLS and not (HERE / cell).exists() ] external_cells = [ cell for cell in triggered if cell not in CELLS and cell not in missing_cells ] + session_control_result = None + if "session_control.py" in external_cells: + if not args.session_control_results: + raise SystemExit( + "This release makes session_control.py mandatory. Run it separately, then pass " + "its results.json with --session-control-results." + ) + session_control_result = _load_session_control_result( + args.session_control_results + ) for cell in triggered: if cell in CELLS and cell not in cells: cells.append(cell) @@ -2331,7 +3459,11 @@ def main() -> int: else ( "MISSING — no such cell exists" if cell in missing_cells - else "run it separately" + else ( + _session_control_result_label(session_control_result) + if cell == "session_control.py" and session_control_result + else "run it separately" + ) ) ) print(f" {cell} ({where})") @@ -2398,7 +3530,11 @@ def main() -> int: results[cid]["journeys"][jname] = r verdict = "SKIP" if r.get("skip") else ("PASS" if r.get("pass") else "FAIL") print(verdict, f"— {r.get('why', '')[:90]}") - (outdir / "results.json").write_text(json.dumps(results, indent=2)) + # Redact at the boundary, never only at the source: a journey's own fields are + # already masked, but the turn summaries it embeds are copied straight off the wire. + (outdir / "results.json").write_text( + json.dumps(redact_tree(results), indent=2) + ) lines = ["| cell | harness | sandbox | model | " + " | ".join(journeys) + " |"] lines.append("|" + "---|" * (4 + len(journeys))) @@ -2426,13 +3562,33 @@ def main() -> int: table += "\n\nMandatory for this release, by path rule:\n\n" table += "| cell | run here | because this release changed |\n|---|---|---|\n" for cell, why in triggered.items(): - here = "yes" if cell in CELLS else "no — run it separately" + if cell in CELLS: + here = "yes" + elif cell == "session_control.py" and session_control_result: + here = _session_control_result_label(session_control_result) + else: + here = "no — run it separately" table += f"| {cell} | {here} | {', '.join(why)} |\n" - if external_cells: + unrecorded_external_cells = [ + cell + for cell in external_cells + if not (cell == "session_control.py" and session_control_result) + ] + if unrecorded_external_cells: table += ( "\nThis release is NOT green until every cell above marked " "`run it separately` has a recorded result.\n" ) + if triggered_journeys: + # Its own file, never a key in mandatory.json: that file is a flat cell -> reasons map + # the seeds and the failure scan walk, and a journey entry in it would break them. + (outdir / "mandatory-journeys.json").write_text( + json.dumps(triggered_journeys, indent=2) + ) + table += "\n\nMandatory journeys for this release, by path rule:\n\n" + table += "| journey | because this release changed |\n|---|---|\n" + for journey, why in triggered_journeys.items(): + table += f"| {journey} | {', '.join(why)} |\n" (outdir / "summary.md").write_text(table + "\n") print("\n" + table) print(f"\nresults: {outdir}") @@ -2443,7 +3599,10 @@ def main() -> int: for cell in results.values() for journey in cell["journeys"].values() ) - return 1 if failed else 0 + standalone_failed = bool( + session_control_result and session_control_result["status"] != "PASS" + ) + return 1 if failed or standalone_failed else 0 if __name__ == "__main__": diff --git a/.agents/skills/agent-release-gate/resources/session_control.py b/.agents/skills/agent-release-gate/resources/session_control.py new file mode 100644 index 00000000000..1042e930c78 --- /dev/null +++ b/.agents/skills/agent-release-gate/resources/session_control.py @@ -0,0 +1,3310 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = ["httpx>=0.27"] +# /// +"""Session-control regression cells for the agent release gate. + +Wire-level scenarios for Stop, durable commands, and the runner's recovery paths. Each cell +drives the same product endpoint the playground drives (`/services/agent/v0/invoke`) and asserts +on the SSE frame stream, the durable records, and the command rows. It never asserts on model +prose. + +Ported from the durable-cancel slice's spike driver +(`~/agenta-qa-evidence/2026-09-03-session-round2/integration-refresh/refresh_live.py`), with four +changes made so this file can live in the repo and run as a standing check instead of a one-box +artifact: + +1. Reads the SAME env contract as `qa_product.py` (`AGENTA_BASE`), plus `AGENTA_ADMIN_KEY` and + `QA_OPENAI_API_KEY`, which this driver needs to mint its own ephemeral account and stock the + vault. No env-file fallback: a fallback file is how a green run gets recorded against the + wrong deployment. +2. The Docker- and Postgres-only helpers sit behind one `OperatorHooks` interface + (`DockerComposeHooks` / `NullHooks`). Six cells need no shell at all and run against any + deployment; the rest need `--project ` and SKIP with a named reason + when it is absent. +3. Emits the gate's result shape: PASS / FAIL / SKIP per cell with a one-line reason, plus + `results.json` and `summary.md` in a timestamped run folder under `~/agenta-qa-evidence/` + (override with `AGENTA_QA_RUNS_DIR`). +4. `--cells` is resumable: pass `--resume ` and any cell already + recorded there is loaded instead of re-run, so a lost agent costs one cell, not the whole run. + + uv run resources/session_control.py --cells all --harness pi_core --sandbox local + +See `SKILL.md` for when these cells are mandatory and where the model keys live. +""" + +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import re +import subprocess +import sys +import threading +import time +import uuid + +import httpx + +REQUIRED_ENV = ("AGENTA_BASE", "AGENTA_ADMIN_KEY", "QA_OPENAI_API_KEY") + +# Resolved by resolve_env() before anything runs. Left empty so --help works with no env set. +BASE = "" +ADMIN_KEY = "" +OPENAI_KEY = "" +# Only required when --harness claude is selected; checked in bootstrap(), not resolve_env(), +# so a pi_core/codex-only run never needs it set. +ANTHROPIC_KEY = "" + +# Set in main() from --sandbox: Daytona sandboxes take 10 to 20s to start, on top of whatever a +# local sandbox needs, so every wait that assumes "local" gets this much extra slack. +SANDBOX_STARTUP_SLACK_S = 0.0 + +# Set in main() from --client-shape. "full" (default) replays the whole transcript on every +# send, like this driver always has, so existing results stay comparable. "last-message" +# reshapes every outbound `messages` list the way the desktop client does — see +# _client_shape_messages() below. +CLIENT_SHAPE = "full" + +# Set in main() from --durable-stop. In auto mode, the first recognized cancel response fixes +# the effective state for the run: the durable route returns command + execution metadata, while +# the production-default legacy route returns its older cancellation summary. +DURABLE_STOP_OPTION = "auto" +DURABLE_STOP_STATE: str | None = None + +RUNS = pathlib.Path( + os.environ.get( + "AGENTA_QA_RUNS_DIR", str(pathlib.Path.home() / "agenta-qa-evidence") + ) +).expanduser() + +STATE: dict = {} +RECALL = "What was the codeword I gave you? Reply with just the codeword." + + +def resolve_env() -> None: + """Populate BASE/ADMIN_KEY/OPENAI_KEY from the environment only. + + No env-file fallback on purpose: qa-audit-2026-09-03.md section 4 names the file fallback as + the mechanism that recorded a green run against the wrong deployment. Every missing variable + is named so a Sonnet QA agent does not have to guess. + """ + global BASE, ADMIN_KEY, OPENAI_KEY + missing = [name for name in REQUIRED_ENV if not os.environ.get(name)] + if missing: + raise SystemExit( + "Missing environment variables: " + ", ".join(missing) + ".\n" + "Set them, e.g.\n" + " export AGENTA_BASE=https://your-stack.example.com\n" + " export AGENTA_ADMIN_KEY=... # ~/.agenta-qa-secrets.env\n" + " export QA_OPENAI_API_KEY=... # ~/.agenta-qa-openai.env\n" + "There is no env-file fallback: a fallback file is how a green run gets recorded " + "against the wrong deployment." + ) + BASE = os.environ["AGENTA_BASE"] + ADMIN_KEY = os.environ["AGENTA_ADMIN_KEY"] + OPENAI_KEY = os.environ["QA_OPENAI_API_KEY"] + global ANTHROPIC_KEY + ANTHROPIC_KEY = os.environ.get("ANTHROPIC_API_KEY", "") + + +# --------------------------------------------------------------------------- # +# Operator hooks: the only place this file talks to Docker or Postgres. +# --------------------------------------------------------------------------- # + + +class HooksUnavailable(Exception): + """Raised by a NullHooks method. Caught at the cell boundary and turned into a SKIP.""" + + +class WrongSandboxTarget(Exception): + """The sandbox-gone cell could not map the tested session to exactly one sandbox-agent + daemon it is safe to kill. Raised INSTEAD of killing a guess. Two sessions can share one + mount key, and the keep-alive pool keeps other sessions' parked daemons alive in the same + runner container, so a blind `ps | grep sandbox-agent` kill hits the wrong process. The cell + turns this into a `wrong target` failure rather than a false negative against the product.""" + + +# A local sandbox id from the turn ledger is `local/:`; a Daytona id is `daytona/`. +_LOCAL_SANDBOX_ID_RE = re.compile(r"^local/[^:\s]+:(\d+)$") + + +# The runner log line that names the port a session's local sandbox daemon bound to, e.g. +# `[sandbox-agent] [timing] stage=prepare_workspace ms=0 sandbox=local/127.0.0.1:44831 session=`. +def _prepare_workspace_port_re(session_id: str) -> re.Pattern[str]: + return re.compile( + r"stage=prepare_workspace\b.*\bsandbox=local/[^:\s]+:(\d+)\b.*\bsession=" + + re.escape(session_id) + ) + + +def _parse_local_sandbox_port(sandbox_id: str | None) -> int | None: + """The port from a local ledger sandbox id, or None for a Daytona/empty/foreign id.""" + if not sandbox_id: + return None + m = _LOCAL_SANDBOX_ID_RE.match(sandbox_id.strip()) + return int(m.group(1)) if m else None + + +def _parse_ss_listener_pid(ss_output: str, port: int) -> str | None: + """The owning pid of the LISTEN socket on `port`, parsed from `ss -ltnHp` output. + + A line looks like: + LISTEN 0 511 127.0.0.1:44831 0.0.0.0:* users:(("node",pid=2170,fd=23)) + The peer column on a listener is always `0.0.0.0:*`/`[::]:*`, so an exact `:` field + match cannot collide with the peer, and `rsplit` guards against a substring port match.""" + for line in ss_output.splitlines(): + fields = line.split() + if not any(f.rsplit(":", 1)[-1] == str(port) for f in fields if ":" in f): + continue + m = re.search(r"\bpid=(\d+)", line) + if m: + return m.group(1) + return None + + +# Fallback for a container without `ss`: read the LISTEN socket's inode from /proc/net/tcp{,6}, +# then find the pid whose fd points at that socket. `$1` is the decimal port. +_PROC_PID_ON_PORT_SH = r""" +port="$1" +hp=$(printf '%04X' "$port" 2>/dev/null) || exit 0 +inode=$(awk -v hp="$hp" 'NR>1 && $4=="0A" { split($2,a,":"); if (a[2]==hp) { print $10; exit } }' /proc/net/tcp /proc/net/tcp6 2>/dev/null) +[ -z "$inode" ] && exit 0 +for fd in /proc/[0-9]*/fd/*; do + link=$(readlink "$fd" 2>/dev/null) || continue + if [ "$link" = "socket:[$inode]" ]; then + echo "$fd" | awk -F/ '{print $3}' + exit 0 + fi +done +""" + + +class OperatorHooks: + """Interface the cells call through. `available` gates whether shell-only cells can run.""" + + available = False + + def dc(self, *args: str, timeout: float = 60.0) -> str: + raise HooksUnavailable + + def psql(self, db: str, sql: str) -> list[list[str]]: + raise HooksUnavailable + + def runner_log(self, since: float) -> list[str]: + raise HooksUnavailable + + def sandbox_procs(self, marker: str, sandbox_id: str | None = None) -> list[dict]: + raise HooksUnavailable + + def stream_row(self, session_id: str) -> dict: + raise HooksUnavailable + + def record_rows(self, session_id: str) -> list[dict]: + raise HooksUnavailable + + def command_rows(self, session_id: str) -> list[dict]: + raise HooksUnavailable + + def execution_rows(self, session_id: str) -> list[dict]: + raise HooksUnavailable + + def wait_for_runner(self, *, timeout: float = 120.0) -> float | None: + raise HooksUnavailable + + def runner_healthy(self) -> bool: + raise HooksUnavailable + + def ensure_runner_healthy(self, *, timeout: float = 120.0) -> dict: + raise HooksUnavailable + + def restart_runner(self, grace_seconds: int = 10) -> None: + raise HooksUnavailable + + def kill_runner(self) -> None: + raise HooksUnavailable + + def pause_runner(self) -> None: + raise HooksUnavailable + + def unpause_runner(self) -> None: + raise HooksUnavailable + + def stop_postgres(self) -> None: + raise HooksUnavailable + + def start_postgres(self) -> None: + raise HooksUnavailable + + def kill_sandbox(self, sandbox_id: str | None = None) -> list[str]: + raise HooksUnavailable + + def kill_sandbox_for_session( + self, + session_id: str | None = None, + sandbox_id: str | None = None, + since: float | None = None, + ) -> dict: + raise HooksUnavailable + + def wait_for_sandbox_ready( + self, + session_id: str | None = None, + ledger_id_getter=None, + since: float | None = None, + timeout: float | None = None, + poll_interval: float | None = None, + clock=time, + ): + raise HooksUnavailable + + +class NullHooks(OperatorHooks): + """No `--project` was given. Every method raises; cells that need it SKIP with a reason.""" + + available = False + + +class DockerComposeHooks(OperatorHooks): + """The original refresh_live.py helpers, ported behind the OperatorHooks interface.""" + + available = True + + def __init__(self, project: str) -> None: + self.project = project + + def dc(self, *args: str, timeout: float = 60.0) -> str: + try: + out = subprocess.run( + ["docker", *args], capture_output=True, text=True, timeout=timeout + ) + return out.stdout + except Exception as exc: # noqa: BLE001 + return f"" + + def psql(self, db: str, sql: str) -> list[list[str]]: + raw = self.dc( + "exec", + f"{self.project}-postgres-1", + "psql", + "-U", + "username", + "-d", + db, + "-At", + "-F", + "|", + "-c", + sql, + ) + return [line.split("|") for line in raw.strip().splitlines() if line.strip()] + + def runner_log(self, since: float) -> list[str]: + stamp = time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(since - 2)) + try: + out = subprocess.run( + ["docker", "logs", "-t", "--since", stamp, f"{self.project}-runner-1"], + capture_output=True, + text=True, + timeout=90, + ) + return (out.stdout + out.stderr).splitlines() + except Exception as exc: # noqa: BLE001 + return [f""] + + def sandbox_procs(self, marker: str, sandbox_id: str | None = None) -> list[dict]: + # A local sandbox IS a subprocess of the runner container, so `ps` inside the runner + # sees it regardless of which session owns it. `sandbox_id` is accepted for interface + # parity with the Daytona-aware hook (which needs it to pick a remote sandbox) and + # ignored here. + raw = self.dc( + "exec", f"{self.project}-runner-1", "ps", "-eo", "pid,ppid,etimes,args" + ) + hits = [] + for line in raw.splitlines()[1:]: + parts = line.split(None, 3) + if len(parts) < 4 or marker not in parts[3]: + continue + if "ps -eo" in parts[3] or parts[3].startswith("grep"): + continue + hits.append( + { + "pid": parts[0], + "ppid": parts[1], + "etimes": parts[2], + "args": parts[3][:120], + } + ) + return hits + + def stream_row(self, session_id: str) -> dict: + rows = self.psql( + "agenta_ee_core", + "select turn_id, coalesce(flags::text,'{}'), coalesce(stopping_turn_id,'') " + f"from session_streams where session_id = '{session_id}'", + ) + if not rows: + return {} + turn, flags, stopping = rows[0] + try: + flags_obj = json.loads(flags) + except Exception: # noqa: BLE001 + flags_obj = {"raw": flags} + return { + "turn_id": turn, + "flags": flags_obj, + "stopping_turn_id": stopping or None, + "read_at": time.time(), + } + + def record_rows(self, session_id: str) -> list[dict]: + rows = self.psql( + "agenta_ee_tracing", + "select coalesce(turn_id,''), record_type, " + "coalesce(to_char(created_at,'HH24:MI:SS.MS'),''), " + "case when quarantined_at is null then '' " + "else to_char(quarantined_at,'HH24:MI:SS.MS') end " + f"from records where session_id = '{session_id}' order by created_at", + ) + return [ + { + "turn_id": r[0], + "type": r[1], + "created_at": r[2], + "quarantined_at": r[3] or None, + } + for r in rows + if len(r) >= 4 + ] + + def command_rows(self, session_id: str) -> list[dict]: + rows = self.psql( + "agenta_ee_core", + "select id::text, state, coalesce(outcome,''), claim_count, " + "coalesce(target_turn_id,'') from session_commands " + f"where session_id = '{session_id}' order by created_at", + ) + return [ + { + "id": r[0], + "state": r[1], + "outcome": r[2] or None, + "claim_count": r[3], + "target_turn_id": r[4] or None, + } + for r in rows + if len(r) >= 5 + ] + + def execution_rows(self, session_id: str) -> list[dict]: + rows = self.psql( + "agenta_ee_core", + "select execution_id, terminal_outcome, coalesce(settled_by,''), " + "coalesce(to_char(settled_at,'HH24:MI:SS.MS'),'') from session_executions " + f"where session_id = '{session_id}' order by settled_at", + ) + return [ + { + "execution_id": r[0], + "terminal_outcome": r[1] or None, + "settled_by": r[2] or None, + "settled_at": r[3] or None, + } + for r in rows + if len(r) >= 4 + ] + + def wait_for_runner(self, *, timeout: float = 120.0) -> float | None: + started = time.time() + while time.time() - started < timeout: + if self.runner_healthy(): + return round(time.time() - started, 1) + time.sleep(1) + return None + + def runner_healthy(self) -> bool: + """One health check: the runner container's Docker health status reads `healthy`.""" + state = self.dc( + "inspect", "-f", "{{.State.Health.Status}}", f"{self.project}-runner-1" + ).strip() + return state == "healthy" + + def ensure_runner_healthy(self, *, timeout: float = 120.0) -> dict: + """Recover the runner container to running and healthy, whatever a cell left it in. + + `run_cell()` calls this in a `finally` block after every cell that needs hooks, so a + cell that pauses, stops, or restarts the runner and then raises before its own restore + code runs does not strand the runner paused or down for the next cell. + """ + paused = ( + self.dc( + "inspect", "-f", "{{.State.Paused}}", f"{self.project}-runner-1" + ).strip() + == "true" + ) + if paused: + self.unpause_runner() + status = self.dc( + "inspect", "-f", "{{.State.Status}}", f"{self.project}-runner-1" + ).strip() + if status != "running": + self.restart_runner() + healthy_after_s = self.wait_for_runner(timeout=timeout) + return { + "was_paused": paused, + "status_before": status, + "healthy_after_s": healthy_after_s, + } + + def restart_runner(self, grace_seconds: int = 10) -> None: + self.dc( + "restart", "-t", str(grace_seconds), f"{self.project}-runner-1", timeout=120 + ) + + def kill_runner(self) -> None: + self.dc("restart", "-t", "0", f"{self.project}-runner-1", timeout=60) + + def pause_runner(self) -> None: + self.dc("pause", f"{self.project}-runner-1") + + def unpause_runner(self) -> None: + self.dc("unpause", f"{self.project}-runner-1") + + def stop_postgres(self) -> None: + self.dc("stop", f"{self.project}-postgres-1") + + def start_postgres(self) -> None: + self.dc("start", f"{self.project}-postgres-1") + + def kill_sandbox(self, sandbox_id: str | None = None) -> list[str]: + # A local sandbox is a subprocess of the runner container: there is only ever one + # `sandbox-agent server` process family running there per cell, so `sandbox_id` (accepted + # for interface parity with the Daytona-aware hook) is not needed to target it. + ps = self.dc( + "exec", + f"{self.project}-runner-1", + "sh", + "-c", + 'ps -eo pid,args | grep "[s]andbox-agent server"', + ) + pids = [line.split()[0] for line in ps.strip().splitlines() if line.strip()] + for pid in pids: + self.dc( + "exec", + f"{self.project}-runner-1", + "sh", + "-c", + f"kill -9 -{pid} || kill -9 {pid}", + ) + return pids + + def local_sandbox_port( + self, + session_id: str, + sandbox_id: str | None = None, + since: float | None = None, + ) -> int | None: + """The TCP port THIS session's own local sandbox daemon bound to. + + The source of truth is the runner log's `prepare_workspace` line for this exact session + id; the turn ledger's `local/:` sandbox id is a fallback and a cross-check. + Two sessions can share one mount key, so a global `ps | grep` cannot tell them apart — + this is per-session by construction. When both sources disagree the target is ambiguous + and this refuses (raises), rather than guessing which daemon to kill.""" + log_port = None + pat = _prepare_workspace_port_re(session_id) + for line in self.runner_log(since if since is not None else time.time() - 600): + m = pat.search(line) + if m: + log_port = int( + m.group(1) + ) # last match wins: a rebuild uses a fresh port + ledger_port = _parse_local_sandbox_port(sandbox_id) + if log_port is not None and ledger_port is not None and log_port != ledger_port: + raise WrongSandboxTarget( + f"the runner log names port {log_port} for session {session_id} but the turn " + f"ledger names {ledger_port}; refusing to kill an ambiguous target" + ) + return log_port if log_port is not None else ledger_port + + def pid_listening_on_port(self, port: int) -> str | None: + """The pid of the process listening on `port` inside the runner container. + + Prefers `ss -ltnHp` (the pid is inline); falls back to reading the socket inode from + /proc/net/tcp and matching it against /proc/*/fd when the image ships no `ss`.""" + ss_out = self.dc( + "exec", + f"{self.project}-runner-1", + "sh", + "-c", + "ss -ltnHp 2>/dev/null || true", + ) + pid = _parse_ss_listener_pid(ss_out, port) + if pid: + return pid + proc_out = self.dc( + "exec", + f"{self.project}-runner-1", + "sh", + "-c", + _PROC_PID_ON_PORT_SH, + "pid-on-port", + str(port), + ) + proc_out = proc_out.strip() + return proc_out or None + + def process_cmdline(self, pid: str) -> str: + """The argv of `pid` inside the runner container, space-joined (nul-separated on disk).""" + return self.dc( + "exec", + f"{self.project}-runner-1", + "sh", + "-c", + f"tr '\\0' ' ' < /proc/{pid}/cmdline 2>/dev/null", + ).strip() + + def kill_sandbox_for_session( + self, + session_id: str | None = None, + sandbox_id: str | None = None, + since: float | None = None, + ) -> dict: + """Kill ONLY the local sandbox daemon that belongs to `session_id`. + + Maps the session to its own port, resolves the listening pid, and asserts the pid is a + sandbox-agent daemon before killing it. Any gap in that chain raises `WrongSandboxTarget` + so the cell fails as `wrong target` instead of killing an unrelated session's parked + sandbox (the historical false negative). Returns the port, pid, cmdline, and killed pids + as evidence.""" + if not session_id: + raise WrongSandboxTarget("no session id given; refusing to kill a guess") + port = self.local_sandbox_port(session_id, sandbox_id=sandbox_id, since=since) + if port is None: + raise WrongSandboxTarget( + f"could not find this session's local sandbox port for {session_id} in the " + f"runner log or the turn ledger (ledger id={sandbox_id!r}); refusing to kill a guess" + ) + pid = self.pid_listening_on_port(port) + if not pid: + raise WrongSandboxTarget( + f"nothing is listening on port {port} inside the runner container for session " + f"{session_id}; the named sandbox is not here — refusing to kill a guess" + ) + cmdline = self.process_cmdline(pid) + if "sandbox-agent" not in cmdline: + raise WrongSandboxTarget( + f"pid {pid} on port {port} is not a sandbox-agent daemon " + f"(cmdline={cmdline[:120]!r}); refusing to kill it" + ) + self.dc( + "exec", + f"{self.project}-runner-1", + "sh", + "-c", + f"kill -9 -{pid} || kill -9 {pid}", + ) + return { + "port": port, + "pid": pid, + "cmdline": cmdline[:200], + "killed": [pid], + } + + def wait_for_local_sandbox_port( + self, + session_id: str, + ledger_id_getter=None, + since: float | None = None, + timeout: float | None = None, + poll_interval: float | None = None, + clock=time, + ) -> int: + """Poll until THIS session's local sandbox port is resolvable, or refuse on timeout. + + A cold acquire writes the `prepare_workspace` line ~35 s after the turn starts, so reading + once right after the turn began finds nothing and the cell refused correctly but uselessly. + Poll the runner log (and the turn ledger via `ledger_id_getter`) until the port appears. + A transient log/ledger disagreement during acquire is retried, not fatal; only its + persistence to the deadline raises. When nothing appears within `timeout`, raise + WrongSandboxTarget so the cell fails as `wrong target` rather than killing a guess.""" + timeout = SANDBOX_GONE_RESOLVE_TIMEOUT_S if timeout is None else timeout + poll_interval = ( + SANDBOX_GONE_RESOLVE_POLL_S if poll_interval is None else poll_interval + ) + getter = ledger_id_getter or (lambda: None) + deadline = clock.time() + timeout + last_error: WrongSandboxTarget | None = None + while True: + try: + ledger_id = getter() + except Exception: # noqa: BLE001 + ledger_id = None + try: + port = self.local_sandbox_port( + session_id, sandbox_id=ledger_id, since=since + ) + except WrongSandboxTarget as exc: + # A log/ledger disagreement mid-acquire is usually transient; keep polling and + # let it raise only if it is still the state at the deadline. + last_error = exc + port = None + if port is not None: + return port + if clock.time() >= deadline: + if last_error is not None: + raise last_error + raise WrongSandboxTarget( + f"this session's prepare_workspace line never appeared in the runner log " + f"(and no local ledger sandbox id) within {timeout:.0f}s for session " + f"{session_id}; refusing to kill a guess" + ) + clock.sleep(poll_interval) + + def wait_for_sandbox_ready( + self, + session_id: str | None = None, + ledger_id_getter=None, + since: float | None = None, + timeout: float | None = None, + poll_interval: float | None = None, + clock=time, + ): + """Local: block until this session's own sandbox port is resolvable (returns the port).""" + return self.wait_for_local_sandbox_port( + session_id, + ledger_id_getter=ledger_id_getter, + since=since, + timeout=timeout, + poll_interval=poll_interval, + clock=clock, + ) + + +class DaytonaAwareHooks(DockerComposeHooks): + """`DockerComposeHooks` plus a Daytona-provider-aware `kill_sandbox` and `sandbox_procs`. + + A local sandbox is a subprocess of the runner container, so the base class's `docker exec ps` + sees it. A Daytona sandbox is a remote machine: `docker exec` into the runner container never + sees the sandbox's process table, and killing a local process cannot end a remote sandbox. So + for `--sandbox daytona` this hook ends the sandbox and lists its processes through the same + Daytona REST API the runner itself uses (`services/runner/src/engines/sandbox_agent/ + daytona-provider.ts`'s `sandbox.delete()`, and the vendored `sandbox-agent/daytona` provider's + `runProcess`, which `reap-exec.ts` drives with the identical `ps -eo pid=,ppid=,etimes=,args=` + used below). + + Every call is scoped to the ONE sandbox id the cell observed for its own session + (`sandbox_ids(session_id)` in the driver, threaded in by the caller) — never a list, never a + wildcard. Credentials come from `AGENTA_RUNNER_DAYTONA_API_KEY` / `AGENTA_RUNNER_DAYTONA_API_URL` + (export only; never logged, never put in an exception message). + """ + + def __init__(self, project: str) -> None: + super().__init__(project) + missing = [ + name + for name in ( + "AGENTA_RUNNER_DAYTONA_API_KEY", + "AGENTA_RUNNER_DAYTONA_API_URL", + ) + if not os.environ.get(name) + ] + if missing: + raise SystemExit( + "--sandbox daytona needs " + ", ".join(missing) + " exported (from the " + "integration env file's AGENTA_RUNNER_DAYTONA_* block) so sandbox-gone and " + "codex-child can reach the Daytona API directly." + ) + self._daytona_api_url = os.environ["AGENTA_RUNNER_DAYTONA_API_URL"].rstrip("/") + self._daytona_api_key = os.environ["AGENTA_RUNNER_DAYTONA_API_KEY"] + + @staticmethod + def _bare_id(sandbox_id: str) -> str: + """`sandbox_ids()` returns ids like `daytona/`; the Daytona API wants the bare uuid.""" + return sandbox_id.split("/", 1)[1] if "/" in sandbox_id else sandbox_id + + def _daytona_get(self, path: str) -> httpx.Response: + return httpx.get( + f"{self._daytona_api_url}{path}", + headers={"Authorization": f"Bearer {self._daytona_api_key}"}, + timeout=30.0, + ) + + def _daytona_delete(self, path: str) -> httpx.Response: + return httpx.delete( + f"{self._daytona_api_url}{path}", + headers={"Authorization": f"Bearer {self._daytona_api_key}"}, + timeout=30.0, + ) + + def kill_sandbox(self, sandbox_id: str | None = None) -> list[str]: + if not sandbox_id: + return [] + bare = self._bare_id(sandbox_id) + try: + resp = self._daytona_delete(f"/sandbox/{bare}") + except Exception as exc: # noqa: BLE001 + print( + f"[daytona] delete sandbox={bare} failed: {exc}", + file=sys.stderr, + ) + return [] + # DELETE /sandbox/{id} is what `sandbox.delete()` calls on this same SDK/API version + # (Sandbox.js -> SandboxApi.deleteSandbox); a 404 means it is already gone, also success + # for "the sandbox is gone" purposes. + if resp.status_code not in (200, 202, 204, 404): + print( + f"[daytona] delete sandbox={bare} returned {resp.status_code}: " + f"{resp.text[:200]}", + file=sys.stderr, + ) + return [] + return [bare] + + def kill_sandbox_for_session( + self, + session_id: str | None = None, + sandbox_id: str | None = None, + since: float | None = None, + ) -> dict: + """Delete THIS session's remote sandbox by the one id its turn ledger observed. + + A Daytona sandbox is a remote machine, addressed by its own uuid, so this path is already + per-session targeted and never had the shared-runner ambiguity the local path did. An + absent id means there is nothing to end — that is a `wrong target` refusal, not a kill.""" + if not sandbox_id: + raise WrongSandboxTarget( + f"no sandbox id observed for session {session_id}; nothing to end" + ) + killed = self.kill_sandbox(sandbox_id=sandbox_id) + if not killed: + raise WrongSandboxTarget( + f"the Daytona delete for sandbox {sandbox_id} did not confirm; refusing to " + "claim a kill that did not land" + ) + return { + "port": None, + "pid": None, + "cmdline": None, + "killed": killed, + } + + def wait_for_sandbox_ready( + self, + session_id: str | None = None, + ledger_id_getter=None, + since: float | None = None, + timeout: float | None = None, + poll_interval: float | None = None, + clock=time, + ): + """Daytona: block until this session's remote sandbox id is observed (returns the id). + + A remote sandbox is even slower to appear than a local one, so the same poll applies; the + target here is the ledger id, not a port. Refuse on timeout rather than deleting a guess.""" + timeout = SANDBOX_GONE_RESOLVE_TIMEOUT_S if timeout is None else timeout + poll_interval = ( + SANDBOX_GONE_RESOLVE_POLL_S if poll_interval is None else poll_interval + ) + getter = ledger_id_getter or (lambda: None) + deadline = clock.time() + timeout + while True: + try: + sandbox_id = getter() + except Exception: # noqa: BLE001 + sandbox_id = None + if sandbox_id: + return sandbox_id + if clock.time() >= deadline: + raise WrongSandboxTarget( + f"no Daytona sandbox id was observed for session {session_id} within " + f"{timeout:.0f}s; refusing to kill a guess" + ) + clock.sleep(poll_interval) + + def sandbox_procs(self, marker: str, sandbox_id: str | None = None) -> list[dict]: + if not sandbox_id: + return [] + bare = self._bare_id(sandbox_id) + try: + proxy = self._daytona_get(f"/sandbox/{bare}/toolbox-proxy-url") + if proxy.status_code != 200: + print( + f"[daytona] toolbox-proxy-url sandbox={bare} returned " + f"{proxy.status_code}: {proxy.text[:200]}", + file=sys.stderr, + ) + return [] + proxy_url = (proxy.json() or {}).get("url") + if not proxy_url: + print( + f"[daytona] toolbox-proxy-url sandbox={bare} returned no url", + file=sys.stderr, + ) + return [] + # Same shape as `reap-exec.ts`'s `PS_ARGS` (`-eo pid=,ppid=,etimes=,args=`): the `=` + # suffixes drop the header line, so every returned line is a data row. + exec_resp = httpx.post( + f"{proxy_url.rstrip('/')}/process/execute", + json={"command": "ps -eo pid=,ppid=,etimes=,args=", "timeout": 10}, + timeout=20.0, + ) + except Exception as exc: # noqa: BLE001 + print( + f"[daytona] process listing sandbox={bare} failed: {exc}", + file=sys.stderr, + ) + return [] + if exec_resp.status_code != 200: + print( + f"[daytona] process/execute sandbox={bare} returned " + f"{exec_resp.status_code}: {exec_resp.text[:200]}", + file=sys.stderr, + ) + return [] + raw = (exec_resp.json() or {}).get("result", "") or "" + hits = [] + for line in raw.splitlines(): + parts = line.split(None, 3) + if len(parts) < 4 or marker not in parts[3]: + continue + if "ps -eo" in parts[3] or parts[3].startswith("grep"): + continue + hits.append( + { + "pid": parts[0], + "ppid": parts[1], + "etimes": parts[2], + "args": parts[3][:120], + } + ) + return hits + + +def select_hooks(project: str | None, sandbox: str) -> OperatorHooks: + """The provider switch: no `--project` is NullHooks regardless of `--sandbox`; with a + project, `--sandbox daytona` needs the Daytona-aware hook (docker exec cannot see or touch a + remote sandbox), everything else gets the plain docker-compose hook. Pulled out of `main()` so + it is unit-testable without a live stack. + """ + if not project: + return NullHooks() + if sandbox == "daytona": + return DaytonaAwareHooks(project) + return DockerComposeHooks(project) + + +# --------------------------------------------------------------------------- # +# HTTP plumbing (unchanged from refresh_live.py, keyed off the resolved env) +# --------------------------------------------------------------------------- # + +HARNESSES = { + "pi_core": { + "kind": "pi_core", + "model": "gpt-5.6-luna", + "provider": "openai", + "connection": {"mode": "agenta", "slug": None}, + }, + "codex": { + "kind": "codex", + "model": "gpt-5.6-luna", + "provider": "openai", + "connection": {"mode": "agenta", "slug": None}, + }, + "claude": { + # `sonnet` alias, not a full model id: a full id is dropped to the default on the Claude + # ACP path (qa_product.py F-007). VAULT key (mode "agenta"), not subscription: this + # driver's cells run on Daytona too, and Daytona rejects subscription auth by design. + "kind": "claude", + "model": "sonnet", + "provider": "anthropic", + "connection": {"mode": "agenta", "slug": None}, + }, +} + +# Read timeout for the SSE stream `invoke()` opens, per harness kind (`cfg["harness"]["kind"]`, +# the same key HARNESSES above sets). Pi is a single in-process model loop; Codex and Claude Code +# are agentic CLIs behind an ACP bridge and routinely take longer per turn under load, so their +# budget is about 1.5x Pi's — high enough that a genuinely slow-but-healthy turn does not trip +# the driver's OWN httpx.ReadTimeout and get misread as a product failure (concurrent-stops hit +# exactly this on Claude Code). A cell whose Stop settlement is the actual problem is caught by +# `assert_command_settled` well before this ever fires, at its own fixed 20s budget regardless of +# harness — this table is about not confusing "the driver gave up too early" with "the product is +# broken", not about giving a broken product more rope. +STREAM_TIMEOUT_S = { + "pi_core": 600.0, + "codex": 900.0, + "claude": 900.0, +} +DEFAULT_STREAM_TIMEOUT_S = 600.0 + + +def stream_timeout_s(cfg: dict) -> float: + kind = (cfg.get("harness") or {}).get("kind") + return STREAM_TIMEOUT_S.get(kind, DEFAULT_STREAM_TIMEOUT_S) + + +# The "sandbox-gone" cell runs one slow shell command, kills the tested session's OWN sandbox +# daemon under it, and expects the runner to end the turn with a terminal record. The settle +# budget is derived from the runner's sandbox-liveness probe defaults +# (services/runner/src/engines/sandbox_agent/sandbox-liveness.ts): PROBE_FAILURES consecutive +# probe failures at PROBE_INTERVAL_S each, after which the turn ends with an error record. The +# cell waits that budget plus slack, and never less than the slow command itself — so a healthy +# turn that outlives a mis-targeted kill can never be misread as "still running" before it would +# even have finished. The command duration is a constant the cell prints in its evidence. +SANDBOX_LIVENESS_PROBE_INTERVAL_S = 30.0 +SANDBOX_LIVENESS_PROBE_FAILURES = 3 +SANDBOX_GONE_SETTLE_SLACK_S = 60.0 + +# A cold acquire on the gate stack takes ~35 s before the sandbox's `prepare_workspace` line is +# even written (observed `acquire_total ms=34766`), so the cell must POLL for this session's own +# sandbox to become resolvable rather than reading once right after the turn starts. Poll the +# runner log and the turn ledger for up to this long, then let the slow command run a moment +# before the kill. If the line never appears, refuse (never kill a guess). +SANDBOX_GONE_ACQUIRE_BUDGET_S = 60.0 +SANDBOX_GONE_RESOLVE_TIMEOUT_S = 120.0 +SANDBOX_GONE_RESOLVE_POLL_S = 3.0 +SANDBOX_GONE_RUNNING_SLACK_S = 5.0 + +# The design window the runner needs to end the turn once the sandbox is dead: PROBE_FAILURES +# probes at PROBE_INTERVAL_S each. +_SANDBOX_GONE_DESIGN_WINDOW_S = ( + SANDBOX_LIVENESS_PROBE_INTERVAL_S * SANDBOX_LIVENESS_PROBE_FAILURES +) + +# The slow command must OUTLAST the whole worst case before the kill lands, plus the design +# window, so it is still running when the sandbox dies and a failed kill cannot be misread as a +# healthy completion: acquire budget + the resolve poll window + the probe design window + margin. +SANDBOX_GONE_COMMAND_S = int( + SANDBOX_GONE_ACQUIRE_BUDGET_S + + SANDBOX_GONE_RESOLVE_TIMEOUT_S + + _SANDBOX_GONE_DESIGN_WINDOW_S + + 30 +) + + +def sandbox_gone_settle_budget_s() -> float: + """Seconds to wait for the runner to end the turn after the sandbox is killed: the probe's + three-strikes budget plus slack plus any sandbox-startup slack the run declared.""" + return ( + _SANDBOX_GONE_DESIGN_WINDOW_S + + SANDBOX_GONE_SETTLE_SLACK_S + + SANDBOX_STARTUP_SLACK_S + ) + + +# After the runner-gone-late cell restarts the runner, the recovery Send must not race the +# runner coming back up: a Send issued mid-restart gets "All connection attempts failed" and is +# misread as a product failure. Poll the runner's health until it is back, bounded, then send. +RECOVERY_HEALTH_TIMEOUT_S = 60.0 +RECOVERY_HEALTH_POLL_S = 2.0 + +# The sweep commits the execution outcome before it commits the terminal records. Keep the +# terminal assertion strict, but allow that second transaction to become visible first. +TERMINAL_RECORD_SETTLE_BUDGET_S = 20.0 +TERMINAL_RECORD_SETTLE_POLL_S = 0.5 + + +def _has_watchdog_ending(rows: list) -> bool: + return any( + (row.get("attributes") or {}).get("settled_by") == "watchdog" for row in rows + ) + + +def _require_watchdog_execution_lost(rows: list) -> dict | None: + found = any( + row.get("type") == "error" + and (row.get("attributes") or {}).get("code") == "execution_lost" + and (row.get("attributes") or {}).get("settled_by") == "watchdog" + for row in rows + ) + if found: + return None + return _fail( + "no watchdog execution_lost ending was found among the terminal records" + ) + + +def _poll_terminal_after_settle( + read_terminal, + *, + timeout=TERMINAL_RECORD_SETTLE_BUDGET_S, + poll_interval=TERMINAL_RECORD_SETTLE_POLL_S, + clock=time, +) -> list: + """Wait for the watchdog's terminal-record transaction after durable settlement.""" + deadline = clock.time() + timeout + while True: + rows = read_terminal() + if _has_watchdog_ending(rows) or clock.time() >= deadline: + return rows + clock.sleep(poll_interval) + + +def _recover_then_send(health_poll, send, *, timeout, poll_interval, clock=time): + """Poll `health_poll()` until it returns truthy (bounded by `timeout`), THEN call `send()`. + + `send` runs ONLY once the runner is healthy again, so a recovery Send can never race a runner + that is still restarting. Returns `(healthy, result)`; when health never recovers within the + budget, `send` is not called and `result` is None. The clock is injectable for tests.""" + deadline = clock.time() + timeout + healthy = False + while True: + if health_poll(): + healthy = True + break + if clock.time() >= deadline: + break + clock.sleep(poll_interval) + result = send() if healthy else None + return healthy, result + + +def _measure_runner_gone_while_paused( + hooks, + session_id, + turn, + do_stop, + read_terminal, + *, + sweep_wait, + poll_interval=5.0, + clock=time, +): + """Pause the runner, fire the Stop while it is gone, wait for the sweep to settle it lost, and + read is_running from the stream row WHILE THE RUNNER IS STILL PAUSED. + + The pause is the whole assertion. "Runner gone" must be measured while the runner is still + gone: once it is unpaused it starts a new turn on the same session that legitimately sets + is_running true again, so a read after the unpause sees that new turn and misreads a healthy + recovery as a failure. Settlement is detected on the durable rows — the Stop command reaching + `obsolete`/`applied` with outcome `lost`, or an execution row carrying a terminal outcome — + not on the volatile stream. Unpauses on every path. Returns the paused measurements.""" + stop = None + stop_at = None + settled_at = None + paused_read_at = None + stream_row = None + stop_command = None + commands: list = [] + executions: list = [] + terminal: list = [] + hooks.pause_runner() + try: + stop = do_stop() + stop_at = clock.time() + deadline = clock.time() + sweep_wait + while True: + commands = hooks.command_rows(session_id) + stop_command = _match_stop_command(commands, turn) + executions = hooks.execution_rows(session_id) + command_settled = ( + stop_command is not None + and stop_command.get("state") in ("obsolete", "applied") + and stop_command.get("outcome") == "lost" + ) + execution_lost = any(e.get("terminal_outcome") for e in executions) + if command_settled or execution_lost: + settled_at = clock.time() + break + if clock.time() >= deadline: + break + clock.sleep(poll_interval) + terminal = ( + _poll_terminal_after_settle( + read_terminal, + poll_interval=0.5, + clock=clock, + ) + if settled_at is not None + else read_terminal() + ) + # THE gone-and-stays-gone read: is_running, taken while the runner is still paused. + stream_row = hooks.stream_row(session_id) + paused_read_at = clock.time() + finally: + # Unpause on every path: a paused runner left behind strands every later cell. + hooks.unpause_runner() + return { + "stop": stop, + "stop_at": stop_at, + "settled_at": settled_at, + "paused_read_at": paused_read_at, + "stream_row": stream_row, + "stop_command": stop_command, + "commands": commands, + "executions": executions, + "terminal": terminal, + } + + +def api(method: str, path: str, *, timeout: float = 120.0, **kw) -> httpx.Response: + headers = { + "Authorization": STATE["credentials"], + "Content-Type": "application/json", + **(kw.pop("headers", None) or {}), + } + params = {"project_id": STATE["project_id"], **(kw.pop("params", None) or {})} + return httpx.request( + method, + f"{BASE}/api{path}", + params=params, + headers=headers, + timeout=timeout, + **kw, + ) + + +def bootstrap(harness: str = "pi_core") -> None: + uid = uuid.uuid4().hex[:12] + r = httpx.post( + f"{BASE}/api/admin/simple/accounts/", + headers={"Authorization": f"Access {ADMIN_KEY}"}, + json={ + "accounts": { + "user": { + "user": {"email": f"{uid}@test.agenta.ai"}, + "options": { + "create_api_keys": True, + "return_api_keys": True, + "seed_defaults": False, + }, + } + } + }, + timeout=120.0, + ) + r.raise_for_status() + account = next(iter(r.json()["accounts"].values())) + STATE["credentials"] = f"ApiKey {account['api_keys']['key']}" + STATE["project_id"] = next(iter(account["projects"].values()))["id"] + print(f"[bootstrap] project={STATE['project_id']}", file=sys.stderr) + + r = api( + "POST", + "/vault/v1/secrets/", + json={ + "header": {"name": "OpenAI", "description": "session-control gate"}, + "secret": { + "kind": "provider_key", + "data": {"kind": "openai", "provider": {"key": OPENAI_KEY}}, + }, + }, + ) + if r.status_code != 200: + raise SystemExit(f"vault create HTTP {r.status_code}: {r.text[:400]}") + print("[bootstrap] vault stocked with an openai provider key", file=sys.stderr) + + if harness == "claude": + # The claude harness's vault connection (agent_config mode "agenta") needs a funded + # Anthropic key, the same way the OpenAI key above covers pi_core and codex. Checked + # here, not in resolve_env(), so a pi_core/codex-only run never needs it set. + if not ANTHROPIC_KEY: + raise SystemExit( + "Missing environment variable: ANTHROPIC_API_KEY. Required for --harness " + "claude (the vault connection needs a funded Anthropic key). " + "e.g. export ANTHROPIC_API_KEY=... # ~/.agenta-qa-secrets.env" + ) + r = api( + "POST", + "/vault/v1/secrets/", + json={ + "header": {"name": "Anthropic", "description": "session-control gate"}, + "secret": { + "kind": "provider_key", + "data": {"kind": "anthropic", "provider": {"key": ANTHROPIC_KEY}}, + }, + }, + ) + if r.status_code != 200: + raise SystemExit(f"vault create HTTP {r.status_code}: {r.text[:400]}") + print( + "[bootstrap] vault stocked with an anthropic provider key", file=sys.stderr + ) + + +def agent_config( + harness: str, model: str, provider: str, connection: dict, sandbox: str = "local" +) -> dict: + return { + "instructions": {"agents_md": "Be terse. Do exactly what is asked."}, + "llm": { + "model": model, + "provider": provider, + "connection": connection, + "extras": {}, + }, + "tools": [], + "mcps": [], + "skills": [], + "harness": {"kind": harness}, + "sandbox": {"kind": sandbox}, + "runner": {"permissions": {"default": "allow"}}, + } + + +def create_revision(cfg: dict, tag: str) -> dict: + hexid = uuid.uuid4().hex[:8] + r = api( + "POST", + "/workflows/", + json={ + "workflow": { + "slug": f"{tag}-{hexid}", + "name": f"session-control {hexid}", + "flags": { + "is_custom": True, + "is_evaluator": False, + "is_feedback": False, + }, + } + }, + ) + if r.status_code != 200: + raise SystemExit(f"create workflow HTTP {r.status_code}: {r.text[:400]}") + wf = r.json()["workflow"]["id"] + + r = api( + "POST", + "/workflows/variants/", + json={ + "workflow_variant": { + "slug": f"{tag}-{hexid}-v", + "name": f"session-control {hexid} v", + "workflow_id": wf, + } + }, + ) + if r.status_code != 200: + raise SystemExit(f"create variant HTTP {r.status_code}: {r.text[:400]}") + var = r.json()["workflow_variant"]["id"] + + rev_id = None + for step in ("seed", "baseline"): + r = api( + "POST", + "/workflows/revisions/commit", + json={ + "workflow_revision": { + "slug": f"{tag}-{step}-{hexid}", + "name": f"session-control rev {step}", + "message": step, + "data": { + "uri": "agenta:builtin:agent:v0", + "parameters": {"agent": cfg}, + }, + "workflow_id": wf, + "workflow_variant_id": var, + } + }, + ) + if r.status_code != 200: + raise SystemExit(f"commit {step} HTTP {r.status_code}: {r.text[:400]}") + rev_id = r.json()["workflow_revision"]["id"] + + return { + "application": {"id": wf}, + "variant": {"id": var}, + "revision": {"id": rev_id}, + } + + +def user_msg(text: str) -> dict: + return { + "id": str(uuid.uuid4()), + "role": "user", + "parts": [{"type": "text", "text": text}], + } + + +def _is_answer_part(part: dict) -> bool: + """Mirrors `isAnswerPart` in agentRequest.ts (web/packages/agenta-playground/src/state/ + execution/agentRequest.ts): a non-empty text part, a tool part (`tool-*`), a + `dynamic-tool` part, or a `file` part.""" + t = part.get("type") if isinstance(part, dict) else None + if not isinstance(t, str): + return False + if t == "text": + text = part.get("text") + return isinstance(text, str) and text.strip() != "" + return t.startswith("tool-") or t in ("dynamic-tool", "file") + + +def _has_answer(message: dict) -> bool: + """Mirrors `hasAnswer` in agentRequest.ts: a user (non-assistant) message always counts; + an assistant message counts only if at least one of its parts is an answer part. Strips an + answer-less assistant turn so it cannot cascade into every later turn failing.""" + if message.get("role") != "assistant": + return True + parts = message.get("parts") + return isinstance(parts, list) and any(_is_answer_part(p) for p in parts) + + +def _client_shape_messages(messages: list) -> list: + """Shape the outbound `messages` list the way the desktop client does (agentRequest.ts), + when `--client-shape last-message` is selected. A no-op under the default `full`. + + Strip answer-less assistant turns, then send only the trailing message when it is a fresh + user turn — the runner rebuilds prior turns from the durable record log. A resume whose + trailing turn carries a settled HITL answer (not a user turn) keeps the full history so the + answer still binds to its tool call. + """ + if CLIENT_SHAPE != "last-message": + return messages + history = [m for m in messages if _has_answer(m)] + if not history: + return history + if history[-1].get("role") == "user": + return [history[-1]] + return history + + +def invoke( + session_id: str, + messages: list, + cfg: dict, + references: dict, + label: str, + out: dict | None = None, +) -> dict: + url = f"{BASE}/services/agent/v0/invoke" + body = { + "session_id": session_id, + "references": references, + "data": { + "inputs": {"messages": _client_shape_messages(messages)}, + "parameters": {"agent": cfg}, + }, + } + headers = { + "Authorization": STATE["credentials"], + "Accept": "text/event-stream", + "x-ag-messages-format": "vercel", + "Content-Type": "application/json", + } + out = out if out is not None else {} + out.update( + { + "frames": [], + "text": "", + "tool_calls": [], + "errors": [], + "raw": [], + "segments": [], + "tool_outcomes": {}, + "tool_payloads": {}, + } + ) + started = time.time() + with httpx.Client(timeout=stream_timeout_s(cfg)) as client: + with client.stream( + "POST", + url, + params={ + "project_id": STATE["project_id"], + "application_id": references["application"]["id"], + }, + json=body, + headers=headers, + ) as r: + print(f"[{label}] HTTP {r.status_code}", file=sys.stderr) + if r.status_code >= 400: + out["errors"].append(f"HTTP {r.status_code}: {r.read().decode()[:600]}") + return out + for line in r.iter_lines(): + if not line.startswith("data: "): + continue + payload = line[6:] + if payload == "[DONE]": + break + try: + f = json.loads(payload) + except json.JSONDecodeError: + continue + out["raw"].append(f) + t = f.get("type", "?") + out["frames"].append(t) + if t == "message-metadata": + tid = (f.get("messageMetadata") or {}).get("turnId") + if isinstance(tid, str) and tid: + out["turn_id"] = tid + if t == "text-delta": + delta = f.get("delta", "") + out["text"] += delta + if out["segments"] and out["segments"][-1]["kind"] == "text": + out["segments"][-1]["text"] += delta + else: + out["segments"].append({"kind": "text", "text": delta}) + elif t == "tool-input-available": + call = { + "toolCallId": f.get("toolCallId"), + "name": f.get("toolName"), + "input": f.get("input"), + } + is_new = not any( + c["toolCallId"] == call["toolCallId"] for c in out["tool_calls"] + ) + out["tool_calls"] = [ + c + for c in out["tool_calls"] + if c["toolCallId"] != call["toolCallId"] + ] + [call] + if is_new: + out["segments"].append( + {"kind": "tool", "id": call["toolCallId"]} + ) + elif t == "tool-output-available": + out["tool_outcomes"][f.get("toolCallId")] = "available" + out["tool_payloads"][f.get("toolCallId")] = { + "output": f.get("output") + } + elif t == "tool-output-error": + out["tool_outcomes"][f.get("toolCallId")] = "error" + out["tool_payloads"][f.get("toolCallId")] = { + "errorText": f.get("errorText") + } + elif t == "error": + out["errors"].append(json.dumps(f)[:600]) + out["elapsed_s"] = round(time.time() - started, 1) + print( + f"[{label}] frames={out['frames']} elapsed={out['elapsed_s']}s", file=sys.stderr + ) + return out + + +def assistant_message(turn: dict) -> dict: + parts: list = [] + text_buf: list[str] = [] + # `turn` can be `{}` when the driver's own wait for the turn timed out (`handle["out"]` was + # never set, e.g. because the runner was unhealthy and the stream thread never finished) — a + # driver-side timeout, not a reason to crash the cell with a KeyError instead of reporting a + # FAIL. Missing segments means no assistant turn to replay. + for seg in turn.get("segments") or []: + if seg["kind"] == "text": + text_buf.append(seg["text"]) + continue + if text_buf: + parts.append({"type": "text", "text": "".join(text_buf)}) + text_buf = [] + call = next(c for c in turn["tool_calls"] if c["toolCallId"] == seg["id"]) + part = { + "type": f"tool-{call['name']}", + "toolCallId": call["toolCallId"], + "input": call["input"], + "state": "input-available", + } + outcome = turn["tool_outcomes"].get(call["toolCallId"]) + if outcome == "available": + part["state"] = "output-available" + part["output"] = ( + turn["tool_payloads"].get(call["toolCallId"], {}).get("output") + ) + elif outcome == "error": + part["state"] = "output-error" + part["errorText"] = ( + turn["tool_payloads"].get(call["toolCallId"], {}).get("errorText") + ) + parts.append(part) + if text_buf: + parts.append({"type": "text", "text": "".join(text_buf)}) + return {"id": str(uuid.uuid4()), "role": "assistant", "parts": parts} + + +def turn_ledger(session_id: str, limit: int = 20) -> list[dict]: + """The session's turn rows, newest first, over HTTP only (no docker needed). + + The runner writes `agent_session_id` and `sandbox_id` on every turn, so this is a STORED + outcome, not an echo of what the client sent. Used to check the resume after a Stop landed + in the SAME sandbox rather than a rebuilt one. + """ + r = api( + "POST", + "/sessions/turns/query", + json={ + "query": {"session_id": session_id}, + "windowing": {"limit": limit, "order": "descending"}, + }, + ) + if r.status_code != 200: + return [] + try: + body = r.json() + except Exception: # noqa: BLE001 + return [] + turns = body.get("turns") if isinstance(body, dict) else None + return turns if isinstance(turns, list) else [] + + +def sandbox_ids(session_id: str) -> list[str]: + """Distinct sandbox ids across the session's turn ledger. + + ONE id = the resume reused the same sandbox (warm). TWO or more = the sandbox was rebuilt. + """ + return sorted( + {r.get("sandbox_id") for r in turn_ledger(session_id) if r.get("sandbox_id")} + ) + + +def session_stream(session_id: str) -> dict: + r = api("GET", "/sessions/streams/", params={"session_id": session_id}) + if r.status_code != 200: + return {} + return (r.json() or {}).get("stream") or {} + + +def cancel( + session_id: str, + *, + expected: str | None = None, + idempotency_key: str | None = None, + label: str = "stop", +) -> dict: + headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None + body = {"expected_execution_id": expected} if expected else {} + sent = time.time() + r = api( + "POST", + f"/sessions/{session_id}/cancel", + json=body, + headers=headers, + timeout=30.0, + ) + got = time.time() + try: + payload = r.json() + except Exception: + payload = {"raw": r.text[:400]} + durable_stop = _observe_durable_stop(payload) + record = { + "status": r.status_code, + "body": payload, + "durable_stop": durable_stop, + "sent_at": sent, + "sent_iso": time.strftime("%H:%M:%S", time.localtime(sent)) + + f".{int((sent % 1) * 1000):03d}", + "round_trip_s": round(got - sent, 3), + } + print( + f"[{label}] HTTP {r.status_code} at {record['sent_iso']} rt={record['round_trip_s']}s {json.dumps(payload)[:300]}", + file=sys.stderr, + ) + return record + + +_LEGACY_CANCEL_KEYS = { + "mode", + "session_id", + "turn_id", + "watcher_id", + "detached", + "cancelled_turn_ids", +} + + +def _detect_durable_stop(payload: object) -> str | None: + """Identify the Stop implementation from a successful cancel response body.""" + if not isinstance(payload, dict): + return None + if "command" in payload and "execution" in payload: + return "on" + if _LEGACY_CANCEL_KEYS.issubset(payload): + return "off" + return None + + +def _resolve_durable_stop(option: str, payload: object) -> str | None: + """Resolve an explicit flag value, or infer auto from the cancel response shape.""" + if option in ("on", "off"): + return option + return _detect_durable_stop(payload) + + +def _observe_durable_stop(payload: object) -> str | None: + """Record the effective durable-stop state for this run when the response identifies it.""" + global DURABLE_STOP_STATE + observed = _resolve_durable_stop(DURABLE_STOP_OPTION, payload) + if observed is None: + return DURABLE_STOP_STATE + if DURABLE_STOP_STATE is not None and observed != DURABLE_STOP_STATE: + raise RuntimeError( + "cancel responses disagreed about durable Stop state: " + f"first {DURABLE_STOP_STATE}, now {observed}" + ) + DURABLE_STOP_STATE = observed + return DURABLE_STOP_STATE + + +def records(session_id: str) -> list: + r = api("POST", "/sessions/records/query", json={"session_id": session_id}) + if r.status_code != 200: + return [{"error": f"HTTP {r.status_code}: {r.text[:200]}"}] + return (r.json() or {}).get("records") or [] + + +def terminal_records(session_id: str, turn_id: str | None = None) -> list: + rows = [ + { + "type": rec.get("record_type"), + "turn_id": rec.get("turn_id"), + "attributes": rec.get("attributes"), + } + for rec in records(session_id) + if rec.get("record_type") in ("error", "done") + ] + if turn_id: + rows = [r for r in rows if r["turn_id"] == turn_id] + return rows + + +def interactions(session_id: str) -> list: + r = api( + "POST", + "/sessions/interactions/query", + json={"query": {"session_id": session_id}}, + ) + if r.status_code != 200: + return [{"error": f"HTTP {r.status_code}"}] + return [ + { + "id": i.get("id"), + "turn_id": i.get("turn_id"), + "kind": i.get("kind"), + "status": i.get("status"), + } + for i in ((r.json() or {}).get("interactions") or []) + ] + + +def invoke_async(session_id, messages, cfg, references, label) -> dict: + live: dict = {} + handle: dict = {"out": None, "live": live} + + def go() -> None: + handle["out"] = invoke(session_id, messages, cfg, references, label, out=live) + + t = threading.Thread(target=go, daemon=True) + t.start() + handle["thread"] = t + return handle + + +def wait_for_turn(session_id: str, *, timeout: float = 40.0) -> str | None: + deadline = time.time() + timeout + SANDBOX_STARTUP_SLACK_S + while time.time() < deadline: + stream = session_stream(session_id) + turn = stream.get("turn_id") + flags = stream.get("flags") or {} + if turn and flags.get("is_running"): + return turn + time.sleep(0.5) + return None + + +def wait_for_tool(handle: dict, *, timeout: float = 60.0) -> dict | None: + deadline = time.time() + timeout + SANDBOX_STARTUP_SLACK_S + live = handle["live"] + while time.time() < deadline: + calls = live.get("tool_calls") or [] + outcomes = live.get("tool_outcomes") or {} + open_calls = [c for c in calls if c["toolCallId"] not in outcomes] + if open_calls: + return open_calls[-1] + if handle["out"] is not None: + return None + time.sleep(0.1) + return None + + +def sleep_prompt(marker: str, seconds: int) -> str: + return ( + f"The codeword is {marker}. Run exactly this one shell command and nothing " + f"else: sleep {seconds}. Do not write, read or search any files. " + "When the command finishes, reply with the single word DONE." + ) + + +# --------------------------------------------------------------------------- # +# Cells. Each returns (evidence: dict, verdict: dict) where verdict is +# {"pass": bool, "skip": bool, "why": str} — the gate's result shape. +# --------------------------------------------------------------------------- # + +Cell = "tuple[dict, dict]" + + +def _pass(why: str) -> dict: + return {"pass": True, "skip": False, "why": why} + + +def _fail(why: str) -> dict: + return {"pass": False, "skip": False, "why": why} + + +def _skip(why: str) -> dict: + return {"pass": False, "skip": True, "why": why} + + +def _match_stop_command(commands: list[dict], turn_id: str | None) -> dict | None: + """The Stop command for a given turn: the last command row targeting it, or (when the turn + id is unknown, or nothing targets it) the last command row overall. Shared by every cell that + needs to find "the command the Stop I just sent produced" among a session's command rows.""" + matching = [c for c in commands if turn_id and c.get("target_turn_id") == turn_id] + return matching[-1] if matching else (commands[-1] if commands else None) + + +def assert_command_settled( + hooks: OperatorHooks, session_id: str, turn_id: str | None, *, timeout: float = 20.0 +) -> dict: + """Poll for up to `timeout` seconds after a Stop for the durable settlement invariant every + Stop-issuing cell must observe: the session_commands row for the Stop reaches a terminal + state (`applied` or `obsolete` — never left `pending` or `claimed`), and exactly one + session_executions row exists for the stopped session with a non-empty terminal outcome. This + is the check that would have caught the repeat-stop false pass on 2026-09-04 (session + 190e9118: command stuck `claimed` forever, zero session_executions rows, yet every driver- + level assertion — one terminal trace record, a warm resume — still passed). + + A Stop can also land AFTER the turn already finished naturally — common on a fast Claude Code + turn: the valid Stop returns 202, but the runner has nothing to cancel, so the command settles + `obsolete`/`not_running` and NO execution row is written. Zero rows is correct there, so a Stop + that settled `not_running` is accepted with zero execution rows and `natural_finish=True`. The + strict one-row requirement is kept for a Stop the runner applied (`stopped`) or the sweep + settled (`lost`). `stop-after-finish` and `stop-during-completion` already accept this shape; + routing it through here shares it with every Stop-issuing cell. + + Returns a dict with `settled` (bool), `command`, `execution_rows`, `natural_finish` (bool), + `note` (set to "stop landed after a natural finish" on that path, else None), and `why` (a + one-line reason, only set when `settled` is False). Never raises: a hookless run (`NullHooks`) + reads as `settled=True` so a cell that runs without --project is not blocked by a check it has + no way to make (the cell's own `hooks.available` guard already SKIPs it). + """ + if not hooks.available: + return { + "settled": True, + "command": None, + "execution_rows": [], + "natural_finish": False, + "note": None, + "why": None, + } + deadline = time.time() + timeout + command: dict | None = None + executions: list[dict] = [] + while True: + commands = hooks.command_rows(session_id) + command = _match_stop_command(commands, turn_id) + executions = hooks.execution_rows(session_id) + settled_command = command is not None and command.get("state") in ( + "applied", + "obsolete", + ) + outcome = command.get("outcome") if command else None + # The Stop landed after a natural finish: obsolete/not_running, no execution row to expect. + natural_finish = settled_command and outcome == "not_running" + settled_execution = len(executions) == 1 and bool( + executions[0].get("terminal_outcome") + ) + if settled_command and (natural_finish or settled_execution): + return { + "settled": True, + "command": command, + "execution_rows": executions, + "natural_finish": natural_finish, + "note": "stop landed after a natural finish" + if natural_finish + else None, + "why": None, + } + if time.time() >= deadline: + break + time.sleep(1) + if command is None: + why = "no session_commands row was found for the Stop" + elif command.get("state") not in ("applied", "obsolete"): + why = f"the Stop command was left {command.get('state')!r}, expected applied or obsolete" + elif len(executions) != 1: + why = ( + "expected exactly one session_executions row for the stopped session, saw " + f"{len(executions)}" + ) + else: + why = "the session_executions row settled with no terminal outcome" + return { + "settled": False, + "command": command, + "execution_rows": executions, + "natural_finish": False, + "note": None, + "why": why, + } + + +def _judge_runner_gone(evidence: dict) -> dict: + """Shared PASS rule for the runner-gone family (`runner-gone`, `runner-gone-late`). + + The invariant: exactly one effective terminal outcome for the execution, no command left + pending or claimed, is_running false, and the next Send succeeds. Two different races can + land this — the runner reports the Stop's outcome before it dies (`outcome-reported-then- + died`), or it never gets the chance and the sweep settles the command `lost` + (`never-reported`) — and both satisfy the invariant, so both PASS. Which one landed is + recorded on `evidence["race"]` for visibility, not asserted on. Mutates `evidence` in place. + """ + if not evidence.get("terminal_records"): + return _fail("no terminal record settled within the sweep-wait window") + stop_command = evidence.get("stop_command") + if stop_command is None: + return _fail("no session_commands row was found for the Stop") + if stop_command.get("state") not in ("obsolete", "applied"): + return _fail( + f"the Stop command read state {stop_command.get('state')!r}, expected obsolete or applied" + ) + outcome = stop_command.get("outcome") + if outcome in (None, "", "pending", "claimed"): + return _fail( + f"the Stop command was left {outcome!r}: still pending or claimed, never settled" + ) + stream_row = evidence.get("stream_row") or {} + if (stream_row.get("flags") or {}).get("is_running") is not False: + return _fail( + "the session_streams row did not read is_running: false after the sweep settled " + "the command" + ) + if not evidence.get("new_message_ran"): + return _fail("the Send sent after recovery did not run cleanly") + race = "never-reported" if outcome == "lost" else "outcome-reported-then-died" + evidence["race"] = race + return _pass( + f"race {race}: the Stop command settled off pending/claimed, is_running read false, " + "and the next Send ran" + ) + + +def cell_stop_warm(cfg, references, args, hooks: OperatorHooks) -> Cell: + """Stop under 5 s, park, warm resume that recalls the codeword. Needs no shell.""" + session_id = str(uuid.uuid4()) + marker = f"MANGO{uuid.uuid4().hex[:6].upper()}" + msgs = [user_msg(sleep_prompt(marker, args.sleep_seconds))] + handle = invoke_async(session_id, msgs, cfg, references, "warm-turn1") + turn = wait_for_turn(session_id) + open_call = wait_for_tool(handle) + stop = cancel(session_id, expected=turn, label="stop-warm") + settle = assert_command_settled(hooks, session_id, turn) + handle["thread"].join(timeout=180) + t1 = handle["out"] or {} + time.sleep(4) + msgs2 = msgs + [assistant_message(t1), user_msg(RECALL)] + t2 = invoke(session_id, msgs2, cfg, references, "warm-turn2") + evidence = { + "session_id": session_id, + "turn_id": turn, + "marker": marker, + "stop": stop, + "stopped_during_tool": open_call, + "turn1_elapsed_s": t1.get("elapsed_s"), + "terminal_records": terminal_records(session_id, turn), + "resume_recalled_marker": marker in (t2.get("text") or ""), + "resume_elapsed_s": t2.get("elapsed_s"), + "command_settled": settle, + } + evidence["sandbox_ids"] = sandbox_ids(session_id) + evidence["warm_same_sandbox"] = len(evidence["sandbox_ids"]) <= 1 + if stop["status"] not in (200, 202): + return evidence, _fail( + f"Stop returned HTTP {stop['status']}, expected 200 or 202" + ) + if not settle["settled"]: + return evidence, _fail(settle["why"]) + if not evidence["resume_recalled_marker"]: + return evidence, _fail("warm resume did not recall the codeword") + return evidence, _pass( + f"Stop returned HTTP {stop['status']} and the warm resume recalled the codeword" + ) + + +def cell_double_send(cfg, references, args, hooks: OperatorHooks) -> Cell: + """A second message during a running turn is refused, and destroys nothing. Needs no shell.""" + session_id = str(uuid.uuid4()) + marker = f"KIWI{uuid.uuid4().hex[:6].upper()}" + msgs = [user_msg(sleep_prompt(marker, args.sleep_seconds))] + handle = invoke_async(session_id, msgs, cfg, references, "double-turn1") + turn = wait_for_turn(session_id) + time.sleep(5) + second_started = time.time() + t2 = invoke( + session_id, [user_msg("Say hello.")], cfg, references, "double-turn2-refused" + ) + second_elapsed = round(time.time() - second_started, 2) + handle["thread"].join(timeout=300) + t1 = handle["out"] or {} + time.sleep(4) + msgs3 = msgs + [assistant_message(t1), user_msg(RECALL)] + t3 = invoke(session_id, msgs3, cfg, references, "double-turn3") + evidence = { + "session_id": session_id, + "turn_id": turn, + "marker": marker, + "second_send": { + "frames": t2.get("frames"), + "errors": t2.get("errors"), + "elapsed_s": second_elapsed, + }, + "turn1_elapsed_s": t1.get("elapsed_s"), + "turn1_errors": t1.get("errors"), + "third_send_recalled_marker": marker in (t3.get("text") or ""), + } + evidence["sandbox_ids"] = sandbox_ids(session_id) + evidence["warm_same_sandbox"] = len(evidence["sandbox_ids"]) <= 1 + refused = bool(t2.get("errors")) + if not refused: + return evidence, _fail("second Send during a running turn was not refused") + if not evidence["third_send_recalled_marker"]: + return evidence, _fail( + "turn 1 finished but the codeword was not recalled afterwards" + ) + return evidence, _pass( + "second Send was refused and the original turn completed cleanly" + ) + + +def cell_stale_stop(cfg, references, args, hooks: OperatorHooks) -> Cell: + """A Stop naming a settled turn is refused and tombstones nothing. Needs no shell.""" + session_id = str(uuid.uuid4()) + marker = f"PLUM{uuid.uuid4().hex[:6].upper()}" + msgs = [ + user_msg(f"The codeword is {marker}. Reply with just the single word READY.") + ] + t1 = invoke(session_id, msgs, cfg, references, "stale-turn1") + turn1 = session_stream(session_id).get("turn_id") + time.sleep(3) + msgs2 = msgs + [ + assistant_message(t1), + user_msg(sleep_prompt(marker, args.sleep_seconds)), + ] + handle = invoke_async(session_id, msgs2, cfg, references, "stale-turn2") + turn2 = None + deadline = time.time() + 40 + while time.time() < deadline: + candidate = wait_for_turn(session_id, timeout=2) + if candidate and candidate != turn1: + turn2 = candidate + break + time.sleep(3) + stale = cancel(session_id, expected=turn1, label="stale-stop") + time.sleep(3) + bare = cancel(session_id, label="bare-stop") + # The stale Stop (targets turn1, already settled) is expected to be REFUSED, not to produce + # a settlement of its own — only `bare` (the real Stop, targets the live turn2) must settle. + settle = assert_command_settled(hooks, session_id, turn2) + handle["thread"].join(timeout=180) + t2 = handle["out"] or {} + time.sleep(4) + msgs3 = msgs2 + [assistant_message(t2), user_msg(RECALL)] + t3 = invoke(session_id, msgs3, cfg, references, "stale-turn3") + evidence = { + "session_id": session_id, + "turn1_id": turn1, + "turn2_id": turn2, + "stale_stop": stale, + "bare_stop": bare, + "turn2_elapsed_s": t2.get("elapsed_s"), + "turn3_recalled_marker": marker in (t3.get("text") or ""), + "command_settled": settle, + } + evidence["sandbox_ids"] = sandbox_ids(session_id) + evidence["warm_same_sandbox"] = len(evidence["sandbox_ids"]) <= 1 + if stale["status"] not in (400, 404, 409): + return evidence, _fail( + f"stale Stop returned HTTP {stale['status']}, expected a mismatch status" + ) + if not settle["settled"]: + return evidence, _fail(settle["why"]) + if not evidence["turn3_recalled_marker"]: + return evidence, _fail("turn 2 did not survive the stale Stop") + return evidence, _pass("stale Stop was refused and turn 2 completed and survived") + + +def cell_stop_approval(cfg_ask, references_ask, args, hooks: OperatorHooks) -> Cell: + """Stop a parked approval and enforce the flag-specific late-answer behavior.""" + session_id = str(uuid.uuid4()) + marker = f"PEAR{uuid.uuid4().hex[:6].upper()}" + prompt = f"The codeword is {marker}. Run exactly this one shell command and nothing else: echo hello. Then reply DONE." + t1 = invoke( + session_id, [user_msg(prompt)], cfg_ask, references_ask, "approval-turn" + ) + time.sleep(3) + before = interactions(session_id) + stream_before = session_stream(session_id) + expected = t1.get("turn_id") or stream_before.get("turn_id") + stop = cancel(session_id, expected=expected, label="stop-approval-named") + settle = assert_command_settled(hooks, session_id, expected) + time.sleep(3) + pending = next((i for i in before if i.get("status") == "pending"), None) + late = {"skipped": "no pending interaction was found before the Stop"} + if pending: + r = api( + "POST", + f"/sessions/interactions/{pending['id']}/respond", + json={"answer": {"approved": True}}, + ) + late = {"status": r.status_code, "body": r.text[:300]} + denied = assistant_message(t1) + for part in denied["parts"]: + if ( + part.get("type", "").startswith("tool-") + and part.get("state") == "input-available" + ): + part["state"] = "output-denied" + msgs2 = [user_msg(prompt), denied, user_msg(RECALL)] + t2 = invoke(session_id, msgs2, cfg_ask, references_ask, "approval-resume") + evidence = { + "session_id": session_id, + "marker": marker, + "expected_execution_id": expected, + "stop": stop, + "late_answer": late, + "durable_stop": _resolve_durable_stop(args.durable_stop, stop["body"]), + "resume_recalled_marker": marker in (t2.get("text") or ""), + # Without the actual reply, a FAIL here cannot be told apart from a driver replay bug + # (the reconstructed `output-denied` part shaped wrong) versus the model genuinely not + # recalling the codeword -- keep enough of the wire to tell the two apart after the fact. + "resume_text": (t2.get("text") or "")[:400], + "resume_frames": t2.get("frames", [])[:20], + "resume_errors": t2.get("errors"), + "command_settled": settle, + } + evidence["sandbox_ids"] = sandbox_ids(session_id) + evidence["warm_same_sandbox"] = len(evidence["sandbox_ids"]) <= 1 + return evidence, _judge_stop_approval(evidence, pending_found=pending is not None) + + +def _judge_stop_approval(evidence: dict, *, pending_found: bool) -> dict: + """Apply all stop-approval assertions with only the late-answer rule gated by the flag.""" + if not pending_found: + return _fail( + "no pending approval was seen before the Stop; the race did not land" + ) + stop = evidence["stop"] + if stop["status"] not in (200, 202): + return _fail( + f"named Stop on a parked approval returned HTTP {stop['status']}, expected 200 or 202" + ) + settle = evidence["command_settled"] + if not settle["settled"]: + return _fail(settle["why"]) + durable_stop = evidence["durable_stop"] + if durable_stop not in ("on", "off"): + return _fail( + "could not determine durable Stop state from the cancel response; " + "pass --durable-stop on or off" + ) + late = evidence["late_answer"] + if durable_stop == "on" and late.get("status") != 409: + return _fail( + f"the late approval answer returned HTTP {late.get('status')}, expected 409" + ) + if durable_stop == "off": + if late.get("status") != 200: + return _fail( + "the legacy path refused the late approval answer, expected HTTP 200" + ) + evidence["late_answer"]["note"] = "late answer accepted: legacy path" + if not evidence["resume_recalled_marker"]: + return _fail("resume after the approval Stop did not recall the codeword") + if durable_stop == "off": + return _pass("late answer accepted: legacy path") + return _pass( + "Stop cancelled the parked approval, the late answer was refused, resume recalled the codeword" + ) + + +def cell_sandbox_gone(cfg, references, args, hooks: OperatorHooks) -> Cell: + """Kill the sandbox under a running tool call. Needs shell to find and kill the process.""" + if not hooks.available: + return {}, _skip( + "no --project given: killing the sandbox process needs docker exec" + ) + session_id = str(uuid.uuid4()) + marker = f"OLIVE{uuid.uuid4().hex[:6].upper()}" + # `since` bounds the runner-log window used to map THIS session to its own sandbox port. + since = time.time() + msgs = [user_msg(sleep_prompt(marker, SANDBOX_GONE_COMMAND_S))] + handle = invoke_async(session_id, msgs, cfg, references, "sandbox-turn1") + turn = wait_for_turn(session_id) + settle_budget = sandbox_gone_settle_budget_s() + evidence = { + "session_id": session_id, + "turn_id": turn, + "command_seconds": SANDBOX_GONE_COMMAND_S, + "resolve_timeout_seconds": SANDBOX_GONE_RESOLVE_TIMEOUT_S, + "settle_budget_seconds": round(settle_budget, 1), + } + # A cold acquire writes this session's `prepare_workspace` line ~35 s in, so POLL for the + # session's own sandbox to become resolvable (up to the resolve timeout) instead of reading + # once right after the turn started. Refuse if the line never appears — never kill a guess. + # The ledger id (`local/:` on local, the remote uuid on Daytona) is the + # cross-check; never a shared `ps | grep`, which cannot tell two sessions apart. + resolve_started = time.time() + try: + hooks.wait_for_sandbox_ready( + session_id, + ledger_id_getter=lambda: (sandbox_ids(session_id) or [None])[-1], + since=since, + timeout=SANDBOX_GONE_RESOLVE_TIMEOUT_S, + ) + except WrongSandboxTarget as exc: + evidence["wrong_target"] = str(exc) + evidence["resolve_seconds"] = round(time.time() - resolve_started, 1) + return evidence, _fail(f"wrong target, refused to kill a guess: {exc}") + evidence["resolve_seconds"] = round(time.time() - resolve_started, 1) + # Let the slow command actually be running before the kill, so the sandbox dies mid-turn. + time.sleep(SANDBOX_GONE_RUNNING_SLACK_S) + observed_ids = sandbox_ids(session_id) + target_sandbox_id = observed_ids[-1] if observed_ids else None + evidence["target_sandbox_id"] = target_sandbox_id + # Now resolve the pid on the tested session's own port (or the remote id), assert it is a + # sandbox-agent daemon, and refuse (never kill a guess) if the mapping cannot be made. + try: + target = hooks.kill_sandbox_for_session( + session_id, sandbox_id=target_sandbox_id, since=since + ) + except WrongSandboxTarget as exc: + evidence["wrong_target"] = str(exc) + return evidence, _fail(f"wrong target, refused to kill a guess: {exc}") + evidence.update( + { + "killed_port": target.get("port"), + "killed_pid": target.get("pid"), + "killed_cmdline": target.get("cmdline"), + "killed_pids": target.get("killed"), + } + ) + # Wait for the runner to end the turn: the probe's three-strikes budget, and never shorter + # than the slow command, so a mis-target could not read as "still running" prematurely. The + # thread returns as soon as the stream closes, so a healthy kill settles well inside this. + wait_s = max(settle_budget, float(SANDBOX_GONE_COMMAND_S)) + SANDBOX_STARTUP_SLACK_S + handle["thread"].join(timeout=wait_s) + t1 = handle["out"] or {} + time.sleep(5) + terminal = _poll_terminal_after_settle(lambda: terminal_records(session_id, turn)) + evidence.update( + { + "turn1_errors": t1.get("errors"), + "terminal_records": terminal, + "stream_after": session_stream(session_id), + } + ) + if not target.get("killed"): + return evidence, _fail("no sandbox-agent process was found to kill") + flags = (evidence["stream_after"] or {}).get("flags") or {} + if flags.get("is_running"): + return evidence, _fail( + "session still reads is_running after the sandbox process was killed" + ) + if not evidence["terminal_records"]: + return evidence, _fail( + "no terminal record was written after the sandbox process was killed" + ) + return evidence, _pass( + "killing the tested session's own sandbox ended the turn and wrote a terminal record" + ) + + +def cell_records_outage(cfg, references, args, hooks: OperatorHooks) -> Cell: + """Stop Postgres for 20 s during a turn. Every record must land after it returns.""" + if not hooks.available: + return {}, _skip("no --project given: stopping Postgres needs docker") + session_id = str(uuid.uuid4()) + marker = f"CEDAR{uuid.uuid4().hex[:6].upper()}" + msgs = [ + user_msg( + f"The codeword is {marker}. Run exactly this one shell command and nothing else: sleep 30. When it finishes, reply with the single word DONE." + ) + ] + handle = invoke_async(session_id, msgs, cfg, references, "outage-turn1") + wait_for_turn(session_id) + time.sleep(6) + hooks.stop_postgres() + try: + time.sleep(20) + finally: + # Restore Postgres even if something above raises: a stopped Postgres left behind + # strands every cell that runs after this one, not just this one's own assertions. + hooks.start_postgres() + handle["thread"].join(timeout=400) + landed = [] + deadline = time.time() + 180 + while time.time() < deadline: + landed = records(session_id) + if "done" in [r.get("record_type") for r in landed]: + break + time.sleep(5) + evidence = { + "session_id": session_id, + "marker": marker, + "record_types": [r.get("record_type") for r in landed], + "record_count": len(landed), + } + if "done" not in evidence["record_types"]: + return evidence, _fail( + "no done record landed after the Postgres outage recovered" + ) + return evidence, _pass("every record landed after the Postgres outage recovered") + + +def cell_stop_after_finish(cfg, references, args, hooks: OperatorHooks) -> Cell: + """Fire the Stop at the instant the runner settles the prompt. Needs no shell for the core + assertion; the [control] aborted check is skipped without --project.""" + session_id = str(uuid.uuid4()) + marker = f"ACORN{uuid.uuid4().hex[:6].upper()}" + since = time.time() + msgs = [ + user_msg( + f"The codeword is {marker}. Run exactly this one shell command and nothing else: sleep 6. When it finishes, reply with the single word DONE." + ) + ] + seen: dict = {} + watcher_proc = None + if hooks.available: + watcher_proc = subprocess.Popen( + ["docker", "logs", "-f", "--since", "0s", f"{args.project}-runner-1"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + + def watch() -> None: + assert watcher_proc.stdout is not None + for line in watcher_proc.stdout: + if "prompt stopReason=" in line: + seen["line"] = line.strip() + seen["at"] = time.time() + return + + threading.Thread(target=watch, daemon=True).start() + + handle = invoke_async(session_id, msgs, cfg, references, "finish-turn1") + turn = wait_for_turn(session_id) + if hooks.available: + deadline = time.time() + 180 + while "at" not in seen and time.time() < deadline: + time.sleep(0.02) + else: + time.sleep( + 6.5 + ) # no runner-log watch: fire the Stop right around the natural finish + stop = cancel(session_id, expected=turn, label="stop-after-finish") + if watcher_proc: + try: + watcher_proc.kill() + except Exception: # noqa: BLE001 + pass + handle["thread"].join(timeout=180) + t1 = handle["out"] or {} + time.sleep(6) + msgs2 = msgs + [assistant_message(t1), user_msg(RECALL)] + t2 = invoke(session_id, msgs2, cfg, references, "finish-turn2") + evidence = { + "session_id": session_id, + "turn_id": t1.get("turn_id") or turn, + "settle_line": seen.get("line"), + "stop": stop, + "resume_recalled_marker": marker in (t2.get("text") or ""), + "terminal_records": terminal_records(session_id, turn), + } + evidence["sandbox_ids"] = sandbox_ids(session_id) + evidence["warm_same_sandbox"] = len(evidence["sandbox_ids"]) <= 1 + if hooks.available: + logs = hooks.runner_log(since) + evidence["control_aborted_lines"] = [ + ln for ln in logs if "[control] aborted" in ln and session_id in ln + ] + if hooks.available and evidence.get("control_aborted_lines"): + return evidence, _fail( + "a Stop that lost the race to completion still aborted the settled run" + ) + if not evidence["resume_recalled_marker"]: + return evidence, _fail( + "the session did not park warm after Stop raced a finished turn" + ) + why = ( + "no spurious abort and a warm continuation recalled the codeword" + if hooks.available + else "a warm continuation recalled the codeword (abort-log check skipped: no --project)" + ) + return evidence, _pass(why) + + +def cell_restart_after_stop(cfg, references, args, hooks: OperatorHooks) -> Cell: + """Stop, restart the runner, continue with an EMPTY client transcript. + + The codeword recall alone is not proof of native continuity: when the native session did not + truly hydrate, the runner can still answer correctly by reconstructing the conversation from + the persisted record log (the `[reconstruct]` / `session/load ... loaded=false` path), and a + driver that ever sent more than the trailing message could paper over the same gap from the + client side. So this cell forces its resume onto `--client-shape last-message` (the shape the + desktop actually sends) regardless of the run's own `--client-shape`, and requires a SECOND, + independent signal beyond the recalled codeword: either the sandbox id after the restart is + the SAME one the turn ran on before it (true continuity needs no rebuild), or the runner log + for the resume shows `session/load ... loaded=true` (a genuine native hydrate, not a + reconstruction). Recall without either is a false pass, not a pass. + """ + if not hooks.available: + return {}, _skip("no --project given: restarting the runner needs docker") + session_id = str(uuid.uuid4()) + marker = f"BIRCH{uuid.uuid4().hex[:6].upper()}" + msgs = [user_msg(sleep_prompt(marker, args.sleep_seconds))] + handle = invoke_async(session_id, msgs, cfg, references, "restart-turn1") + turn = wait_for_turn(session_id) + wait_for_tool(handle) + stop = cancel(session_id, expected=turn, label="stop-before-restart") + handle["thread"].join(timeout=180) + time.sleep(4) + sandbox_id_before = (sandbox_ids(session_id) or [None])[-1] + restart_at = time.time() + hooks.restart_runner(grace_seconds=10) + healthy_after = hooks.wait_for_runner() + attempts = [] + admitted = None + global CLIENT_SHAPE + prior_client_shape = CLIENT_SHAPE + CLIENT_SHAPE = "last-message" + try: + deadline = time.time() + 240 + while time.time() < deadline: + t = invoke( + session_id, [user_msg(RECALL)], cfg, references, "restart-recall" + ) + refused = any( + "already running a turn" in (e or "") for e in t.get("errors", []) + ) + attempts.append( + { + "at_s_after_restart": round(time.time() - restart_at, 1), + "refused": refused, + } + ) + if not refused: + admitted = t + break + time.sleep(5) + finally: + CLIENT_SHAPE = prior_client_shape + sandbox_id_after = (sandbox_ids(session_id) or [None])[-1] + resume_log = [ + line + for line in hooks.runner_log(restart_at) + if session_id in line and "session/load" in line + ] + loaded_true = any("loaded=true" in line for line in resume_log) + same_sandbox = bool(sandbox_id_before) and sandbox_id_before == sandbox_id_after + evidence = { + "session_id": session_id, + "turn_id": turn, + "stop": stop, + "runner_healthy_after_s": healthy_after, + "attempts": attempts, + "admitted_at_s": attempts[-1]["at_s_after_restart"] if admitted else None, + "recalled_marker": marker in ((admitted or {}).get("text") or ""), + "sandbox_id_before": sandbox_id_before, + "sandbox_id_after": sandbox_id_after, + "same_sandbox": same_sandbox, + "resume_load_log_lines": resume_log, + "loaded_true": loaded_true, + } + return evidence, _judge_restart_after_stop(evidence) + + +def _judge_restart_after_stop(evidence: dict) -> dict: + """PASS rule for `restart-after-stop`. A recalled codeword alone is not proof of native + continuity — the runner can recover it by reconstructing the conversation from persisted + records even when the native session did not truly hydrate. Require the recall AND one of: + the sandbox was not rebuilt (`same_sandbox`), or the runner log shows a genuine native hydrate + (`loaded_true`). See `cell_restart_after_stop`'s docstring for why.""" + if evidence.get("runner_healthy_after_s") is None: + return _fail("the runner never reported healthy after the restart") + if evidence.get("admitted_at_s") is None: + return _fail( + "the continuation was refused for the whole wait window after the restart" + ) + if not evidence.get("recalled_marker"): + return _fail( + "the native harness session did not survive the restart: the codeword was not recalled" + ) + if not (evidence.get("same_sandbox") or evidence.get("loaded_true")): + return _fail("native session not resumed, recovered by transcript replay") + return _pass( + "the runner rehydrated the native session across a restart and recalled the codeword" + ) + + +def cell_runner_gone(cfg, references, args, hooks: OperatorHooks) -> Cell: + """Pause the runner BEFORE the Stop, so the command can never be claimed or reported. + + Deterministic version of the hard race: hoping a restart lands between the Stop and the + runner's own outcome report is timing-dependent and mostly loses the race (see + `runner-gone-late`). Pausing first removes the timing dependency: the runner cannot claim + or report the command at all, so it must stay `pending` until the stale threshold and the + sweep interval both pass (--sweep-wait), at which point the sweep must settle it `lost` + (state `obsolete` or `applied`, outcome `lost`) and write the execution's own watchdog + `execution_lost` ending. + + Every gone-and-stays-gone signal — the settled command, the watchdog ending, and is_running: + false — is read WHILE THE RUNNER IS STILL PAUSED. Reading after the unpause is the run-2b bug: + the returning runner starts a new turn on the same session that legitimately sets is_running + true. The unpause and the Send that follows are only a restore step plus an OPTIONAL, separately + recorded resumability check; they are not part of this cell's pass. + """ + if not hooks.available: + return {}, _skip("no --project given: pausing the runner needs docker") + session_id = str(uuid.uuid4()) + marker = f"FIG{uuid.uuid4().hex[:6].upper()}" + msgs = [user_msg(sleep_prompt(marker, 240))] + handle = invoke_async(session_id, msgs, cfg, references, "gone-turn1") + turn = wait_for_turn(session_id) + time.sleep(5) + + # Pause, Stop, settle, and READ is_running all while the runner is still gone. Measuring after + # the unpause is the run-2b bug: the returning runner starts a new turn on the same session + # that legitimately sets is_running true, and the driver read that true. + measured = _measure_runner_gone_while_paused( + hooks, + session_id, + turn, + do_stop=lambda: cancel(session_id, expected=turn, label="stop-then-pause"), + read_terminal=lambda: terminal_records(session_id, turn), + sweep_wait=args.sweep_wait, + ) + stop = measured["stop"] + settled_at = measured["settled_at"] + stop_command = measured["stop_command"] + terminal = measured["terminal"] + stream_row = measured["stream_row"] + paused_is_running = ( + (stream_row.get("flags") or {}).get("is_running") if stream_row else None + ) + + # The runner is back. Restore health, then run an OPTIONAL, separately-recorded resumability + # check — a Send after health. It is NOT part of the runner-gone verdict: a returning runner + # starting a new turn is exactly the signal that must not count against "gone". + healthy_after_s = hooks.wait_for_runner() + resume: dict = {"attempted": False} + if healthy_after_s is not None: + handle["thread"].join(timeout=60) + runner_recovered, t2 = _recover_then_send( + health_poll=hooks.runner_healthy, + send=lambda: invoke( + session_id, + [ + user_msg( + f"The codeword is {marker}. Reply with just the single word READY." + ) + ], + cfg, + references, + "gone-turn2", + ), + timeout=RECOVERY_HEALTH_TIMEOUT_S, + poll_interval=RECOVERY_HEALTH_POLL_S, + ) + t2 = t2 or {} + resume = { + "attempted": True, + "runner_recovered": runner_recovered, + "ran": bool(t2.get("frames")) and not t2.get("errors"), + "errors": t2.get("errors"), + } + + evidence = { + "session_id": session_id, + "turn_id": turn, + "stop": stop, + "settled_at": measured["settled_at"], + "paused_read_at": measured["paused_read_at"], + "seconds_to_settle": ( + round(settled_at - measured["stop_at"], 1) + if settled_at and measured["stop_at"] + else None + ), + "terminal_records": terminal, + "commands": measured["commands"], + "executions": measured["executions"], + "stop_command": stop_command, + "stream_row_while_paused": stream_row, + "is_running_while_paused": paused_is_running, + "healthy_after_unpause_s": healthy_after_s, + "resumability": resume, + } + # Gone-and-stays-gone verdict, every signal measured WHILE the runner was still paused. + if settled_at is None: + return evidence, _fail( + "the Stop was not settled lost within the sweep-wait window while the runner was paused" + ) + if stop_command is None: + return evidence, _fail("no session_commands row was found for the Stop") + if stop_command.get("state") not in ("obsolete", "applied"): + return evidence, _fail( + f"the Stop command read state {stop_command.get('state')!r}, expected obsolete or applied" + ) + if stop_command.get("outcome") != "lost": + return evidence, _fail( + f"the Stop command read outcome {stop_command.get('outcome')!r}, expected lost: a " + "paused runner should never have been able to report it" + ) + watchdog_failure = _require_watchdog_execution_lost(terminal) + if watchdog_failure: + return evidence, watchdog_failure + if paused_is_running is not False: + return evidence, _fail( + "the session_streams row did not read is_running: false while the runner was still paused" + ) + evidence["race"] = "never-reported" + return evidence, _pass( + "pausing the runner first forced the never-reported race: while the runner was still gone " + "the sweep settled the Stop lost with a watchdog execution_lost ending and the stream row " + "read is_running: false" + ) + + +def cell_runner_gone_late(cfg, references, args, hooks: OperatorHooks) -> Cell: + """Restart the runner right after a Stop is claimed, hoping it lands before the runner can + report the outcome. The softer, timing-dependent sibling of `runner-gone`: a restart often + loses this race (the runner reports the Stop's outcome before it actually dies), so this + cell accepts either race the sweep can produce — see `_judge_runner_gone`. + """ + if not hooks.available: + return {}, _skip("no --project given: restarting the runner needs docker") + session_id = str(uuid.uuid4()) + marker = f"FIG{uuid.uuid4().hex[:6].upper()}" + msgs = [user_msg(sleep_prompt(marker, 240))] + handle = invoke_async(session_id, msgs, cfg, references, "gone-late-turn1") + turn = wait_for_turn(session_id) + time.sleep(5) + + # Stop first, then take the runner away before it can (maybe) report the outcome. + stop = cancel(session_id, expected=turn, label="stop-then-kill") + kill_at = time.time() + hooks.kill_runner() + print( + f"[runner-gone-late] restarted the runner at {time.strftime('%H:%M:%S')}", + file=sys.stderr, + ) + handle["thread"].join(timeout=60) + + # Wait for the sweep. The plan budgets the stale threshold plus the sweep interval, held in + # --sweep-wait. + settled_at = None + terminal: list = [] + deadline = time.time() + args.sweep_wait + while time.time() < deadline: + stream = session_stream(session_id) + flags = stream.get("flags") or {} + terminal = terminal_records(session_id, turn) + if terminal and not flags.get("is_running"): + settled_at = time.time() + break + time.sleep(5) + + if settled_at is not None: + terminal = _poll_terminal_after_settle( + lambda: terminal_records(session_id, turn) + ) + + time.sleep(3) + commands = hooks.command_rows(session_id) + stream_row = hooks.stream_row(session_id) + stop_command = _match_stop_command(commands, turn) + # The runner is restarting from the kill above. A recovery Send issued before it is back up + # gets "All connection attempts failed" and is misread as a product failure. Wait for the + # runner to be healthy again (bounded), THEN send. If it never recovers, do not send a doomed + # request — `runner_recovered` records which happened. + recover_started = time.time() + runner_recovered, t2 = _recover_then_send( + health_poll=hooks.runner_healthy, + send=lambda: invoke( + session_id, + [ + user_msg( + f"The codeword is {marker}. Reply with just the single word READY." + ) + ], + cfg, + references, + "gone-late-turn2", + ), + timeout=RECOVERY_HEALTH_TIMEOUT_S, + poll_interval=RECOVERY_HEALTH_POLL_S, + ) + t2 = t2 or {} + evidence = { + "session_id": session_id, + "turn_id": turn, + "stop": stop, + "seconds_to_settle": round(settled_at - kill_at, 1) if settled_at else None, + "terminal_records": terminal, + "stream_after": session_stream(session_id), + "commands": commands, + "stop_command": stop_command, + "stream_row": stream_row, + "runner_recovered": runner_recovered, + "runner_recover_seconds": round(time.time() - recover_started, 1), + "new_message_ran": bool(t2.get("frames")) and not t2.get("errors"), + "new_message_errors": t2.get("errors"), + } + if not runner_recovered: + return evidence, _fail( + f"runner did not become healthy within {RECOVERY_HEALTH_TIMEOUT_S:.0f}s after the " + "restart; recovery Send not attempted" + ) + return evidence, _judge_runner_gone(evidence) + + +def cell_post_stop_row(cfg, references, args, hooks: OperatorHooks) -> Cell: + """After a Stop the row must read is_running: false within a few seconds.""" + if not hooks.available: + return {}, _skip("no --project given: reading the Postgres row needs psql") + session_id = str(uuid.uuid4()) + marker = f"CEDAR{uuid.uuid4().hex[:6].upper()}" + msgs = [user_msg(sleep_prompt(marker, args.sleep_seconds))] + handle = invoke_async(session_id, msgs, cfg, references, "row-turn1") + turn = wait_for_turn(session_id) + wait_for_tool(handle) + stop = cancel(session_id, expected=turn, label="stop-post-row") + first_false_at = None + deadline = time.time() + 20 + while time.time() < deadline: + row = hooks.stream_row(session_id) + flags = row.get("flags") or {} + if flags.get("is_running") is False: + first_false_at = round(row.get("read_at", time.time()) - stop["sent_at"], 2) + break + time.sleep(0.1) + settle = assert_command_settled(hooks, session_id, turn) + handle["thread"].join(timeout=180) + evidence = { + "session_id": session_id, + "turn_id": turn, + "stop": stop, + "seconds_to_is_running_false": first_false_at, + "command_settled": settle, + } + if first_false_at is None: + return evidence, _fail( + "the Postgres row never read is_running: false within 20 s of the Stop" + ) + if first_false_at > 5: + return evidence, _fail( + f"the row took {first_false_at}s to read is_running: false, expected under 5s" + ) + if not settle["settled"]: + return evidence, _fail(settle["why"]) + return evidence, _pass( + f"the row read is_running: false {first_false_at}s after the Stop" + ) + + +def cell_codex_child(cfg, references, args, hooks: OperatorHooks) -> Cell: + """A stopped Codex turn must not leave its shell child alive in the parked sandbox.""" + if not hooks.available: + return {}, _skip( + "no --project given: reading the runner's process table needs docker exec" + ) + session_id = str(uuid.uuid4()) + codeword = f"DELTA{uuid.uuid4().hex[:6].upper()}" + marker = f"sleep 300.{uuid.uuid4().int % 900000 + 100000}" + msgs = [ + user_msg( + f"The codeword is {codeword}. Run exactly this one shell command and nothing else: {marker}\n" + "Run it in the FOREGROUND and wait for it to finish. Never run it in the background and never " + "append an ampersand. Do not read, write or search any files. When it finishes, reply with the " + "single word DONE." + ) + ] + handle = invoke_async(session_id, msgs, cfg, references, "codex-turn1") + turn = wait_for_turn(session_id, timeout=90) + # The sandbox id this session's turn ledger observed. On daytona, `sandbox_procs` needs this + # to know which remote sandbox to list processes on (DaytonaAwareHooks); on local it is + # unused (docker exec into the runner container sees every local sandbox subprocess). + observed_ids = sandbox_ids(session_id) + target_sandbox_id = observed_ids[-1] if observed_ids else None + child_before = [] + deadline = time.time() + 120 + while time.time() < deadline: + child_before = hooks.sandbox_procs(marker, sandbox_id=target_sandbox_id) + if child_before: + break + if not target_sandbox_id: + observed_ids = sandbox_ids(session_id) + target_sandbox_id = observed_ids[-1] if observed_ids else None + time.sleep(1) + stop = cancel(session_id, expected=turn, label="stop-codex") + settle = assert_command_settled(hooks, session_id, turn) + handle["thread"].join(timeout=180) + gone_at = None + deadline = time.time() + 45 + while time.time() < deadline: + alive = hooks.sandbox_procs(marker, sandbox_id=target_sandbox_id) + if not alive: + gone_at = round(time.time() - stop["sent_at"], 1) + break + time.sleep(1) + time.sleep(4) + t2 = invoke( + session_id, + msgs + [assistant_message(handle["out"] or {}), user_msg(RECALL)], + cfg, + references, + "codex-turn2", + ) + evidence = { + "session_id": session_id, + "turn_id": turn, + "target_sandbox_id": target_sandbox_id, + "child_before_stop": child_before, + "stop": stop, + "seconds_until_child_gone": gone_at, + "resume_recalled_marker": codeword in (t2.get("text") or ""), + "command_settled": settle, + } + if not child_before: + return evidence, _fail( + "never observed the child process before the Stop; the race did not land" + ) + if gone_at is None: + return evidence, _fail("the child process was still alive 45s after the Stop") + if not settle["settled"]: + return evidence, _fail(settle["why"]) + if not evidence["resume_recalled_marker"]: + return evidence, _fail( + "the parked Codex sandbox did not recall the codeword on resume" + ) + return evidence, _pass( + f"the child was reaped {gone_at}s after Stop and the resume recalled the codeword" + ) + + +def cell_stale_tail(cfg, references, args, hooks: OperatorHooks) -> Cell: + """Freeze the runner past the watchdog threshold, thaw it, and read the late tail.""" + if not hooks.available: + return {}, _skip("no --project given: pausing the runner needs docker") + session_id = str(uuid.uuid4()) + marker = f"ELDER{uuid.uuid4().hex[:6].upper()}" + msgs = [ + user_msg( + f"The codeword is {marker}. Run exactly this one shell command and nothing else: sleep 20. When it finishes, reply with the single word DONE." + ) + ] + handle = invoke_async(session_id, msgs, cfg, references, "tail-turn1") + wait_for_turn(session_id) + time.sleep(3) + hooks.pause_runner() + try: + deadline = time.time() + args.sweep_wait + while time.time() < deadline: + if any(r["type"] == "done" for r in hooks.record_rows(session_id)): + break + time.sleep(5) + finally: + # A paused runner left behind strands every cell that runs after this one. Restore it + # even if hooks.record_rows() above raises. + hooks.unpause_runner() + handle["thread"].join(timeout=180) + time.sleep(20) + rows = hooks.record_rows(session_id) + quarantined = [r for r in rows if r["quarantined_at"]] + endpoint = [r.get("record_type") for r in records(session_id)] + evidence = { + "session_id": session_id, + "quarantined": quarantined, + "endpoint_record_types": endpoint, + } + if not quarantined: + return evidence, _fail( + "no late record was quarantined after the runner was thawed past the watchdog window" + ) + if "done" not in endpoint and "error" not in endpoint: + return evidence, _fail( + "the transcript read shows no terminal record after the watchdog fired" + ) + return evidence, _pass( + f"{len(quarantined)} late record(s) quarantined and hidden from the transcript read" + ) + + +def cell_repeat_stop(cfg, references, args, hooks: OperatorHooks) -> Cell: + """Two Stop requests for one execution, 50ms apart. One command effect, one ending.""" + session_id = str(uuid.uuid4()) + marker = f"HAZEL{uuid.uuid4().hex[:6].upper()}" + msgs = [user_msg(sleep_prompt(marker, args.sleep_seconds))] + handle = invoke_async(session_id, msgs, cfg, references, "repeat-turn1") + turn = wait_for_turn(session_id) + wait_for_tool(handle) + results: list = [] + + def fire(label: str) -> None: + results.append(cancel(session_id, expected=turn, label=label)) + + t1 = threading.Thread(target=fire, args=("repeat-stop-a",)) + t1.start() + time.sleep(0.05) + t2 = threading.Thread(target=fire, args=("repeat-stop-b",)) + t2.start() + t1.join() + t2.join() + settle = assert_command_settled(hooks, session_id, turn) + handle["thread"].join(timeout=180) + out = handle["out"] or {} + time.sleep(4) + t3 = invoke( + session_id, + msgs + [assistant_message(out), user_msg(RECALL)], + cfg, + references, + "repeat-turn2", + ) + evidence = { + "session_id": session_id, + "turn_id": turn, + "stops": results, + "terminal_records": terminal_records(session_id, turn), + "resume_recalled_marker": marker in (t3.get("text") or ""), + "command_settled": settle, + } + evidence["sandbox_ids"] = sandbox_ids(session_id) + evidence["warm_same_sandbox"] = len(evidence["sandbox_ids"]) <= 1 + if hooks.available: + evidence["commands"] = hooks.command_rows(session_id) + accepted = [r for r in results if r["status"] in (200, 202)] + if len(accepted) == 0: + return evidence, _fail("neither of the two repeated Stops was accepted") + if len(evidence["terminal_records"]) != 1: + return evidence, _fail( + f"expected exactly one terminal record for the turn, saw {len(evidence['terminal_records'])}" + ) + if not settle["settled"]: + return evidence, _fail(settle["why"]) + if not evidence["resume_recalled_marker"]: + return evidence, _fail( + "resume after the repeated Stop did not recall the codeword" + ) + return evidence, _pass( + "two Stops 50ms apart produced exactly one terminal record and a warm resume" + ) + + +def cell_concurrent_stops(cfg, references, args, hooks: OperatorHooks) -> Cell: + """Five independent sessions, each with a long turn, all Stopped within one second. + + Every Stop must return HTTP 200 or 202, every session must read exactly one terminal record, and + every session must recall its own codeword on a warm resume. HTTP-only: needs no shell. + """ + n = 5 + sessions = [] + for i in range(n): + session_id = str(uuid.uuid4()) + marker = f"NOVA{i}{uuid.uuid4().hex[:5].upper()}" + msgs = [user_msg(sleep_prompt(marker, args.sleep_seconds))] + handle = invoke_async( + session_id, msgs, cfg, references, f"concurrent-turn1-{i}" + ) + sessions.append( + {"session_id": session_id, "marker": marker, "msgs": msgs, "handle": handle} + ) + + for s in sessions: + s["turn_id"] = wait_for_turn(s["session_id"]) + missing_turn = [s["session_id"] for s in sessions if not s["turn_id"]] + if missing_turn: + evidence = {"n": n, "missing_turn_sessions": missing_turn} + return evidence, _fail( + f"{len(missing_turn)} of {n} sessions never reported a running turn" + ) + time.sleep(2) + + def fire(s: dict) -> None: + s["stop"] = cancel( + s["session_id"], + expected=s["turn_id"], + label=f"concurrent-stop-{s['marker']}", + ) + + threads = [threading.Thread(target=fire, args=(s,)) for s in sessions] + fired_at = time.time() + for t in threads: + t.start() + for t in threads: + t.join() + stop_window_s = round(time.time() - fired_at, 3) + + def settle(s: dict) -> None: + s["command_settled"] = assert_command_settled( + hooks, s["session_id"], s["turn_id"] + ) + + settle_threads = [threading.Thread(target=settle, args=(s,)) for s in sessions] + for t in settle_threads: + t.start() + for t in settle_threads: + t.join() + + for s in sessions: + s["handle"]["thread"].join(timeout=180) + s["out"] = s["handle"]["out"] or {} + time.sleep(4) + + def read_terminal(s: dict) -> None: + s["terminal_records"] = _poll_terminal_after_settle( + lambda: terminal_records(s["session_id"], s["turn_id"]) + ) + + terminal_threads = [ + threading.Thread(target=read_terminal, args=(s,)) for s in sessions + ] + for thread in terminal_threads: + thread.start() + for thread in terminal_threads: + thread.join() + + for s in sessions: + msgs2 = s["msgs"] + [assistant_message(s["out"]), user_msg(RECALL)] + t2 = invoke( + s["session_id"], msgs2, cfg, references, f"concurrent-turn2-{s['marker']}" + ) + s["resume_recalled_marker"] = s["marker"] in (t2.get("text") or "") + s["resume_text"] = (t2.get("text") or "")[:200] + + evidence = { + "n": n, + "stop_window_s": stop_window_s, + "sessions": [ + { + "session_id": s["session_id"], + "turn_id": s["turn_id"], + "stop_status": s["stop"]["status"], + "stop_round_trip_s": s["stop"]["round_trip_s"], + "terminal_record_count": len(s["terminal_records"]), + "resume_recalled_marker": s["resume_recalled_marker"], + "command_settled": s["command_settled"], + } + for s in sessions + ], + } + not_accepted = [ + s["session_id"] for s in sessions if s["stop"]["status"] not in (200, 202) + ] + if not_accepted: + return evidence, _fail( + f"{len(not_accepted)} of {n} concurrent Stops did not return HTTP 200 or 202: " + f"{not_accepted}" + ) + unsettled = [ + s["session_id"] for s in sessions if not s["command_settled"]["settled"] + ] + if unsettled: + return evidence, _fail( + f"{len(unsettled)} of {n} sessions did not settle their Stop command within " + f"20s: {unsettled}" + ) + bad_terminal = [ + s["session_id"] for s in sessions if len(s["terminal_records"]) != 1 + ] + if bad_terminal: + return evidence, _fail( + f"{len(bad_terminal)} of {n} sessions did not read exactly one terminal record: " + f"{bad_terminal}" + ) + not_recalled = [ + s["session_id"] for s in sessions if not s["resume_recalled_marker"] + ] + if not_recalled: + return evidence, _fail( + f"{len(not_recalled)} of {n} sessions did not recall their codeword on resume: " + f"{not_recalled}" + ) + return evidence, _pass( + f"all {n} concurrent Stops returned HTTP 200 or 202 within {stop_window_s}s, each session read " + "exactly one terminal record, and each resumed warm with its own codeword" + ) + + +def cell_stop_during_completion(cfg, references, args, hooks: OperatorHooks) -> Cell: + """Stop fired at the moment a short (toolless) turn completes naturally. One committed winner: + obsolete/not_running, or a clean stopped ending — never both, never neither.""" + session_id = str(uuid.uuid4()) + marker = f"IVY{uuid.uuid4().hex[:6].upper()}" + msgs = [ + user_msg(f"The codeword is {marker}. Reply with just the single word READY.") + ] + handle = invoke_async(session_id, msgs, cfg, references, "completion-turn1") + turn = wait_for_turn(session_id) + # Race the natural finish: poll the live frame count and fire the instant it stops growing, + # which is the closest an HTTP-only driver can land on "while the execution completes". + live = handle["live"] + last_len = -1 + stable_since = None + deadline = time.time() + 30 + while time.time() < deadline: + n = len(live.get("frames") or []) + if n == last_len and n > 0: + if stable_since is None: + stable_since = time.time() + elif time.time() - stable_since > 0.05: + break + else: + stable_since = None + last_len = n + if handle["out"] is not None: + break + time.sleep(0.02) + stop = cancel(session_id, expected=turn, label="stop-during-completion") + handle["thread"].join(timeout=60) + out = handle["out"] or {} + time.sleep(3) + terminal = terminal_records(session_id, turn) + stream_after = session_stream(session_id) + t2 = invoke( + session_id, + msgs + [assistant_message(out), user_msg(RECALL)], + cfg, + references, + "completion-turn2", + ) + evidence = { + "session_id": session_id, + "turn_id": turn, + "stop": stop, + "terminal_records": terminal, + "stream_after_flags": (stream_after or {}).get("flags"), + "resume_recalled_marker": marker in (t2.get("text") or ""), + } + evidence["sandbox_ids"] = sandbox_ids(session_id) + evidence["warm_same_sandbox"] = len(evidence["sandbox_ids"]) <= 1 + if len(terminal) > 1: + return evidence, _fail( + f"the race produced {len(terminal)} terminal records for one turn, expected one" + ) + if stop["status"] not in (200, 202, 404, 409): + return evidence, _fail( + f"Stop-at-completion returned an unexpected HTTP {stop['status']}" + ) + if not evidence["resume_recalled_marker"]: + return evidence, _fail( + "the session did not survive the completion race cleanly" + ) + return evidence, _pass( + "Stop racing a natural finish produced exactly one committed ending and a clean resume" + ) + + +# (needs_hooks, permission, fn) +CELLS: dict[str, tuple[bool, str, "object"]] = { + "stop-warm": (False, "allow", cell_stop_warm), + "double-send": (False, "allow", cell_double_send), + "stale-stop": (False, "allow", cell_stale_stop), + "stop-approval": (False, "ask", cell_stop_approval), + "sandbox-gone": (True, "allow", cell_sandbox_gone), + "records-outage": (True, "allow", cell_records_outage), + "stop-after-finish": (False, "allow", cell_stop_after_finish), + "restart-after-stop": (True, "allow", cell_restart_after_stop), + "runner-gone": (True, "allow", cell_runner_gone), + "runner-gone-late": (True, "allow", cell_runner_gone_late), + "post-stop-row": (True, "allow", cell_post_stop_row), + "codex-child": (True, "allow", cell_codex_child), + "stale-tail": (True, "allow", cell_stale_tail), + "repeat-stop": (False, "allow", cell_repeat_stop), + "concurrent-stops": (False, "allow", cell_concurrent_stops), + "stop-during-completion": (False, "allow", cell_stop_during_completion), +} + + +def run_cell( + name: str, fn, cfg, references, args, hooks: OperatorHooks, needs_hooks: bool +) -> dict: + """Run one cell and return its `results["cells"][name]` entry. + + A cell that pauses, stops, or restarts the runner restores it itself in its own `finally` + block (see `cell_stale_tail` and `cell_records_outage`). This is the second, run-level + guarantee: a cell that raises BEFORE its own restore code runs must not strand the runner + paused or down for the cell that runs after it, so the recovery check here runs in a + `finally` block too, no matter how the cell ends. + """ + started = time.time() + try: + evidence, verdict = fn(cfg, references, args, hooks) + except Exception as exc: # noqa: BLE001 + import traceback + + evidence = { + "driver_error": f"{type(exc).__name__}: {exc}", + "traceback": traceback.format_exc()[-1500:], + } + verdict = _fail(f"driver exception: {type(exc).__name__}: {exc}") + finally: + if needs_hooks and hooks.available: + try: + recovery = hooks.ensure_runner_healthy() + except Exception as exc: # noqa: BLE001 + print(f"[{name}] runner-health recovery failed: {exc}", file=sys.stderr) + else: + if ( + recovery.get("was_paused") + or recovery.get("status_before") != "running" + ): + print(f"[{name}] recovered the runner: {recovery}", file=sys.stderr) + if recovery.get("healthy_after_s") is None: + print( + f"[{name}] WARNING: the runner did not report healthy after recovery", + file=sys.stderr, + ) + elapsed = round(time.time() - started, 1) + verdict_str = "SKIP" if verdict["skip"] else ("PASS" if verdict["pass"] else "FAIL") + print(f"[{name}] {verdict_str} — {verdict['why']}", file=sys.stderr) + return {"evidence": evidence, "verdict": verdict, "elapsed_s": elapsed} + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--harness", default="pi_core", choices=sorted(HARNESSES)) + ap.add_argument("--cells", default="all", help="comma separated, or 'all'") + ap.add_argument("--sleep-seconds", type=int, default=45) + ap.add_argument("--sweep-wait", type=float, default=240.0) + ap.add_argument( + "--project", + default=None, + help="docker-compose project name; enables the shell-only cells", + ) + ap.add_argument("--sandbox", default="local", choices=["local", "daytona"]) + ap.add_argument( + "--durable-stop", + default="auto", + choices=["on", "off", "auto"], + help=( + "durable Stop feature state. auto (default) detects command+execution responses " + "as on and legacy cancellation-summary responses as off" + ), + ) + ap.add_argument( + "--client-shape", + default="full", + choices=["full", "last-message"], + help=( + "full (default) replays the whole transcript on every send, keeping results " + "comparable with prior runs. last-message sends only the new user message on " + "every resume and follow-up, the way the desktop client does (agentRequest.ts) — " + "use it to catch continuity bugs the full transcript masks." + ), + ) + ap.add_argument( + "--resume", + default=None, + help="path to a prior run's results.json; cells already recorded there are loaded, not re-run", + ) + args = ap.parse_args() + + wanted = ( + list(CELLS) + if args.cells == "all" + else [c.strip() for c in args.cells.split(",") if c.strip()] + ) + unknown = [c for c in wanted if c not in CELLS] + if unknown: + raise SystemExit(f"unknown cells: {unknown}; known: {sorted(CELLS)}") + + resolve_env() + hooks = select_hooks(args.project, args.sandbox) + if args.sandbox == "daytona": + global SANDBOX_STARTUP_SLACK_S + SANDBOX_STARTUP_SLACK_S = 25.0 + global CLIENT_SHAPE + CLIENT_SHAPE = args.client_shape + global DURABLE_STOP_OPTION, DURABLE_STOP_STATE + DURABLE_STOP_OPTION = args.durable_stop + DURABLE_STOP_STATE = ( + args.durable_stop if args.durable_stop in ("on", "off") else None + ) + + prior: dict = {} + if args.resume: + prior_path = pathlib.Path(args.resume).expanduser() + if prior_path.exists(): + prior = json.loads(prior_path.read_text()).get("cells", {}) + print( + f"[resume] loaded {len(prior)} cell result(s) from {prior_path}", + file=sys.stderr, + ) + + bootstrap(args.harness) + spec = HARNESSES[args.harness] + base_cfg = agent_config( + spec["kind"], spec["model"], spec["provider"], spec["connection"], args.sandbox + ) + built: dict = {} + + def config_for(permission: str): + if permission not in built: + cfg = json.loads(json.dumps(base_cfg)) + cfg["runner"] = {"permissions": {"default": permission}} + built[permission] = ( + cfg, + create_revision(cfg, f"session-control-{args.sandbox}-{permission}"), + ) + return built[permission] + + # PID, not just the second-resolution timestamp: two invocations started in the same second + # (e.g. two harnesses smoke-tested in parallel) would otherwise share a folder and the + # second writer silently clobbers the first one's results.json mid-run. + stamp = time.strftime("%Y%m%d-%H%M%S") + outdir = RUNS / f"{stamp}-{os.getpid()}-session-control" + outdir.mkdir(parents=True, exist_ok=True) + + results: dict = { + "project_id": STATE["project_id"], + "harness": args.harness, + "sandbox": args.sandbox, + "client_shape": args.client_shape, + "durable_stop": { + "option": args.durable_stop, + "state": DURABLE_STOP_STATE, + }, + "cells": {}, + } + for name in wanted: + if name in prior: + print( + f"[{name}] resumed from prior run: {prior[name]['verdict']['pass'] and 'PASS' or (prior[name]['verdict']['skip'] and 'SKIP' or 'FAIL')}", + file=sys.stderr, + ) + results["cells"][name] = prior[name] + results["durable_stop"]["state"] = DURABLE_STOP_STATE + (outdir / "results.json").write_text( + json.dumps(results, indent=2, default=str) + ) + continue + needs_hooks, permission, fn = CELLS[name] + cfg, references = config_for(permission) + print(f"\n=== cell {name} ===", file=sys.stderr) + results["cells"][name] = run_cell( + name, fn, cfg, references, args, hooks, needs_hooks + ) + results["durable_stop"]["state"] = DURABLE_STOP_STATE + (outdir / "results.json").write_text(json.dumps(results, indent=2, default=str)) + + lines = ["| cell | verdict | why |", "|---|---|---|"] + for name, r in results["cells"].items(): + v = r["verdict"] + verdict_str = "SKIP" if v["skip"] else ("PASS" if v["pass"] else "FAIL") + lines.append(f"| {name} | {verdict_str} | {v['why']} |") + table = "\n".join(lines) + (outdir / "summary.md").write_text(table + "\n") + print("\n" + table) + print(f"\nresults: {outdir}") + + failed = any( + not r["verdict"]["skip"] and not r["verdict"]["pass"] + for r in results["cells"].values() + ) + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/skills/agent-release-gate/resources/test_custom_secret_gate.py b/.agents/skills/agent-release-gate/resources/test_custom_secret_gate.py new file mode 100644 index 00000000000..ab0b05ec5e3 --- /dev/null +++ b/.agents/skills/agent-release-gate/resources/test_custom_secret_gate.py @@ -0,0 +1,17 @@ +from __future__ import annotations + + +from path_triggers import mandatory_cells + + +def test_custom_secret_paths_require_the_live_cell(): + for path in ( + "api/oss/src/core/secrets/services.py", + "api/oss/src/apis/fastapi/workflows/router.py", + "sdks/python/agenta/sdk/agents/sandbox_credentials.py", + "sdks/python/agenta/sdk/agents/handler.py", + "services/runner/src/lifecycle/desired-state.ts", + "services/runner/src/engines/sandbox_agent/sandbox-credentials.ts", + "services/runner/src/redaction.ts", + ): + assert "matrix_s1_custom_secrets.py" in mandatory_cells([path]) diff --git a/.agents/skills/agent-release-gate/resources/test_qa_product_concurrency.py b/.agents/skills/agent-release-gate/resources/test_qa_product_concurrency.py new file mode 100644 index 00000000000..4638008b609 --- /dev/null +++ b/.agents/skills/agent-release-gate/resources/test_qa_product_concurrency.py @@ -0,0 +1,1002 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["httpx>=0.27", "pytest>=8"] +# /// +"""Offline tests for the `burst` and `crosstalk` journeys. No deployment, no network. + +Run either way: + + uv run test_qa_product_concurrency.py # standalone, runs through pytest + uv run --no-sync pytest test_qa_product_concurrency.py + +Every case fakes the wire. `invoke` is replaced with a function that builds a `Turn` by hand, so +the tests pin the JOURNEY's reasoning: what it calls a failure, what it refuses to call a pass, +and what it does when a stream never ends. The one thing they cannot check is whether the product +works, which is what the live gate is for. +""" + +import gzip +import importlib +import json +import os +import sys +import threading +import time +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent + +CELL = {"harness": "pi_core", "sandbox": "daytona", "model": "m", "provider": "openai"} + + +def _qa(): + os.environ.setdefault("AGENTA_BASE", "https://qa.example") + os.environ.setdefault("AGENTA_PROJECT_ID", "proj-1") + os.environ.setdefault("AGENTA_API_KEY", "test-key") + sys.path.insert(0, str(HERE)) + return importlib.import_module("qa_product") + + +qa = _qa() +# The real functions, so a case that replaced one cannot leak into the next. +_REAL_INVOKE = qa.invoke +_REAL_LEDGER_IDS = qa._ledger_ids + + +def _reset(): + """Small, fast defaults so a test never waits on a real bound.""" + qa.invoke = _REAL_INVOKE + qa._ledger_ids = _REAL_LEDGER_IDS + qa.BURST_SIZE = 3 + qa.CROSSTALK_CONVERSATIONS = 2 + qa.CROSSTALK_APPROVALS = 0 + qa.CONCURRENCY_EVERYWHERE = False + qa.CONCURRENCY_TURN_TIMEOUT_SECONDS = 30.0 + qa.CONCURRENCY_WAIT_MARGIN_SECONDS = 5.0 + qa.LEDGER_POLL_SECONDS = 0.0 + qa.LEDGER_SETTLE_SECONDS = 0.0 + qa._ledger_ids = lambda session: (["agent-1"], ["sandbox-1"]) + + +def _turn(reply="", deltas=40, code=None, finish="stop", hung=False): + t = qa.Turn() + t.http_status, t.ms = 200, 12 + t.finish_reason = finish + t.frames = ["start"] + ["text-delta"] * deltas + ["finish"] + t.text = [reply] + if code: + t.error_codes.append(code) + t.error_texts.append("model authentication failed: 401 unauthorized") + t.errors.append('{"type": "error"}') + t.finish_reason = "error" + if hung: + t.hung = True + t.hung_reason = qa.HUNG_AT_DEADLINE + t.finish_reason = None + return t + + +def _nonce_of(messages): + text = messages[-1]["parts"][0]["text"] + tokens = [w for w in text.replace("\n", " ").split() if w.startswith("QA-")] + return tokens[-1] if tokens else "" + + +def _long(nonce, lines=150): + return "\n".join(str(n) for n in range(1, lines + 1)) + "\n" + nonce + + +def _healthy(session, messages, params, timeout=300.0, deadline=None): + return _turn(_long(_nonce_of(messages))) + + +# --------------------------------------------------------------------------- + + +def test_burst_passes_when_every_cold_start_answers(): + _reset() + qa.invoke = _healthy + r = qa.j_burst(CELL) + assert r["pass"], r + assert r["total"] == 3 and len(r["runs"]) == 3 + assert all(run["session_id"] for run in r["runs"]), r["runs"] + # A pass states its own sampling power rather than implying the fault is gone. + assert "probabilistic" in r["why"], r["why"] + + +def test_burst_names_the_credential_code_and_fails(): + _reset() + state = {"n": 0} + + def flaky(session, messages, params, timeout=300.0, deadline=None): + state["n"] += 1 + if state["n"] == 2: + return _turn(code="credential_delivery_failed") + return _healthy(session, messages, params) + + qa.invoke = flaky + r = qa.j_burst(CELL) + assert not r["pass"] + assert "CREDENTIAL DELIVERY FAILED" in r["why"], r["why"] + assert "credential_delivery_failed" in r["error_codes"] + assert any(run["error_code"] == "credential_delivery_failed" for run in r["runs"]) + + +def test_burst_fails_on_a_turn_that_only_errored(): + """An error-only turn has no reply, no stop reason and no nonce. It is never a pass.""" + _reset() + qa.invoke = lambda s, m, p, timeout=300.0, deadline=None: _turn( + code="runner_error", finish="error" + ) + r = qa.j_burst(CELL) + assert not r["pass"] and r["failed"] == 3, r + assert all(run["ok"] is False for run in r["runs"]) + assert "AUTHENTICATION" in r["why"], r["why"] + + +def test_burst_fails_on_nonce_bleed(): + _reset() + seen = {} + + def bleeding(session, messages, params, timeout=300.0, deadline=None): + t = _healthy(session, messages, params) + seen.setdefault("first", _nonce_of(messages)) + t.text = [t.reply + "\n" + seen["first"]] + return t + + qa.invoke = bleeding + r = qa.j_burst(CELL) + assert not r["pass"] and r["nonce_bleed"], r + + +def test_a_stream_that_never_ends_is_abandoned_not_followed(): + """The MUST from the review: a job that never returns must not hold the journey open. + + The fake `invoke` here ignores its deadline entirely and blocks forever, which is the worst + case (a real stream that keeps emitting bytes never trips an HTTPX read timeout either). The + journey has to come back on its own, mark the runs hung, and fail. + """ + _reset() + qa.BURST_SIZE = 2 + qa.CONCURRENCY_TURN_TIMEOUT_SECONDS = 1.0 + qa.CONCURRENCY_WAIT_MARGIN_SECONDS = 1.0 + release = threading.Event() + + def never_ends(session, messages, params, timeout=300.0, deadline=None): + release.wait(120) + return _turn("too late") + + qa.invoke = never_ends + started = time.monotonic() + r = qa.j_burst(CELL) + took = time.monotonic() - started + release.set() + assert not r["pass"], r + assert took < 10, f"the journey waited {took:.1f}s for a job that never returns" + assert all(run.get("hung") for run in r["runs"]), r["runs"] + assert all(run.get("session_id") for run in r["runs"]), ( + "a hung run still names its session" + ) + assert all(run.get("phase") == "turn" for run in r["runs"]), r["runs"] + assert "Abandoned at the deadline" in r["why"], r["why"] + + +def test_invoke_marks_a_turn_hung_at_its_deadline(): + """The same guarantee one level down: a well-formed stream that simply never ends.""" + _reset() + response = _RawResponse([b'data: {"type": "text-delta", "delta": "x"}\n']) + real = qa.httpx.Client + qa.httpx.Client = _fake_httpx(response) + try: + t = qa.invoke( + "s", [qa.user_msg("hi")], {}, timeout=30.0, deadline=time.monotonic() + 0.3 + ) + finally: + qa.httpx.Client = real + assert t.hung and t.hung_reason == qa.HUNG_AT_DEADLINE, (t.hung, t.hung_reason) + assert t.summary()["hung"] is True + assert t.frames, "the frames it did receive are kept" + assert response.closed + + +def test_crosstalk_requires_the_long_reply_it_asked_for(): + _reset() + qa.invoke = lambda s, m, p, timeout=300.0, deadline=None: _turn( + _nonce_of(m), deltas=40 + ) + r = qa.j_crosstalk(CELL) + assert not r["pass"] and r["too_short"], r + assert "below the size the prompt asked for" in r["why"], r["why"] + + +def test_crosstalk_nonce_exclusivity_covers_the_other_turn_of_the_same_conversation(): + _reset() + qa.CROSSTALK_CONVERSATIONS = 1 + first = {} + + def echo_both(session, messages, params, timeout=300.0, deadline=None): + nonce = _nonce_of(messages) + first.setdefault("a", nonce) + # Turn 2 repeats turn 1's nonce. Same session, so a per-session check would miss it. + return _turn(_long(nonce) + "\n" + first["a"]) + + qa.invoke = echo_both + r = qa.j_crosstalk(CELL) + assert not r["pass"] and r["nonce_bleed"], r + + +def test_crosstalk_records_warm_reuse_without_failing_on_it(): + """A rebuilt sandbox is evidence here, never a verdict: the preflight rebuilds on purpose.""" + _reset() + qa.invoke = _healthy + qa._ledger_ids = lambda session: (["agent-1"], ["sandbox-1", "sandbox-2"]) + r = qa.j_crosstalk(CELL) + assert r["pass"], r + warm = r["runs"][0]["warm_reuse"] + assert warm["ids_stable"] is False and warm["sandbox_ids"] == [ + "sandbox-1", + "sandbox-2", + ] + assert "not_warm" not in r + + +def test_crosstalk_records_a_one_row_ledger_as_stable_and_still_passes(): + _reset() + qa.invoke = _healthy + qa._ledger_ids = lambda session: (["agent-1"], ["sandbox-1"]) + r = qa.j_crosstalk(CELL) + assert r["pass"], r + assert r["runs"][0]["warm_reuse"]["ids_stable"] is True + + +def test_crosstalk_records_an_empty_ledger_without_failing(): + _reset() + qa.invoke = _healthy + qa._ledger_ids = lambda session: ([], []) + r = qa.j_crosstalk(CELL) + assert r["pass"], r + warm = r["runs"][0]["warm_reuse"] + assert warm["ledger_rows_seen"] is False and warm["ids_stable"] is False + + +def test_each_approval_flow_proves_its_own_output(): + """Approval isolation: every flow carries its own nonce and must not see another's.""" + _reset() + qa.CROSSTALK_CONVERSATIONS = 0 + qa.CROSSTALK_APPROVALS = 2 + flows: dict = {} + + def gated(session, messages, params, timeout=300.0, deadline=None): + text = messages[0]["parts"][0]["text"] + nonce = [w for w in text.split() if w.startswith("QA-XTAP")][0] + if session not in flows: # turn 1: park the gate + flows[session] = nonce + t = _turn(finish="other", deltas=0) + t.tool_calls = [ + {"toolCallId": f"c-{nonce}", "toolName": "Bash", "input": {"c": nonce}} + ] + t._segments = [{"kind": "tool", "id": f"c-{nonce}"}] + t.approval = {"approvalId": f"a-{nonce}", "toolCallId": f"c-{nonce}"} + return t + t = _turn(nonce, deltas=3) # turn 2: the approved command ran + t.tool_calls = [ + {"toolCallId": f"c-{nonce}", "toolName": "Bash", "input": {"c": nonce}} + ] + t.tool_outcomes = {f"c-{nonce}": "available"} + t.tool_payloads = {f"c-{nonce}": {"output": nonce}} + return t + + qa.invoke = gated + r = qa.j_crosstalk(CELL) + assert r["pass"], r + records = [run for run in r["runs"] if run["kind"] == "approval"] + assert len(records) == 2 + for run in records: + assert run["own_nonce_in_output"] is True and not run["other_nonces_in_output"] + assert run["nonce_checked"] is True + assert run["turn_paused"] and run["turn_resumed"], "both turns are persisted" + assert run["started_s"] is not None and run["ended_s"] is not None + + # Now make one flow echo the OTHER flow's nonce, from the same journey run. The first flow + # to arrive publishes its nonce; the second one appends it, which is a foreign nonce in its + # output and must fail the journey. + flows.clear() + real = qa.invoke + published: dict = {} + + def leaky(session, messages, params, timeout=300.0, deadline=None): + text = messages[0]["parts"][0]["text"] + nonce = [w for w in text.split() if w.startswith("QA-XTAP")][0] + published.setdefault("first", nonce) + t = real(session, messages, params, timeout, deadline) + for payload in t.tool_payloads.values(): + payload["output"] = f"{payload['output']} {published['first']}" + return t + + qa.invoke = leaky + r = qa.j_crosstalk(CELL) + assert not r["pass"] and r["nonce_bleed"], r + + +def test_a_capacity_refusal_is_a_skip_not_a_verdict(): + _reset() + + def out_of_disk(session, messages, params, timeout=300.0, deadline=None): + t = qa.Turn() + t.http_status = 200 + t.errors.append("sandbox create failed: Total disk limit exceeded") + return t + + qa.invoke = out_of_disk + r = qa.j_burst(CELL) + assert r.get("skip") and "ENVIRONMENT, NOT THE PRODUCT" in r["why"], r + assert "pass" not in r + + +def test_an_internal_rate_limit_is_still_a_failure(): + """The capacity SKIP must never swallow `rate_limited`: that is a real finding.""" + _reset() + qa.invoke = lambda s, m, p, timeout=300.0, deadline=None: _turn(code="rate_limited") + r = qa.j_burst(CELL) + assert not r.get("skip") and r["pass"] is False, r + assert "rate_limited" in r["error_codes"] + + +def test_results_carry_no_key_material(): + _reset() + + def leaks_a_key(session, messages, params, timeout=300.0, deadline=None): + t = qa.Turn() + t.http_status = 200 + t.errors.append( + "401 from the provider: Incorrect API key provided: " + "sk-proj-3M1PW0OPU17zAxPi4wTT33ec5L3Tqfq" + ) + return t + + qa.invoke = leaks_a_key + r = qa.j_burst(CELL) + blob = str(r) + assert "sk-" in blob, blob[:300] + assert "sk-proj-3M1PW" not in blob + + +def test_local_cells_skip_unless_asked(): + _reset() + qa.invoke = _healthy + local = dict(CELL, sandbox="local") + assert qa.j_burst(local).get("skip") and qa.j_crosstalk(local).get("skip") + qa.CONCURRENCY_EVERYWHERE = True + assert not qa.j_burst(local).get("skip") + + +def test_a_runner_path_change_makes_the_journeys_mandatory(): + """Trigger + --only: naming the cell is not enough, the journey has to be forced too.""" + sys.path.insert(0, str(HERE)) + triggers = importlib.import_module("path_triggers") + paths = ["services/runner/src/engines/sandbox_agent/daytona-secrets.ts"] + cells = triggers.mandatory_cells(paths) + journeys = triggers.mandatory_journeys(paths) + assert set(cells) >= {"C2", "C4", "X2"}, cells + assert set(journeys) == {"burst", "crosstalk"}, journeys + assert journeys["burst"] == paths + # The provider path fires the same rule. + assert set( + triggers.mandatory_journeys( + ["services/runner/src/providers/daytona-credential-delivery.ts"] + ) + ) == {"burst", "crosstalk"} + # An unrelated change forces nothing. + assert triggers.mandatory_journeys(["web/oss/src/app/page.tsx"]) == {} + + +def _session_control_result(status="PASS"): + import session_control + + return { + "cells": { + name: { + "verdict": { + "pass": status == "PASS", + "skip": status == "SKIP", + "why": status.lower(), + } + } + for name in session_control.CELLS + } + } + + +def test_session_control_result_consumer_accepts_a_complete_pass(tmp_path): + path = tmp_path / "results.json" + path.write_text(json.dumps(_session_control_result())) + + result = qa._load_session_control_result(str(path)) + + assert result["status"] == "PASS" + assert result["failed"] == [] + assert result["skipped"] == [] + + +def test_session_control_result_consumer_carries_a_failure(tmp_path): + payload = _session_control_result() + payload["cells"]["stop-warm"]["verdict"] = { + "pass": False, + "skip": False, + "why": "regression", + } + path = tmp_path / "results.json" + path.write_text(json.dumps(payload)) + + result = qa._load_session_control_result(str(path)) + + assert result["status"] == "FAIL" + assert result["failed"] == ["stop-warm"] + + +def test_session_control_result_consumer_marks_skips_incomplete(tmp_path): + path = tmp_path / "results.json" + path.write_text(json.dumps(_session_control_result("SKIP"))) + + result = qa._load_session_control_result(str(path)) + + assert result["status"] == "INCOMPLETE" + assert result["skipped"] + label = qa._session_control_result_label(result) + assert "SKIPPED, UNTESTED" in label + assert result["skipped"][0] in label + + +def test_session_control_result_consumer_rejects_an_incomplete_run(tmp_path): + payload = _session_control_result() + del payload["cells"]["stop-warm"] + path = tmp_path / "results.json" + path.write_text(json.dumps(payload)) + + with pytest.raises(SystemExit, match="missing cells: stop-warm"): + qa._load_session_control_result(str(path)) + + +def test_driver_requires_mandatory_session_control_results(monkeypatch): + monkeypatch.setattr( + sys, + "argv", + [ + "qa_product.py", + "--cell", + "C3", + "--only", + "chat", + "--changed-path", + "api/oss/src/core/sessions/service.py", + ], + ) + + with pytest.raises(SystemExit, match="session_control.py mandatory"): + qa.main() + + +def test_driver_fails_for_a_failed_session_control_result(monkeypatch, tmp_path): + payload = _session_control_result() + payload["cells"]["stop-warm"]["verdict"] = { + "pass": False, + "skip": False, + "why": "regression", + } + result_path = tmp_path / "session-control-results.json" + result_path.write_text(json.dumps(payload)) + monkeypatch.setattr(qa, "RUNS", tmp_path / "runs") + monkeypatch.setitem(qa.JOURNEYS, "chat", lambda _cell: {"pass": True, "why": "ok"}) + monkeypatch.setattr( + sys, + "argv", + [ + "qa_product.py", + "--cell", + "C3", + "--only", + "chat", + "--changed-path", + "api/oss/src/core/sessions/service.py", + "--session-control-results", + str(result_path), + ], + ) + + assert qa.main() == 1 + + +def test_driver_fails_for_a_skipped_session_control_result(monkeypatch, tmp_path): + result_path = tmp_path / "session-control-results.json" + result_path.write_text(json.dumps(_session_control_result("SKIP"))) + monkeypatch.setattr(qa, "RUNS", tmp_path / "runs") + monkeypatch.setitem(qa.JOURNEYS, "chat", lambda _cell: {"pass": True, "why": "ok"}) + monkeypatch.setattr( + sys, + "argv", + [ + "qa_product.py", + "--cell", + "C3", + "--only", + "chat", + "--changed-path", + "api/oss/src/core/sessions/service.py", + "--session-control-results", + str(result_path), + ], + ) + + assert qa.main() == 1 + + +def test_the_driver_forces_a_mandatory_journey_past_only(tmp_path=None): + """End to end through main(), with every journey stubbed out.""" + import tempfile + + _reset() + ran: list = [] + real_journeys = dict(qa.JOURNEYS) + qa.JOURNEYS.update( + { + name: ( + lambda cell, name=name: (ran.append(name), {"pass": True, "why": name})[ + 1 + ] + ) + for name in ("chat", "burst", "crosstalk") + } + ) + outdir = tempfile.mkdtemp() + session_control_results = Path(outdir) / "session-control-results.json" + session_control_results.write_text(json.dumps(_session_control_result())) + argv = sys.argv + runs_dir = qa.RUNS + sys.argv = [ + "qa_product.py", + "--cell", + "C4", + "--only", + "chat", + "--changed-path", + "services/runner/src/engines/sandbox_agent/daytona-secrets.ts", + "--session-control-results", + str(session_control_results), + ] + qa.RUNS = Path(outdir) + try: + qa.main() + finally: + sys.argv = argv + qa.RUNS = runs_dir + qa.JOURNEYS.clear() + qa.JOURNEYS.update(real_journeys) + assert set(ran) >= {"chat", "burst", "crosstalk"}, ran + written = sorted(Path(outdir).glob("*/mandatory-journeys.json")) + assert written, "the run records which journeys a rule forced" + assert set(json.loads(written[0].read_text())) == {"burst", "crosstalk"} + + +def test_counts_are_capped(): + argv = sys.argv + sys.argv = ["qa_product.py", "--cell", "C4", "--burst-size", "99"] + try: + qa.main() + raise AssertionError("a 99-run burst must be refused") + except SystemExit as e: + assert "capped at 32" in str(e), e + finally: + sys.argv = argv + + +def _fake_httpx(response): + """A stand-in httpx.Client whose stream() returns `response` and records that it was asked.""" + + class _Client: + def __init__(self, timeout=None): + self.timeout = timeout + response.timeout = timeout + + def stream(self, *a, **k): + response.stream_started = True + # Request setup is not free. A fake that returns instantly cannot show what happens + # when connecting and sending have already spent the turn's budget. + time.sleep(response.setup_sleep) + return response + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + return _Client + + +def _gunzip(chunk: bytes) -> bytes: + """Decode a chunk the way HTTPX would, so the fake models the real contract.""" + if chunk[:2] == b"\x1f\x8b": + return gzip.decompress(chunk) + return chunk + + +class _RawResponse: + """A streaming response whose chunks, status and setup cost the test controls.""" + + def __init__( + self, + chunks, + sleep=0.01, + raise_timeout=False, + repeat=True, + setup_sleep=0.0, + status_code=200, + ): + self._chunks = chunks + self._sleep = sleep + self._raise_timeout = raise_timeout + self._repeat = repeat + self.setup_sleep = setup_sleep + self.status_code = status_code + self.closed = False + self.timeout = None + self.stream_started = False + self.reads = 0 + self.body_reads = 0 + + def read(self): + """The error-body read. Counted, so a test can prove it never happened.""" + self.body_reads += 1 + return b"upstream said no" + + def _emit(self, chunks): + self.reads += 1 + if self._raise_timeout: + time.sleep(self._sleep) + raise qa.httpx.ReadTimeout("read timed out") + while True: + for chunk in chunks: + yield chunk + time.sleep(self._sleep) + if not self._repeat: + return + + def iter_bytes(self): + """What HTTPX yields AFTER decoding Content-Encoding. This is what the driver reads.""" + return self._emit([_gunzip(c) for c in self._chunks]) + + def iter_raw(self): + """The bytes exactly as they arrived. Compressed, when the server gzipped them.""" + return self._emit(self._chunks) + + def close(self): + self.closed = True + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + +def test_a_stream_without_newlines_still_hits_the_deadline(): + """`iter_lines()` only yields on a newline, so the check has to sit on raw chunks.""" + _reset() + response = _RawResponse([b"data: {partial without any newline"]) + real = qa.httpx.Client + qa.httpx.Client = _fake_httpx(response) + try: + started = time.monotonic() + t = qa.invoke( + "s", [qa.user_msg("hi")], {}, timeout=30.0, deadline=time.monotonic() + 0.3 + ) + took = time.monotonic() - started + finally: + qa.httpx.Client = real + assert t.hung and t.hung_reason == qa.HUNG_AT_DEADLINE, (t.hung, t.hung_reason) + assert took < 5, f"a newline-free stream held the turn for {took:.1f}s" + assert response.closed, "the abandoned response is closed, not left open" + + +def test_a_deadline_with_milliseconds_left_never_starts_the_request(): + """The floor is HTTPX's minimum positive timeout, not a grant of 50ms.""" + _reset() + response = _RawResponse([b'data: {"type": "finish", "finishReason": "stop"}\n']) + real = qa.httpx.Client + qa.httpx.Client = _fake_httpx(response) + try: + started = time.monotonic() + t = qa.invoke( + "s", + [qa.user_msg("hi")], + {}, + timeout=30.0, + deadline=time.monotonic() + 0.005, + ) + took = time.monotonic() - started + finally: + qa.httpx.Client = real + assert t.hung and t.hung_reason == qa.HUNG_AT_DEADLINE, t.summary() + assert response.stream_started is False, ( + "an unaffordable request was started anyway" + ) + assert took < qa.DEADLINE_FLOOR_SECONDS, f"returned after {took * 1000:.0f}ms" + + +def test_a_gzip_encoded_stream_is_decoded_and_parsed(): + """`iter_raw()` would hand over compressed bytes: zero frames, no finish, no error.""" + _reset() + body = ( + b'data: {"type": "text-delta", "delta": "hello"}\n' + b'data: {"type": "finish", "finishReason": "stop"}\n' + ) + response = _RawResponse([gzip.compress(body)], sleep=0.0, repeat=False) + real = qa.httpx.Client + qa.httpx.Client = _fake_httpx(response) + try: + t = qa.invoke("s", [qa.user_msg("hi")], {}, timeout=30.0) + finally: + qa.httpx.Client = real + assert t.reply == "hello", t.summary() + assert t.finish_reason == "stop", t.summary() + assert not t.hung + + +def test_setup_that_eats_the_budget_means_no_read_is_started(): + """The deadline passes between the request starting and the first read.""" + _reset() + response = _RawResponse( + [b'data: {"type": "finish", "finishReason": "stop"}\n'], setup_sleep=0.25 + ) + real = qa.httpx.Client + qa.httpx.Client = _fake_httpx(response) + try: + t = qa.invoke( + "s", [qa.user_msg("hi")], {}, timeout=30.0, deadline=time.monotonic() + 0.15 + ) + finally: + qa.httpx.Client = real + assert response.stream_started is True, ( + "the request itself was affordable when it began" + ) + assert t.hung and t.hung_reason == qa.HUNG_AT_DEADLINE, t.summary() + assert response.reads == 0, "a read was started with no time left to pay for it" + assert t.frames == [], t.frames + + +def test_an_error_body_is_not_read_past_the_deadline(): + """`r.read()` is a read like any other and needs the same check in front of it.""" + _reset() + response = _RawResponse([], setup_sleep=0.25, status_code=500) + real = qa.httpx.Client + qa.httpx.Client = _fake_httpx(response) + try: + t = qa.invoke( + "s", [qa.user_msg("hi")], {}, timeout=30.0, deadline=time.monotonic() + 0.15 + ) + finally: + qa.httpx.Client = real + assert t.http_status == 500 + assert t.hung and t.hung_reason == qa.HUNG_AT_DEADLINE, t.summary() + assert response.body_reads == 0, "the error body was read with no time left" + assert response.closed + + +def test_an_error_body_is_still_read_when_there_is_time(): + _reset() + response = _RawResponse([], status_code=503) + real = qa.httpx.Client + qa.httpx.Client = _fake_httpx(response) + try: + t = qa.invoke("s", [qa.user_msg("hi")], {}, timeout=30.0) + finally: + qa.httpx.Client = real + assert t.http_status == 503 and response.body_reads == 1 + assert not t.hung and "HTTP 503" in t.errors[0], t.errors + + +def test_a_silent_read_past_the_deadline_is_hung_not_a_crash(): + """No bytes at all: HTTPX raises its read timeout, and that IS the deadline.""" + _reset() + response = _RawResponse([], sleep=0.05, raise_timeout=True) + real = qa.httpx.Client + qa.httpx.Client = _fake_httpx(response) + try: + t = qa.invoke( + "s", [qa.user_msg("hi")], {}, timeout=30.0, deadline=time.monotonic() + 0.2 + ) + finally: + qa.httpx.Client = real + assert t.hung and t.hung_reason == qa.HUNG_AT_DEADLINE, t.summary() + # The client was never granted the configured 30s: only the time that was left. + assert response.timeout is not None and response.timeout <= 0.3, response.timeout + + +def test_a_read_timeout_without_a_deadline_still_raises(): + """No deadline means the caller asked for the old behaviour, and gets it.""" + _reset() + response = _RawResponse([], sleep=0.0, raise_timeout=True) + real = qa.httpx.Client + qa.httpx.Client = _fake_httpx(response) + try: + qa.invoke("s", [qa.user_msg("hi")], {}, timeout=1.0) + raise AssertionError("a read timeout with no deadline must propagate") + except qa.httpx.ReadTimeout: + pass + finally: + qa.httpx.Client = real + + +def test_a_capacity_refusal_beside_a_product_failure_is_a_failure(): + """The MUST: one out-of-disk run must never excuse a real fault in the same journey.""" + _reset() + qa.BURST_SIZE = 2 + state = {"n": 0} + + def mixed(session, messages, params, timeout=300.0, deadline=None): + state["n"] += 1 + t = qa.Turn() + t.http_status = 200 + if state["n"] == 1: + t.errors.append("sandbox create failed: Total disk limit exceeded") + else: + return _turn(code="rate_limited") + return t + + qa.invoke = mixed + r = qa.j_burst(CELL) + assert not r.get("skip"), "a product failure was hidden behind a capacity refusal" + assert r["pass"] is False + assert r["product_failures"] == 1 and len(r["capacity_refusals"]) == 1, r + assert "capacity refusal" in r["why"] and "product failure" in r["why"], r["why"] + + +def test_a_hung_approval_fails(): + _reset() + qa.CROSSTALK_CONVERSATIONS = 0 + qa.CROSSTALK_APPROVALS = 1 + seen: dict = {} + + def gated_then_hung(session, messages, params, timeout=300.0, deadline=None): + if session not in seen: # turn 1 parks the gate + seen[session] = True + t = _turn(finish="other", deltas=0) + t.tool_calls = [{"toolCallId": "c1", "toolName": "Bash", "input": {}}] + t._segments = [{"kind": "tool", "id": "c1"}] + t.approval = {"approvalId": "a1", "toolCallId": "c1"} + return t + t = _turn(deltas=1, hung=True) # the resume was abandoned at the deadline + t.tool_calls = [{"toolCallId": "c1", "toolName": "Bash", "input": {}}] + t.tool_outcomes = {"c1": "available"} + return t + + qa.invoke = gated_then_hung + r = qa.j_crosstalk(CELL) + assert not r["pass"], r + record = r["runs"][0] + assert record["hung"] is True and record["ok"] is False, record + + +def _deny_flow(resumed_finish="stop", code=None): + """A correct DENY on the wire, with the resumed turn shaped by the arguments.""" + seen: dict = {} + + def flow(session, messages, params, timeout=300.0, deadline=None): + if session not in seen: + seen[session] = True + t = _turn(finish="other", deltas=0) + t.tool_calls = [{"toolCallId": "c1", "toolName": "Bash", "input": {}}] + t._segments = [{"kind": "tool", "id": "c1"}] + t.approval = {"approvalId": "a1", "toolCallId": "c1"} + return t + t = _turn(deltas=1, finish=resumed_finish, code=code) + t.finish_reason = resumed_finish + t.tool_calls = [{"toolCallId": "c1", "toolName": "Bash", "input": {}}] + t.tool_outcomes = {"c1": "denied"} + return t + + return flow + + +def test_deny_passes_when_the_resume_ends_cleanly(): + _reset() + qa.invoke = _deny_flow() + r = qa.j4_deny(CELL) + assert r["pass"], r + + +def test_deny_fails_when_the_resume_did_not_end(): + """Tightened verdict: a correct denied outcome is not enough if the turn never stopped.""" + _reset() + qa.invoke = _deny_flow(resumed_finish="other") + r = qa.j4_deny(CELL) + assert not r["pass"], r + assert "resumed finish=stop: False" in r["why"], r["why"] + + +def test_deny_fails_on_a_coded_error_in_the_resume(): + _reset() + qa.invoke = _deny_flow(code="credential_delivery_failed") + r = qa.j4_deny(CELL) + assert not r["pass"], r + assert "no coded error and neither turn hung: False" in r["why"], r["why"] + + +def test_the_written_results_file_carries_no_key_material(): + """Redaction at the persistence boundary: a key hidden deep inside a turn summary.""" + import tempfile + + _reset() + planted = "sk-proj-3M1PW0OPU17zAxPi4wTT33ec5L3Tqfq" + real_journeys = dict(qa.JOURNEYS) + qa.JOURNEYS["chat"] = lambda cell: { + "pass": True, + "why": "ok", + "turn": { + "reply": "fine", + "errors": [f"401: Incorrect API key provided: {planted}"], + "nested": [{"deep": {"body": planted}}], + }, + } + outdir = tempfile.mkdtemp() + argv, runs_dir = sys.argv, qa.RUNS + sys.argv = ["qa_product.py", "--cell", "C4", "--only", "chat"] + qa.RUNS = Path(outdir) + try: + qa.main() + finally: + sys.argv, qa.RUNS = argv, runs_dir + qa.JOURNEYS.clear() + qa.JOURNEYS.update(real_journeys) + written = sorted(Path(outdir).glob("*/results.json"))[0].read_text() + assert planted not in written, "a planted key survived into results.json" + assert written.count("sk-") == 2, written[:400] + + +def test_the_total_crosstalk_count_is_capped_not_each_half(): + argv = sys.argv + sys.argv = [ + "qa_product.py", + "--cell", + "C4", + "--crosstalk-conversations", + "20", + "--crosstalk-approvals", + "20", + ] + try: + qa.main() + raise AssertionError("40 concurrent crosstalk jobs must be refused") + except SystemExit as e: + assert "capped at 32 concurrent runs" in str(e), e + finally: + sys.argv = argv + + +def test_a_hung_run_still_reports_when_it_started_and_stopped(): + _reset() + qa.BURST_SIZE = 2 + qa.CONCURRENCY_TURN_TIMEOUT_SECONDS = 1.0 + qa.CONCURRENCY_WAIT_MARGIN_SECONDS = 1.0 + release = threading.Event() + + def never_ends(session, messages, params, timeout=300.0, deadline=None): + release.wait(120) + return _turn("too late") + + qa.invoke = never_ends + r = qa.j_burst(CELL) + release.set() + for run in r["runs"]: + assert run["started_s"] is not None and run["ended_s"] is not None, run + assert run["ended_s"] >= run["started_s"], run + + +def main() -> int: + return pytest.main([__file__, "-q"]) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/agent-release-gate/resources/test_session_control.py b/.agents/skills/agent-release-gate/resources/test_session_control.py new file mode 100644 index 00000000000..148a15af09c --- /dev/null +++ b/.agents/skills/agent-release-gate/resources/test_session_control.py @@ -0,0 +1,1477 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = ["httpx>=0.27"] +# /// +"""Unit tests for the pure parts of session_control.py: cell selection, resume, and result shape. + +No stack, no network, no Docker — these exercise only the argument parsing, the OperatorHooks +skip path, and the verdict-shape helpers. +""" + +from __future__ import annotations + +import json +import pathlib +import re +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) + +import session_control as sc # noqa: E402 + + +def test_cells_registry_is_internally_consistent(): + for name, (needs_hooks, permission, fn) in sc.CELLS.items(): + assert isinstance(needs_hooks, bool), name + assert permission in ("allow", "ask"), name + assert callable(fn), name + + +def test_null_hooks_raises_on_every_method(): + hooks = sc.NullHooks() + assert hooks.available is False + for method in ( + "stream_row", + "record_rows", + "command_rows", + "execution_rows", + "sandbox_procs", + ): + try: + getattr(hooks, method)("x") + except sc.HooksUnavailable: + continue + raise AssertionError(f"{method} should raise HooksUnavailable") + for method in ( + "wait_for_runner", + "runner_healthy", + "ensure_runner_healthy", + "restart_runner", + "kill_runner", + "pause_runner", + "unpause_runner", + "stop_postgres", + "start_postgres", + "kill_sandbox", + "kill_sandbox_for_session", + "wait_for_sandbox_ready", + ): + try: + getattr(hooks, method)() + except sc.HooksUnavailable: + continue + raise AssertionError(f"{method} should raise HooksUnavailable") + + +def test_verdict_shape_helpers(): + p = sc._pass("ok") + f = sc._fail("bad") + s = sc._skip("no hooks") + assert p == {"pass": True, "skip": False, "why": "ok"} + assert f == {"pass": False, "skip": False, "why": "bad"} + assert s == {"pass": False, "skip": True, "why": "no hooks"} + for v in (p, f, s): + assert set(v) == {"pass", "skip", "why"} + + +def _stop_approval_evidence(*, durable_stop: str, late_status: int) -> dict: + return { + "stop": {"status": 200}, + "command_settled": {"settled": True, "why": None}, + "durable_stop": durable_stop, + "late_answer": {"status": late_status}, + "resume_recalled_marker": True, + } + + +def test_stop_approval_durable_path_requires_late_answer_refusal(): + refused = _stop_approval_evidence(durable_stop="on", late_status=409) + + for status in (200, 202, 500): + unexpected = _stop_approval_evidence(durable_stop="on", late_status=status) + verdict = sc._judge_stop_approval(unexpected, pending_found=True) + assert verdict["pass"] is False + assert f"HTTP {status}, expected 409" in verdict["why"] + verdict = sc._judge_stop_approval(refused, pending_found=True) + assert verdict["pass"] is True + assert "late answer was refused" in verdict["why"] + + +def test_stop_approval_legacy_path_requires_and_records_late_answer_acceptance(): + accepted = _stop_approval_evidence(durable_stop="off", late_status=200) + refused = _stop_approval_evidence(durable_stop="off", late_status=409) + + verdict = sc._judge_stop_approval(accepted, pending_found=True) + assert verdict == { + "pass": True, + "skip": False, + "why": "late answer accepted: legacy path", + } + assert accepted["late_answer"]["note"] == "late answer accepted: legacy path" + assert sc._judge_stop_approval(refused, pending_found=True)["pass"] is False + + +def test_durable_stop_auto_detection_uses_cancel_response_shape(): + durable = {"command": {"id": "cmd-1"}, "execution": {"id": "exec-1"}} + legacy = { + "mode": "cancelled", + "session_id": "session-1", + "turn_id": "turn-1", + "watcher_id": None, + "detached": False, + "cancelled_turn_ids": ["turn-1"], + } + + assert sc._resolve_durable_stop("auto", durable) == "on" + assert sc._resolve_durable_stop("auto", legacy) == "off" + assert sc._resolve_durable_stop("auto", {"detail": "not found"}) is None + assert sc._resolve_durable_stop("off", durable) == "off" + assert sc._resolve_durable_stop("on", legacy) == "on" + + +def test_hooks_only_cells_skip_without_project(monkeypatch): + """Every cell marked needs_hooks=True must SKIP (not crash, not run) when --project is + absent, per qa-audit-2026-09-03.md section 4 change 2.""" + + class Args: + sleep_seconds = 1 + sweep_wait = 1 + project = None + sandbox = "local" + + hooks = sc.NullHooks() + for name, (needs_hooks, _permission, fn) in sc.CELLS.items(): + if not needs_hooks: + continue + evidence, verdict = fn({}, {}, Args(), hooks) + assert verdict["skip"] is True, f"{name} should skip without --project" + assert evidence == {}, ( + f"{name} should not run any evidence-gathering without --project" + ) + + +def test_resume_skips_cells_already_in_prior_results(tmp_path): + """Cells present in a prior run's results.json are loaded, not re-executed. This is the + resumability property qa-audit-2026-09-03.md section 4 change 4 asks for: a lost agent + costs one cell, not the whole run.""" + prior_results = { + "cells": { + "stop-warm": { + "evidence": {"session_id": "abc"}, + "verdict": {"pass": True, "skip": False, "why": "ok"}, + "elapsed_s": 1.0, + } + } + } + prior_path = tmp_path / "results.json" + prior_path.write_text(json.dumps(prior_results)) + + loaded = json.loads(prior_path.read_text()).get("cells", {}) + assert "stop-warm" in loaded + assert loaded["stop-warm"]["verdict"]["pass"] is True + + # The cell-selection logic in main(): a cell present in `prior` is carried forward as-is + # rather than re-run. Exercise the same branch condition main() uses. + wanted = ["stop-warm", "double-send"] + to_run = [c for c in wanted if c not in loaded] + assert to_run == ["double-send"] + + +def test_cell_names_are_stable_and_known(): + expected = { + "stop-warm", + "double-send", + "stale-stop", + "stop-approval", + "sandbox-gone", + "records-outage", + "stop-after-finish", + "restart-after-stop", + "runner-gone", + "runner-gone-late", + "post-stop-row", + "codex-child", + "stale-tail", + "repeat-stop", + "concurrent-stops", + "stop-during-completion", + } + assert set(sc.CELLS) == expected + + +def _runner_gone_evidence(**overrides) -> dict: + """A minimal evidence dict shaped the way cell_runner_gone / cell_runner_gone_late build + it, with sane defaults that satisfy `_judge_runner_gone` on their own. Tests override just + the field(s) under test.""" + base = { + "terminal_records": [ + { + "type": "error", + "attributes": {"code": "execution_lost", "settled_by": "watchdog"}, + }, + {"type": "done", "attributes": {"settled_by": "watchdog"}}, + ], + "stop_command": {"state": "applied", "outcome": "stopped"}, + "stream_row": {"flags": {"is_running": False}}, + "new_message_ran": True, + } + base.update(overrides) + return base + + +def test_judge_runner_gone_accepts_the_never_reported_race(): + """The hard race: the command settles `lost` because the runner never got to claim or + report it. This must PASS and record which race landed.""" + evidence = _runner_gone_evidence( + stop_command={"state": "applied", "outcome": "lost"} + ) + verdict = sc._judge_runner_gone(evidence) + assert verdict["pass"] is True + assert verdict["skip"] is False + assert evidence["race"] == "never-reported" + + +def test_judge_runner_gone_accepts_the_outcome_reported_then_died_race(): + """The soft race: the runner reports the Stop's outcome before it actually dies. This must + ALSO pass — both races satisfy the same invariant — and record the other race label.""" + evidence = _runner_gone_evidence( + stop_command={"state": "obsolete", "outcome": "stopped"} + ) + verdict = sc._judge_runner_gone(evidence) + assert verdict["pass"] is True + assert verdict["skip"] is False + assert evidence["race"] == "outcome-reported-then-died" + + +def test_judge_runner_gone_fails_without_any_terminal_record(): + evidence = _runner_gone_evidence(terminal_records=[]) + verdict = sc._judge_runner_gone(evidence) + assert verdict["pass"] is False + assert "race" not in evidence + + +def test_judge_runner_gone_fails_without_a_stop_command_row(): + evidence = _runner_gone_evidence(stop_command=None) + verdict = sc._judge_runner_gone(evidence) + assert verdict["pass"] is False + assert "race" not in evidence + + +def test_judge_runner_gone_fails_on_an_unexpected_command_state(): + evidence = _runner_gone_evidence( + stop_command={"state": "claimed", "outcome": "stopped"} + ) + verdict = sc._judge_runner_gone(evidence) + assert verdict["pass"] is False + assert "race" not in evidence + + +def test_judge_runner_gone_fails_while_the_command_is_still_pending_or_claimed(): + for outcome in (None, "", "pending", "claimed"): + evidence = _runner_gone_evidence( + stop_command={"state": "applied", "outcome": outcome} + ) + verdict = sc._judge_runner_gone(evidence) + assert verdict["pass"] is False, outcome + assert "race" not in evidence, outcome + + +def test_judge_runner_gone_fails_when_is_running_still_reads_true(): + evidence = _runner_gone_evidence(stream_row={"flags": {"is_running": True}}) + verdict = sc._judge_runner_gone(evidence) + assert verdict["pass"] is False + assert "race" not in evidence + + +def test_judge_runner_gone_fails_when_the_next_send_did_not_run(): + evidence = _runner_gone_evidence(new_message_ran=False) + verdict = sc._judge_runner_gone(evidence) + assert verdict["pass"] is False + assert "race" not in evidence + + +def _restart_after_stop_evidence(**overrides) -> dict: + base = { + "runner_healthy_after_s": 5.0, + "admitted_at_s": 12.0, + "recalled_marker": True, + "same_sandbox": True, + "loaded_true": False, + } + base.update(overrides) + return base + + +def test_judge_restart_after_stop_accepts_the_same_sandbox_signal(): + """Recall plus an unchanged sandbox id is a real native resume: no rebuild happened.""" + evidence = _restart_after_stop_evidence(same_sandbox=True, loaded_true=False) + verdict = sc._judge_restart_after_stop(evidence) + assert verdict["pass"] is True + + +def test_judge_restart_after_stop_accepts_the_loaded_true_signal(): + """Recall plus a genuine native hydrate in the runner log is also a real resume, even when + the sandbox itself had to be rebuilt (a new sandbox that loads the OLD native session).""" + evidence = _restart_after_stop_evidence(same_sandbox=False, loaded_true=True) + verdict = sc._judge_restart_after_stop(evidence) + assert verdict["pass"] is True + + +def test_judge_restart_after_stop_fails_when_recall_is_the_only_signal(): + """The exact false-pass this cell exists to catch: the codeword comes back, but neither the + sandbox id nor the runner log backs up a genuine native resume — the runner recovered it by + reconstructing the conversation from persisted records, not by resuming the native session.""" + evidence = _restart_after_stop_evidence(same_sandbox=False, loaded_true=False) + verdict = sc._judge_restart_after_stop(evidence) + assert verdict["pass"] is False + assert ( + verdict["why"] == "native session not resumed, recovered by transcript replay" + ) + + +def test_judge_restart_after_stop_fails_without_recall_even_with_both_signals(): + evidence = _restart_after_stop_evidence( + recalled_marker=False, same_sandbox=True, loaded_true=True + ) + verdict = sc._judge_restart_after_stop(evidence) + assert verdict["pass"] is False + assert "codeword was not recalled" in verdict["why"] + + +def test_judge_restart_after_stop_fails_when_the_runner_never_reported_healthy(): + evidence = _restart_after_stop_evidence(runner_healthy_after_s=None) + verdict = sc._judge_restart_after_stop(evidence) + assert verdict["pass"] is False + assert "never reported healthy" in verdict["why"] + + +def test_judge_restart_after_stop_fails_when_the_continuation_was_never_admitted(): + evidence = _restart_after_stop_evidence(admitted_at_s=None) + verdict = sc._judge_restart_after_stop(evidence) + assert verdict["pass"] is False + assert "refused for the whole wait window" in verdict["why"] + + +def test_match_stop_command_prefers_the_row_targeting_the_turn(): + commands = [ + {"id": "old", "target_turn_id": "turn-a", "state": "applied"}, + {"id": "new", "target_turn_id": "turn-b", "state": "obsolete"}, + ] + assert sc._match_stop_command(commands, "turn-b")["id"] == "new" + + +def test_match_stop_command_falls_back_to_the_last_row_when_nothing_matches(): + commands = [ + {"id": "a", "target_turn_id": None}, + {"id": "b", "target_turn_id": None}, + ] + assert sc._match_stop_command(commands, "turn-x")["id"] == "b" + assert sc._match_stop_command(commands, None)["id"] == "b" + + +def test_match_stop_command_returns_none_for_no_commands(): + assert sc._match_stop_command([], "turn-a") is None + + +class _StubSettlementHooks(sc.OperatorHooks): + """A hook stub whose command_rows/execution_rows are scripted per call, so + assert_command_settled can be tested without Docker or Postgres.""" + + available = True + + def __init__(self, command_sequence, execution_sequence): + # Each is a list of return values, one per poll iteration; the last value repeats once + # exhausted, so a test can describe "stays this way forever" with one entry. + self._commands = command_sequence + self._executions = execution_sequence + self._i = 0 + + def command_rows(self, session_id: str) -> list[dict]: + i = min(self._i, len(self._commands) - 1) + return self._commands[i] + + def execution_rows(self, session_id: str) -> list[dict]: + i = min(self._i, len(self._executions) - 1) + result = self._executions[i] + self._i += ( + 1 # advance once per poll iteration (execution_rows is always called) + ) + return result + + +def test_assert_command_settled_is_a_noop_without_hooks(): + """A cell running without --project (NullHooks) must not be blocked by a check it has no + way to make — the cell's own hooks.available guard already SKIPs it where needed.""" + result = sc.assert_command_settled( + sc.NullHooks(), "session-1", "turn-1", timeout=5.0 + ) + assert result == { + "settled": True, + "command": None, + "execution_rows": [], + "natural_finish": False, + "note": None, + "why": None, + } + + +def test_assert_command_settled_passes_immediately_when_already_settled(): + hooks = _StubSettlementHooks( + command_sequence=[ + [{"id": "cmd-1", "target_turn_id": "turn-1", "state": "applied"}] + ], + execution_sequence=[ + [{"execution_id": "exec-1", "terminal_outcome": "stopped"}] + ], + ) + result = sc.assert_command_settled(hooks, "session-1", "turn-1", timeout=5.0) + assert result["settled"] is True + assert result["why"] is None + assert result["command"]["state"] == "applied" + assert result["execution_rows"][0]["terminal_outcome"] == "stopped" + + +def test_assert_command_settled_catches_the_repeat_stop_false_pass(): + """The exact case this function exists to catch (2026-09-04): a command stuck `claimed` + forever with zero session_executions rows, even though every OTHER driver assertion (one + terminal trace record, a warm resume) would still pass. Must FAIL, fast (timeout=0 -> no + retry sleep), with a reason naming the stuck state.""" + hooks = _StubSettlementHooks( + command_sequence=[ + [ + { + "id": "cmd-1", + "target_turn_id": "turn-1", + "state": "claimed", + "outcome": None, + } + ] + ], + execution_sequence=[[]], + ) + result = sc.assert_command_settled(hooks, "session-1", "turn-1", timeout=0) + assert result["settled"] is False + assert "claimed" in result["why"] + + +def test_assert_command_settled_fails_when_no_command_row_exists(): + hooks = _StubSettlementHooks(command_sequence=[[]], execution_sequence=[[]]) + result = sc.assert_command_settled(hooks, "session-1", "turn-1", timeout=0) + assert result["settled"] is False + assert "no session_commands row" in result["why"] + + +def test_assert_command_settled_fails_on_more_than_one_execution_row(): + hooks = _StubSettlementHooks( + command_sequence=[ + [{"id": "cmd-1", "target_turn_id": "turn-1", "state": "applied"}] + ], + execution_sequence=[ + [ + {"execution_id": "exec-1", "terminal_outcome": "stopped"}, + {"execution_id": "exec-2", "terminal_outcome": "stopped"}, + ] + ], + ) + result = sc.assert_command_settled(hooks, "session-1", "turn-1", timeout=0) + assert result["settled"] is False + assert "exactly one session_executions row" in result["why"] + + +def test_assert_command_settled_accepts_a_stop_after_a_natural_finish(): + """A valid Stop that lands after the turn already finished settles obsolete/not_running with + NO execution row. Zero rows is correct there — accept it, flag it, and note it.""" + hooks = _StubSettlementHooks( + command_sequence=[ + [ + { + "id": "cmd-1", + "target_turn_id": "turn-1", + "state": "obsolete", + "outcome": "not_running", + } + ] + ], + execution_sequence=[[]], # zero execution rows, and that is correct here + ) + result = sc.assert_command_settled(hooks, "session-1", "turn-1", timeout=5.0) + assert result["settled"] is True + assert result["natural_finish"] is True + assert result["note"] == "stop landed after a natural finish" + assert result["execution_rows"] == [] + assert result["why"] is None + + +def test_assert_command_settled_still_requires_a_row_for_a_real_stop(): + """The strict one-row requirement is kept when the runner actually stopped the turn: an + obsolete/stopped command with zero execution rows must still FAIL.""" + hooks = _StubSettlementHooks( + command_sequence=[ + [ + { + "id": "cmd-1", + "target_turn_id": "turn-1", + "state": "obsolete", + "outcome": "stopped", + } + ] + ], + execution_sequence=[[]], + ) + result = sc.assert_command_settled(hooks, "session-1", "turn-1", timeout=0) + assert result["settled"] is False + assert result["natural_finish"] is False + assert "exactly one session_executions row" in result["why"] + + +def test_run_cell_finally_path_with_null_hooks_does_not_crash(): + """run_cell()'s runner-health recovery is gated on `needs_hooks and hooks.available`. With + NullHooks (no --project), hooks.available is False, so the finally block must skip the + recovery call rather than let HooksUnavailable escape through it — even for a needs_hooks + cell whose own function raises before it can restore anything itself.""" + + class Args: + sleep_seconds = 1 + sweep_wait = 1 + project = None + sandbox = "local" + + def boom(cfg, references, args, hooks): + raise RuntimeError("cell blew up before it could restore anything") + + hooks = sc.NullHooks() + result = sc.run_cell("boom-cell", boom, {}, {}, Args(), hooks, True) + assert result["verdict"]["pass"] is False + assert result["verdict"]["skip"] is False + assert "driver exception" in result["verdict"]["why"] + assert "RuntimeError" in result["evidence"]["driver_error"] + assert "elapsed_s" in result + + +def test_run_cell_recovers_the_runner_when_a_cell_raises(): + """The run-level guarantee: a needs_hooks cell that raises must still trigger the runner + recovery check, so a paused or restarted runner does not strand the cell that runs next.""" + + class Args: + sleep_seconds = 1 + sweep_wait = 1 + project = "fake-project" + sandbox = "local" + + class StubHooks(sc.OperatorHooks): + available = True + + def __init__(self): + self.recovered = False + + def ensure_runner_healthy(self, *, timeout: float = 120.0) -> dict: + self.recovered = True + return { + "was_paused": True, + "status_before": "running", + "healthy_after_s": 1.0, + } + + def boom(cfg, references, args, hooks): + raise RuntimeError("cell paused the runner and blew up before unpausing it") + + hooks = StubHooks() + result = sc.run_cell("boom-cell", boom, {}, {}, Args(), hooks, True) + assert hooks.recovered is True + assert result["verdict"]["pass"] is False + + +def test_run_cell_skips_recovery_for_cells_that_do_not_need_hooks(): + """A cell that never touches Docker (needs_hooks=False) must not trigger a recovery check, + even when hooks happen to be available.""" + + class Args: + sleep_seconds = 1 + sweep_wait = 1 + project = "fake-project" + sandbox = "local" + + class StubHooks(sc.OperatorHooks): + available = True + + def __init__(self): + self.recovered = False + + def ensure_runner_healthy(self, *, timeout: float = 120.0) -> dict: + self.recovered = True + return { + "was_paused": False, + "status_before": "running", + "healthy_after_s": 1.0, + } + + def ok(cfg, references, args, hooks): + return {"session_id": "abc"}, sc._pass("fine") + + hooks = StubHooks() + result = sc.run_cell("http-only-cell", ok, {}, {}, Args(), hooks, False) + assert hooks.recovered is False + assert result["verdict"]["pass"] is True + + +def test_resolve_env_names_every_missing_variable(monkeypatch): + monkeypatch.delenv("AGENTA_BASE", raising=False) + monkeypatch.delenv("AGENTA_ADMIN_KEY", raising=False) + monkeypatch.delenv("QA_OPENAI_API_KEY", raising=False) + try: + sc.resolve_env() + except SystemExit as exc: + msg = str(exc) + assert "AGENTA_BASE" in msg + assert "AGENTA_ADMIN_KEY" in msg + assert "QA_OPENAI_API_KEY" in msg + assert "no env-file fallback" in msg + else: + raise AssertionError("resolve_env() should raise SystemExit when env is empty") + + +def test_resolve_env_populates_globals(monkeypatch): + monkeypatch.setenv("AGENTA_BASE", "https://example.test") + monkeypatch.setenv("AGENTA_ADMIN_KEY", "admin-secret") + monkeypatch.setenv("QA_OPENAI_API_KEY", "sk-test") + sc.resolve_env() + assert sc.BASE == "https://example.test" + assert sc.ADMIN_KEY == "admin-secret" + assert sc.OPENAI_KEY == "sk-test" + + +def test_stream_timeout_s_gives_pi_the_shorter_budget(): + assert sc.stream_timeout_s({"harness": {"kind": "pi_core"}}) == 600.0 + + +def test_stream_timeout_s_gives_codex_and_claude_1_5x_pi(): + assert sc.stream_timeout_s({"harness": {"kind": "codex"}}) == 900.0 + assert sc.stream_timeout_s({"harness": {"kind": "claude"}}) == 900.0 + + +def test_stream_timeout_s_defaults_for_an_unknown_or_missing_harness(): + assert sc.stream_timeout_s({"harness": {"kind": "some-future-harness"}}) == 600.0 + assert sc.stream_timeout_s({}) == 600.0 + + +def test_client_shape_messages_full_is_a_noop(): + """--client-shape full (the default) must not touch the outbound messages at all.""" + assert sc.CLIENT_SHAPE == "full" + messages = [ + sc.user_msg("first"), + {"role": "assistant", "parts": [{"type": "text", "text": "ok"}]}, + sc.user_msg("last"), + ] + assert sc._client_shape_messages(messages) == messages + + +def test_client_shape_messages_last_message_produces_exactly_one_message_for_a_user_turn(): + """The literal contract: under last-message, the outbound messages a fresh user turn + produces has exactly one entry, and it is the new user message — not a copy or a rebuild + of it.""" + sc.CLIENT_SHAPE = "last-message" + try: + first = sc.user_msg("first") + reply = {"role": "assistant", "parts": [{"type": "text", "text": "ok"}]} + last = sc.user_msg("last") + shaped = sc._client_shape_messages([first, reply, last]) + finally: + sc.CLIENT_SHAPE = "full" + assert len(shaped) == 1 + assert shaped[0] is last + + +def test_client_shape_messages_keeps_full_history_for_a_hitl_resume(): + """A resume whose trailing turn carries a settled HITL answer (an assistant message, not a + fresh user turn) must NOT be truncated: the answer has to stay bound to its tool call, the + same guard agentRequest.ts applies (`lastMessage?.role === "user"`).""" + sc.CLIENT_SHAPE = "last-message" + try: + first = sc.user_msg("first") + settled = { + "role": "assistant", + "parts": [{"type": "tool-shell", "state": "output-denied"}], + } + shaped = sc._client_shape_messages([first, settled]) + finally: + sc.CLIENT_SHAPE = "full" + assert shaped == [first, settled] + + +def test_client_shape_messages_strips_answerless_assistant_turns_first(): + """An assistant turn with no answer part (no text, no tool, no dynamic-tool, no file) is + stripped before the trailing-user-turn check, mirroring `hasAnswer` in agentRequest.ts.""" + sc.CLIENT_SHAPE = "last-message" + try: + first = sc.user_msg("first") + empty_assistant = {"role": "assistant", "parts": []} + last = sc.user_msg("last") + shaped = sc._client_shape_messages([first, empty_assistant, last]) + finally: + sc.CLIENT_SHAPE = "full" + assert len(shaped) == 1 + assert shaped[0] is last + + +class _FakeResponse: + """Minimal stand-in for an `httpx.Response` the DaytonaAwareHooks code path reads.""" + + def __init__(self, status_code: int, payload=None, text: str = ""): + self.status_code = status_code + self._payload = payload + self.text = text + + def json(self): + return self._payload + + +def _set_daytona_env(): + """Dummy, non-secret env values so `DaytonaAwareHooks.__init__` does not raise. Never a real + key — these tests must never touch the network.""" + import os + + saved = { + k: os.environ.get(k) + for k in ("AGENTA_RUNNER_DAYTONA_API_KEY", "AGENTA_RUNNER_DAYTONA_API_URL") + } + os.environ["AGENTA_RUNNER_DAYTONA_API_KEY"] = "test-key-not-real" + os.environ["AGENTA_RUNNER_DAYTONA_API_URL"] = "https://daytona.example/api" + return saved + + +def _restore_env(saved: dict): + import os + + for k, v in saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + +def test_daytona_aware_hooks_requires_daytona_env_vars(): + """Constructing without AGENTA_RUNNER_DAYTONA_API_KEY/URL must fail loudly and by name, the + same discipline `resolve_env` uses for the three top-level env vars — never a silent no-op + that later fails deep inside an HTTP call.""" + import os + + saved = { + k: os.environ.get(k) + for k in ("AGENTA_RUNNER_DAYTONA_API_KEY", "AGENTA_RUNNER_DAYTONA_API_URL") + } + os.environ.pop("AGENTA_RUNNER_DAYTONA_API_KEY", None) + os.environ.pop("AGENTA_RUNNER_DAYTONA_API_URL", None) + try: + try: + sc.DaytonaAwareHooks("fake-project") + except SystemExit as exc: + assert "AGENTA_RUNNER_DAYTONA_API_KEY" in str(exc) + assert "AGENTA_RUNNER_DAYTONA_API_URL" in str(exc) + else: + raise AssertionError("expected SystemExit without the Daytona env vars") + finally: + _restore_env(saved) + + +def test_daytona_aware_hooks_kill_sandbox_noop_without_sandbox_id(): + """No observed sandbox id means nothing to end — must not call the Daytona API at all.""" + saved = _set_daytona_env() + try: + hooks = sc.DaytonaAwareHooks("fake-project") + + def boom(*a, **k): + raise AssertionError("must not call the network without a sandbox id") + + hooks._daytona_delete = boom + assert hooks.kill_sandbox(sandbox_id=None) == [] + finally: + _restore_env(saved) + + +def test_daytona_aware_hooks_kill_sandbox_deletes_only_the_observed_sandbox(): + """Ends the ONE sandbox id the cell observed, by its bare uuid (the `daytona/` prefix is a + driver-internal convention, not part of the Daytona API path).""" + saved = _set_daytona_env() + try: + hooks = sc.DaytonaAwareHooks("fake-project") + calls = [] + + def fake_delete(path): + calls.append(path) + return _FakeResponse(200) + + hooks._daytona_delete = fake_delete + result = hooks.kill_sandbox(sandbox_id="daytona/abc-123") + assert calls == ["/sandbox/abc-123"] + assert result == ["abc-123"] + finally: + _restore_env(saved) + + +def test_daytona_aware_hooks_kill_sandbox_treats_404_as_already_gone(): + saved = _set_daytona_env() + try: + hooks = sc.DaytonaAwareHooks("fake-project") + hooks._daytona_delete = lambda path: _FakeResponse(404) + assert hooks.kill_sandbox(sandbox_id="daytona/abc-123") == ["abc-123"] + finally: + _restore_env(saved) + + +def test_daytona_aware_hooks_sandbox_procs_noop_without_sandbox_id(): + saved = _set_daytona_env() + try: + hooks = sc.DaytonaAwareHooks("fake-project") + + def boom(*a, **k): + raise AssertionError("must not call the network without a sandbox id") + + hooks._daytona_get = boom + assert hooks.sandbox_procs("marker", sandbox_id=None) == [] + finally: + _restore_env(saved) + + +def test_daytona_aware_hooks_sandbox_procs_matches_the_marker_and_filters_self(): + """The full happy path: fetch the toolbox proxy URL for the ONE observed sandbox, run the + same `ps -eo pid=,ppid=,etimes=,args=` reap-exec.ts uses, and keep only the row matching the + driver's own marker — never the `ps` invocation itself or an unrelated process.""" + saved = _set_daytona_env() + try: + hooks = sc.DaytonaAwareHooks("fake-project") + get_calls = [] + post_calls = [] + + hooks._daytona_get = lambda path: ( + get_calls.append(path), + _FakeResponse(200, {"url": "https://proxy.example/tb/abc-123"}), + )[1] + + ps_output = ( + " 501 1 120 /sbin/init\n" + " 777 501 30 sleep 300.123456\n" + " 778 777 0 ps -eo pid=,ppid=,etimes=,args=\n" + ) + + class _FakePost: + def __call__(self, url, json=None, timeout=None): + post_calls.append((url, json)) + return _FakeResponse(200, {"result": ps_output, "exitCode": 0}) + + import httpx as real_httpx + + saved_post = real_httpx.post + real_httpx.post = _FakePost() + try: + hits = hooks.sandbox_procs("sleep 300.123456", sandbox_id="daytona/abc-123") + finally: + real_httpx.post = saved_post + + assert get_calls == ["/sandbox/abc-123/toolbox-proxy-url"] + assert len(post_calls) == 1 + url, body = post_calls[0] + assert url == "https://proxy.example/tb/abc-123/process/execute" + assert body["command"] == "ps -eo pid=,ppid=,etimes=,args=" + assert len(hits) == 1 + assert hits[0]["pid"] == "777" + assert "sleep 300.123456" in hits[0]["args"] + finally: + _restore_env(saved) + + +def test_select_hooks_returns_null_hooks_without_project(): + hooks = sc.select_hooks(None, "local") + assert isinstance(hooks, sc.NullHooks) + hooks = sc.select_hooks(None, "daytona") + assert isinstance(hooks, sc.NullHooks) + + +def test_select_hooks_returns_docker_compose_hooks_for_local_sandbox(): + hooks = sc.select_hooks("fake-project", "local") + assert type(hooks) is sc.DockerComposeHooks # noqa: E721 -- exact class, not the daytona subclass + + +def test_select_hooks_returns_daytona_aware_hooks_for_daytona_sandbox(): + saved = _set_daytona_env() + try: + hooks = sc.select_hooks("fake-project", "daytona") + assert isinstance(hooks, sc.DaytonaAwareHooks) + finally: + _restore_env(saved) + + +# --------------------------------------------------------------------------- # +# sandbox-gone: per-session sandbox targeting (the driver defect this PR fixes). +# --------------------------------------------------------------------------- # + + +def test_parse_local_sandbox_port_reads_the_port_from_a_local_ledger_id(): + assert sc._parse_local_sandbox_port("local/127.0.0.1:44831") == 44831 + assert sc._parse_local_sandbox_port("local/0.0.0.0:5") == 5 + + +def test_parse_local_sandbox_port_ignores_daytona_and_empty_ids(): + assert sc._parse_local_sandbox_port("daytona/abc-123") is None + assert sc._parse_local_sandbox_port(None) is None + assert sc._parse_local_sandbox_port("") is None + + +def test_parse_ss_listener_pid_matches_the_exact_port(): + out = ( + 'LISTEN 0 511 127.0.0.1:44831 0.0.0.0:* users:(("node",pid=2170,fd=23))\n' + 'LISTEN 0 511 127.0.0.1:34013 0.0.0.0:* users:(("node",pid=1999,fd=23))\n' + ) + assert sc._parse_ss_listener_pid(out, 44831) == "2170" + assert sc._parse_ss_listener_pid(out, 34013) == "1999" + + +def test_parse_ss_listener_pid_does_not_match_a_substring_port(): + out = 'LISTEN 0 511 127.0.0.1:44831 0.0.0.0:* users:(("node",pid=2170,fd=23))\n' + assert sc._parse_ss_listener_pid(out, 4483) is None + assert sc._parse_ss_listener_pid(out, 831) is None + + +def test_parse_ss_listener_pid_returns_none_when_absent(): + assert sc._parse_ss_listener_pid("", 44831) is None + + +class _FakeLocalHooks(sc.DockerComposeHooks): + """DockerComposeHooks with the container round-trips (`dc`, `runner_log`) scripted, so the + port-to-pid mapping and the wrong-target refusals are tested without Docker.""" + + def __init__( + self, *, log_lines=None, log_reads=None, ss="", proc_pid="", cmdlines=None + ): + super().__init__("fake-project") + self._log_lines = log_lines or [] + # `log_reads` scripts one return value per `runner_log` call (the last repeats), so a test + # can make this session's line appear on, say, the third poll. `log_lines` is the fixed + # fallback when no script is given. + self._log_reads = log_reads + self._ss = ss + self._proc_pid = proc_pid + self._cmdlines = cmdlines or {} + self.killed: list[str] = [] + self.log_read_count = 0 + + def runner_log(self, since: float) -> list[str]: + self.log_read_count += 1 + if self._log_reads is not None: + i = min(self.log_read_count - 1, len(self._log_reads) - 1) + return list(self._log_reads[i]) + return list(self._log_lines) + + def dc(self, *args: str, timeout: float = 60.0) -> str: + joined = " ".join(str(a) for a in args) + if "ss -ltnHp" in joined: + return self._ss + if "socket:[" in joined: # the /proc/net/tcp fallback resolver script + return self._proc_pid + if "/cmdline" in joined: + m = re.search(r"/proc/(\d+)/cmdline", joined) + return self._cmdlines.get(m.group(1) if m else "", "") + if "kill -9" in joined: + self.killed.append(joined) + return "" + raise AssertionError(f"unexpected dc call: {args}") + + +def test_local_kill_targets_only_the_tested_sessions_sandbox(): + """The tested session's port maps to its own pid; the other session's parked daemon on a + different port is never touched — the exact defect that produced the false negative.""" + sid = "sess-under-test" + hooks = _FakeLocalHooks( + log_lines=[ + f"12:29 [sandbox-agent] [timing] stage=prepare_workspace ms=0 " + f"sandbox=local/127.0.0.1:44831 session={sid}", + "12:28 [sandbox-agent] [timing] stage=prepare_workspace ms=0 " + "sandbox=local/127.0.0.1:34013 session=other-session", + ], + ss=( + 'LISTEN 0 511 127.0.0.1:44831 0.0.0.0:* users:(("node",pid=2170,fd=23))\n' + 'LISTEN 0 511 127.0.0.1:34013 0.0.0.0:* users:(("node",pid=1999,fd=23))\n' + ), + cmdlines={ + "2170": "node /app/node_modules/.bin/sandbox-agent server --port 44831", + }, + ) + result = hooks.kill_sandbox_for_session( + sid, sandbox_id="local/127.0.0.1:44831", since=0.0 + ) + assert result["port"] == 44831 + assert result["pid"] == "2170" + assert result["killed"] == ["2170"] + assert any("2170" in k for k in hooks.killed) + assert not any("1999" in k for k in hooks.killed) + + +def test_local_kill_refuses_when_the_port_cannot_be_mapped(): + hooks = _FakeLocalHooks(log_lines=[]) + try: + hooks.kill_sandbox_for_session("sess", sandbox_id=None, since=0.0) + except sc.WrongSandboxTarget: + assert hooks.killed == [] + return + raise AssertionError("expected WrongSandboxTarget") + + +def test_local_kill_refuses_when_nothing_listens_on_the_port(): + hooks = _FakeLocalHooks(ss="", proc_pid="") + try: + hooks.kill_sandbox_for_session( + "sess", sandbox_id="local/127.0.0.1:44831", since=0.0 + ) + except sc.WrongSandboxTarget: + assert hooks.killed == [] + return + raise AssertionError("expected WrongSandboxTarget") + + +def test_local_kill_refuses_when_the_pid_is_not_a_sandbox_agent(): + hooks = _FakeLocalHooks( + ss='LISTEN 0 511 127.0.0.1:44831 0.0.0.0:* users:(("postgres",pid=42,fd=7))\n', + cmdlines={"42": "postgres: primary process"}, + ) + try: + hooks.kill_sandbox_for_session( + "sess", sandbox_id="local/127.0.0.1:44831", since=0.0 + ) + except sc.WrongSandboxTarget: + assert hooks.killed == [] + return + raise AssertionError("expected WrongSandboxTarget") + + +def test_local_kill_refuses_when_log_and_ledger_ports_disagree(): + sid = "sess" + hooks = _FakeLocalHooks( + log_lines=[ + f"12:29 [sandbox-agent] [timing] stage=prepare_workspace ms=0 " + f"sandbox=local/127.0.0.1:34013 session={sid}", + ], + ) + try: + hooks.kill_sandbox_for_session( + sid, sandbox_id="local/127.0.0.1:44831", since=0.0 + ) + except sc.WrongSandboxTarget: + assert hooks.killed == [] + return + raise AssertionError("expected WrongSandboxTarget") + + +def test_local_kill_falls_back_to_proc_when_ss_is_absent(): + """A distroless runner has no `ss`; the /proc resolver supplies the pid instead.""" + sid = "sess" + hooks = _FakeLocalHooks( + log_lines=[ + f"12:29 [sandbox-agent] [timing] stage=prepare_workspace ms=0 " + f"sandbox=local/127.0.0.1:44831 session={sid}", + ], + ss="", + proc_pid="2170\n", + cmdlines={"2170": "node .../sandbox-agent server"}, + ) + result = hooks.kill_sandbox_for_session( + sid, sandbox_id="local/127.0.0.1:44831", since=0.0 + ) + assert result["pid"] == "2170" + assert result["killed"] == ["2170"] + + +def test_daytona_kill_for_session_delegates_to_the_remote_delete(): + saved = _set_daytona_env() + try: + hooks = sc.DaytonaAwareHooks("fake-project") + hooks._daytona_delete = lambda path: _FakeResponse(200) + result = hooks.kill_sandbox_for_session( + "sess", sandbox_id="daytona/abc-123", since=0.0 + ) + assert result["killed"] == ["abc-123"] + assert result["port"] is None + finally: + _restore_env(saved) + + +def test_daytona_kill_for_session_refuses_without_a_sandbox_id(): + saved = _set_daytona_env() + try: + hooks = sc.DaytonaAwareHooks("fake-project") + + def boom(*a, **k): + raise AssertionError("must not call the network without a sandbox id") + + hooks._daytona_delete = boom + try: + hooks.kill_sandbox_for_session("sess", sandbox_id=None, since=0.0) + except sc.WrongSandboxTarget: + return + raise AssertionError("expected WrongSandboxTarget") + finally: + _restore_env(saved) + + +class _FakeClock: + """A clock whose `sleep` advances `time` instantly, so poll loops run without real waiting.""" + + def __init__(self): + self.now = 0.0 + self.sleeps: list[float] = [] + + def time(self) -> float: + return self.now + + def sleep(self, seconds: float) -> None: + self.sleeps.append(seconds) + self.now += seconds + + +def test_wait_for_local_sandbox_port_returns_when_the_line_appears_on_the_third_read(): + sid = "sess" + line = ( + "12:29 [sandbox-agent] [timing] stage=prepare_workspace ms=0 " + f"sandbox=local/127.0.0.1:44831 session={sid}" + ) + hooks = _FakeLocalHooks(log_reads=[[], [], [line]]) # empty, empty, then the line + clock = _FakeClock() + port = hooks.wait_for_local_sandbox_port( + sid, + ledger_id_getter=lambda: None, + since=0.0, + timeout=120.0, + poll_interval=3.0, + clock=clock, + ) + assert port == 44831 + assert hooks.log_read_count == 3 + assert clock.sleeps == [3.0, 3.0] # slept twice before the third read found it + + +def test_wait_for_local_sandbox_port_refuses_when_the_line_never_appears(): + hooks = _FakeLocalHooks(log_reads=[[]]) # every read is empty + clock = _FakeClock() + try: + hooks.wait_for_local_sandbox_port( + "sess", + ledger_id_getter=lambda: None, + since=0.0, + timeout=9.0, + poll_interval=3.0, + clock=clock, + ) + except sc.WrongSandboxTarget as exc: + assert "never appeared" in str(exc) + assert clock.now >= 9.0 + return + raise AssertionError("expected WrongSandboxTarget on timeout") + + +def test_wait_for_local_sandbox_port_resolves_from_the_ledger_when_the_log_is_silent(): + calls = {"n": 0} + + def ledger(): + calls["n"] += 1 + return "local/127.0.0.1:44831" if calls["n"] >= 3 else None + + hooks = _FakeLocalHooks( + log_reads=[[]] + ) # log stays empty; the ledger supplies the port + clock = _FakeClock() + port = hooks.wait_for_local_sandbox_port( + "sess", + ledger_id_getter=ledger, + since=0.0, + timeout=120.0, + poll_interval=3.0, + clock=clock, + ) + assert port == 44831 + assert calls["n"] == 3 + + +def test_sandbox_gone_command_outlasts_acquire_resolve_and_the_design_window(): + assert ( + sc.SANDBOX_GONE_COMMAND_S + > sc.SANDBOX_GONE_ACQUIRE_BUDGET_S + + sc.SANDBOX_GONE_RESOLVE_TIMEOUT_S + + sc._SANDBOX_GONE_DESIGN_WINDOW_S + ) + + +def test_recover_then_send_waits_for_health_before_sending(): + """The recovery Send is not issued until the health poll returns healthy: fail twice, then + succeed, and the send must fire exactly once and only after the third (healthy) check.""" + order: list = [] + calls = {"n": 0} + + def health(): + calls["n"] += 1 + order.append(("health", calls["n"])) + return calls["n"] >= 3 # unhealthy on the first two polls, healthy on the third + + def send(): + order.append(("send", None)) + return {"ok": True} + + clock = _FakeClock() + healthy, result = sc._recover_then_send( + health, send, timeout=60.0, poll_interval=2.0, clock=clock + ) + assert healthy is True + assert result == {"ok": True} + assert calls["n"] == 3 + assert clock.sleeps == [2.0, 2.0] # slept between the two failed polls only + assert order == [ + ("health", 1), + ("health", 2), + ("health", 3), + ("send", None), + ] + + +def test_recover_then_send_does_not_send_when_health_never_recovers(): + sent = {"n": 0} + + def send(): + sent["n"] += 1 + return {"ok": True} + + clock = _FakeClock() + healthy, result = sc._recover_then_send( + lambda: False, send, timeout=6.0, poll_interval=2.0, clock=clock + ) + assert healthy is False + assert result is None + assert sent["n"] == 0 # a doomed Send is never attempted + assert clock.now >= 6.0 + + +class _RunnerGoneStubHooks(sc.OperatorHooks): + """Records the order of pause/read/unpause and DB reads, so the runner-gone measurement can be + tested without Docker or Postgres. `settle_on_call` makes the command settle on the Nth poll.""" + + available = True + + def __init__(self, *, command, executions, stream, settle_on_call=1): + self.calls: list[str] = [] + self._command = command + self._executions = executions + self._stream = stream + self._settle_on_call = settle_on_call + self._cmd_calls = 0 + + def pause_runner(self) -> None: + self.calls.append("pause") + + def unpause_runner(self) -> None: + self.calls.append("unpause") + + def command_rows(self, session_id: str) -> list[dict]: + self._cmd_calls += 1 + self.calls.append("command_rows") + return self._command if self._cmd_calls >= self._settle_on_call else [] + + def execution_rows(self, session_id: str) -> list[dict]: + self.calls.append("execution_rows") + return self._executions + + def stream_row(self, session_id: str) -> dict: + self.calls.append("stream_row") + return self._stream + + +def test_runner_gone_measurement_reads_is_running_while_paused(): + """The is_running read must happen while the pause is still in effect: pause before the read, + unpause after it. Reading after the unpause would catch the returning runner's new turn.""" + hooks = _RunnerGoneStubHooks( + command=[{"state": "applied", "outcome": "lost", "target_turn_id": "t1"}], + executions=[{"terminal_outcome": "execution_lost"}], + stream={"flags": {"is_running": False}}, + ) + stop_calls = [] + terminal = [ + { + "type": "error", + "attributes": {"code": "execution_lost", "settled_by": "watchdog"}, + } + ] + clock = _FakeClock() + measured = sc._measure_runner_gone_while_paused( + hooks, + "sess", + "t1", + do_stop=lambda: (stop_calls.append("stop"), {"status": 202})[1], + read_terminal=lambda: terminal, + sweep_wait=60.0, + poll_interval=5.0, + clock=clock, + ) + assert "pause" in hooks.calls and "unpause" in hooks.calls + assert ( + hooks.calls.index("pause") + < hooks.calls.index("stream_row") + < hooks.calls.index("unpause") + ) + assert measured["stream_row"] == {"flags": {"is_running": False}} + assert measured["stream_row"]["flags"]["is_running"] is False + assert measured["settled_at"] is not None + assert measured["paused_read_at"] is not None + assert stop_calls == ["stop"] + + +def test_runner_gone_measurement_unpauses_even_when_it_never_settles(): + """No settlement within the window still unpauses (never strand the runner) and still takes the + is_running read while paused, so the cell can report the timeout honestly.""" + hooks = _RunnerGoneStubHooks( + command=[], + executions=[], + stream={"flags": {"is_running": True}}, + settle_on_call=999, + ) + clock = _FakeClock() + measured = sc._measure_runner_gone_while_paused( + hooks, + "sess", + "t1", + do_stop=lambda: {"status": 202}, + read_terminal=lambda: [], + sweep_wait=6.0, + poll_interval=3.0, + clock=clock, + ) + assert measured["settled_at"] is None + assert "unpause" in hooks.calls + assert hooks.calls.index("stream_row") < hooks.calls.index("unpause") + + +def test_terminal_records_arriving_one_second_after_settle_pass_strict_check(): + clock = _FakeClock() + hooks = _RunnerGoneStubHooks( + command=[{"state": "applied", "outcome": "lost", "target_turn_id": "t1"}], + executions=[{"terminal_outcome": "execution_lost"}], + stream={"flags": {"is_running": False}}, + ) + + def read_terminal(): + if clock.time() < 1.0: + return [] + return [ + { + "type": "error", + "attributes": { + "code": "execution_lost", + "settled_by": "watchdog", + }, + }, + {"type": "done", "attributes": {"settled_by": "watchdog"}}, + ] + + measured = sc._measure_runner_gone_while_paused( + hooks, + "sess", + "t1", + do_stop=lambda: {"status": 202}, + read_terminal=read_terminal, + sweep_wait=60.0, + poll_interval=5.0, + clock=clock, + ) + + assert clock.time() == 1.0 + assert sc._require_watchdog_execution_lost(measured["terminal"]) is None + + +def test_terminal_records_missing_for_budget_fail_with_old_message(): + clock = _FakeClock() + hooks = _RunnerGoneStubHooks( + command=[{"state": "applied", "outcome": "lost", "target_turn_id": "t1"}], + executions=[{"terminal_outcome": "execution_lost"}], + stream={"flags": {"is_running": False}}, + ) + measured = sc._measure_runner_gone_while_paused( + hooks, + "sess", + "t1", + do_stop=lambda: {"status": 202}, + read_terminal=lambda: [], + sweep_wait=60.0, + poll_interval=5.0, + clock=clock, + ) + verdict = sc._require_watchdog_execution_lost(measured["terminal"]) + + assert clock.time() == 20.0 + assert verdict == { + "pass": False, + "skip": False, + "why": "no watchdog execution_lost ending was found among the terminal records", + } + + +def test_sandbox_gone_settle_budget_derives_from_probe_defaults(): + saved = sc.SANDBOX_STARTUP_SLACK_S + sc.SANDBOX_STARTUP_SLACK_S = 0.0 + try: + expected = ( + sc.SANDBOX_LIVENESS_PROBE_INTERVAL_S * sc.SANDBOX_LIVENESS_PROBE_FAILURES + + sc.SANDBOX_GONE_SETTLE_SLACK_S + ) + assert sc.sandbox_gone_settle_budget_s() == expected + # Never shorter than the slow command's own duration would leave it, per the wait rule. + assert sc.SANDBOX_GONE_COMMAND_S > 0 + finally: + sc.SANDBOX_STARTUP_SLACK_S = saved + + +if __name__ == "__main__": + import inspect + + failures = 0 + tests = [ + (name, obj) + for name, obj in sorted(globals().items()) + if name.startswith("test_") and callable(obj) + ] + for name, fn in tests: + params = inspect.signature(fn).parameters + try: + if "monkeypatch" in params or "tmp_path" in params: + # Minimal standalone monkeypatch/tmp_path so this file runs without pytest too. + import os + import tempfile + + class _MonkeyPatch: + def __init__(self): + self._saved = {} + + def setenv(self, k, v): + self._saved.setdefault(k, os.environ.get(k)) + os.environ[k] = v + + def delenv(self, k, raising=False): + self._saved.setdefault(k, os.environ.get(k)) + os.environ.pop(k, None) + + def restore(self): + for k, v in self._saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + kwargs = {} + mp = _MonkeyPatch() + if "monkeypatch" in params: + kwargs["monkeypatch"] = mp + if "tmp_path" in params: + kwargs["tmp_path"] = pathlib.Path(tempfile.mkdtemp()) + fn(**kwargs) + mp.restore() + else: + fn() + print(f"PASS {name}") + except Exception as exc: # noqa: BLE001 + failures += 1 + print(f"FAIL {name}: {exc}") + print(f"\n{len(tests) - failures}/{len(tests)} passed") + sys.exit(1 if failures else 0) diff --git a/.all-contributorsrc b/.all-contributorsrc index 3d32986c767..6700b105353 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -798,6 +798,15 @@ "contributions": [ "code" ] + }, + { + "login": "oforiwaasam", + "name": "Lily Sam", + "avatar_url": "https://avatars.githubusercontent.com/u/41793292?v=4", + "profile": "https://oforiwaasam.github.io/portfolio/", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/.github/workflows/15-website-preview.yml b/.github/workflows/15-website-preview.yml index 933c858c8b0..1fe334c5dff 100644 --- a/.github/workflows/15-website-preview.yml +++ b/.github/workflows/15-website-preview.yml @@ -5,6 +5,9 @@ on: types: [opened, synchronize, reopened, ready_for_review] paths: - 'website/**' + # The site publishes /openapi.json from this spec (website/scripts/copy-openapi.mjs), + # so a regenerated spec has to redeploy the site. + - 'docs/docs/reference/openapi.json' - '.github/workflows/15-website-preview.yml' workflow_dispatch: inputs: @@ -22,10 +25,39 @@ concurrency: cancel-in-progress: true env: + # wrangler itself is a pinned devDependency in website/package.json (the worker + # bundler must match everywhere); this stays as documentation of that pin. WRANGLER_VERSION: "4.113.0" R2_FONTS_BUCKET: agenta-brand-fonts jobs: + # Unit tests and typecheck run for EVERY PR, forks included — the deploy job + # below is gated on secrets and would otherwise leave contributors with no + # signal on the worker's negotiation logic. + check: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + package_json_file: website/package.json + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: 'pnpm' + cache-dependency-path: website/pnpm-lock.yaml + + - name: Install dependencies + run: cd website && pnpm install --frozen-lockfile + + - name: Run unit tests + run: cd website && pnpm test + deploy: name: Build and deploy preview runs-on: ubuntu-latest @@ -77,7 +109,9 @@ jobs: run: | cd website alias="pr-${PR_NUMBER}" - pnpm dlx "wrangler@${WRANGLER_VERSION}" versions upload \ + # wrangler is a pinned devDependency (see website/package.json), so the + # worker bundles with the same version everywhere. + pnpm exec wrangler versions upload \ --preview-alias "$alias" 2>&1 | tee /tmp/wrangler.log url=$(grep -oiE 'Version Preview Alias URL: https://[a-z0-9.-]+\.workers\.dev' /tmp/wrangler.log | sed 's/.*: //' | head -1) if [ -z "$url" ]; then @@ -86,15 +120,12 @@ jobs: fi echo "url=$url" >> "$GITHUB_OUTPUT" + # Asserts the site serves AND that the edge worker's agent-readiness + # behavior survived: content negotiation, Vary, 406, the JSON/markdown + # 404s, /openapi.json — plus the static behavior it must not break + # (the _redirects 308s and trailing-slash normalization). - name: Verify preview serves the site - run: | - for i in 1 2 3 4 5; do - code=$(curl -s -o /dev/null -w '%{http_code}' "${{ steps.deploy.outputs.url }}/") - [ "$code" = "200" ] && exit 0 - sleep 5 - done - echo "::error::Preview URL did not return 200 (last code: $code)" - exit 1 + run: cd website && bash scripts/verify-deployment.sh "${{ steps.deploy.outputs.url }}" - name: Comment preview URL uses: marocchino/sticky-pull-request-comment@v2 diff --git a/.github/workflows/16-website-production.yml b/.github/workflows/16-website-production.yml index d11f0b17bbd..f2c2f552a93 100644 --- a/.github/workflows/16-website-production.yml +++ b/.github/workflows/16-website-production.yml @@ -5,6 +5,9 @@ on: branches: [main] paths: - 'website/**' + # The site publishes /openapi.json from this spec (website/scripts/copy-openapi.mjs), + # so a regenerated spec has to redeploy the site. + - 'docs/docs/reference/openapi.json' - '.github/workflows/16-website-production.yml' workflow_dispatch: @@ -16,6 +19,8 @@ concurrency: cancel-in-progress: false env: + # wrangler itself is a pinned devDependency in website/package.json (the worker + # bundler must match everywhere); this stays as documentation of that pin. WRANGLER_VERSION: "4.113.0" R2_FONTS_BUCKET: agenta-brand-fonts @@ -43,6 +48,11 @@ jobs: - name: Install dependencies run: cd website && pnpm install --frozen-lockfile + # Gate production on the worker's unit tests: a negotiation regression + # would be invisible in the build output. + - name: Run unit tests + run: cd website && pnpm test + - name: Build site env: R2_S3_ENDPOINT: ${{ secrets.R2_S3_ENDPOINT }} @@ -62,7 +72,9 @@ jobs: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} run: | cd website - pnpm dlx "wrangler@${WRANGLER_VERSION}" deploy \ + # wrangler is a pinned devDependency (see website/package.json), so the + # worker bundles with the same version everywhere. + pnpm exec wrangler deploy \ --config wrangler.production.jsonc 2>&1 | tee /tmp/wrangler.log url=$(grep -oiE 'https://[a-z0-9.-]+\.workers\.dev' /tmp/wrangler.log | head -1) if [ -z "$url" ]; then @@ -71,12 +83,19 @@ jobs: fi echo "url=$url" >> "$GITHUB_OUTPUT" + # This zone-level rule runs before the marketing worker, static assets, and + # the /docs/* proxy. That is what lets every variant canonicalize in one hop. + - name: Enforce canonical host and protocol + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: cd website && node scripts/configure-canonical-redirect.mjs + + # Asserts the site serves AND that the edge worker's agent-readiness + # behavior survived: content negotiation, Vary, 406, the JSON/markdown + # 404s, /openapi.json — plus the static behavior it must not break + # (the _redirects 308s and trailing-slash normalization). - name: Verify production serves the site - run: | - for i in 1 2 3 4 5; do - code=$(curl -s -o /dev/null -w '%{http_code}' "${{ steps.deploy.outputs.url }}/") - [ "$code" = "200" ] && exit 0 - sleep 5 - done - echo "::error::Production URL did not return 200 (last code: $code)" - exit 1 + run: cd website && bash scripts/verify-deployment.sh "${{ steps.deploy.outputs.url }}" + + - name: Verify canonical host and protocol + run: cd website && bash scripts/verify-canonical-redirects.sh diff --git a/.github/workflows/20-docs-monitor.yml b/.github/workflows/20-docs-monitor.yml new file mode 100644 index 00000000000..a783b8d06f1 --- /dev/null +++ b/.github/workflows/20-docs-monitor.yml @@ -0,0 +1,26 @@ +name: "20 - docs production monitor" + +on: + schedule: + - cron: "7,22,37,52 * * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: docs-production-monitor + cancel-in-progress: true + +jobs: + status: + name: Check sitemap and representative pages + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Check browser and crawler responses + run: node docs/scripts/monitor-production.mjs diff --git a/.github/workflows/44-railway-tests.yml b/.github/workflows/44-railway-tests.yml index 2b27d3fe136..e277eacce8d 100644 --- a/.github/workflows/44-railway-tests.yml +++ b/.github/workflows/44-railway-tests.yml @@ -630,9 +630,8 @@ jobs: checks: write pull-requests: write contents: read - # 30 min was too tight: retries on slow tests (e.g. the 7-minute trace test x3 - # attempts) plus real fixes need headroom before the job gets cut off mid-run. - timeout-minutes: 45 + # Reserve time around Playwright's 20-minute limit for setup and artifacts. + timeout-minutes: 30 strategy: fail-fast: false matrix: @@ -646,8 +645,8 @@ jobs: AGENTA_TEST_OSS_OWNER_PASSWORD: ${{ secrets.AGENTA_TEST_OSS_OWNER_PASSWORD }} AGENTA_TEST_LLM_PROVIDER: mock AGENTA_TEST_EPHEMERAL_PROJECT: "true" - AGENTA_MOBILE_GATE: ${{ inputs.mobile_gate_enabled }} - AGENTA_MOBILE_REVERSE_GATE: ${{ inputs.mobile_reverse_gate_enabled }} + AGENTA_MOBILE_GATE: ${{ inputs.mobile_gate_enabled && 'true' || 'false' }} + AGENTA_MOBILE_REVERSE_GATE: ${{ inputs.mobile_reverse_gate_enabled && 'true' || 'false' }} TESTMAIL_API_KEY: ${{ secrets.TESTMAIL_API_KEY }} TESTMAIL_NAMESPACE: ${{ secrets.TESTMAIL_NAMESPACE }} steps: @@ -708,7 +707,7 @@ jobs: # run-tests.ts runs every source for the layer (package vitest, then # playwright) and resolves the playwright dir from --layer itself, # skipping internally when that dir is empty. No pre-guard needed. - CMD=(pnpm exec tsx playwright/scripts/run-tests.ts --layer "${{ matrix.layer }}" --reporter=html,github) + CMD=(pnpm exec tsx playwright/scripts/run-tests.ts --layer "${{ matrix.layer }}") if [ -n "${{ inputs.coverage }}" ]; then CMD+=(--coverage "${{ inputs.coverage }}"); fi if [ -n "${{ inputs.lens }}" ]; then CMD+=(--lens "${{ inputs.lens }}"); fi @@ -749,11 +748,12 @@ jobs: - name: Upload test results id: upload_results uses: actions/upload-artifact@v4 - if: failure() + if: always() with: name: playwright-results-${{ matrix.layer }} path: | web/tests/results/oss/ + !web/tests/results/oss/state.json web/packages/*/test-results/ retention-days: 7 @@ -772,7 +772,7 @@ jobs: echo "| --- | --- | --- |" echo "| Workflow run | [Open run](${RUN_URL}) | Current run |" echo "| Playwright report | ${REPORT_URL:+[Artifact](${REPORT_URL})} | Layer: \`${{ matrix.layer }}\` |" - echo "| Playwright results | ${RESULTS_URL:+[Artifact](${RESULTS_URL})} | Uploaded on failures only |" + echo "| Playwright results | ${RESULTS_URL:+[Artifact](${RESULTS_URL})} | Includes interrupted runs |" echo "| AGENTA_WEB_URL | \`${AGENTA_WEB_URL}\` | |" echo "| AGENTA_API_URL | \`${AGENTA_API_URL}\` | |" echo "| AGENTA_SERVICES_URL | \`${AGENTA_SERVICES_URL}\` | |" diff --git a/.gitleaksignore b/.gitleaksignore index 5c796507b58..82da7adbdc9 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -158,6 +158,8 @@ ad74134f522cde71f860cb59b6363a8fdf0a64c6:ee/setup_agenta_web.sh:generic-api-key: 590578c803d94d8ccb1a6ca977471f3d44b43fc3:hosting/helm/oss/templates/config/app-configmap.yaml:generic-api-key:45 1d8f08b267675726441fcaaae24572bb635c5eac:api/oss/src/utils/env.py:generic-api-key:53 55f27e52327062382beb299b162f94895268d766:web/oss/public/__ENV.js:generic-api-key:1 +012ae6318c880d19944ffe1ff51740da0613cd8a:services/runner/tests/unit/server.test.ts:generic-api-key:586 +e22c629b3113e522f4fdd918a9b0971e62145056:services/runner/tests/unit/server.test.ts:generic-api-key:889 c98a5da1a33d2c0986e3c66329eaa5237fbccf3d:hosting/docker-compose/ee/aws/docker-compose.oss.prod.yml:generic-api-key:73 bf0cd42bffc2581b1df6f56fa6e4b20ff9b68c33:hosting/docker-compose/ee/aws/docker-compose.oss.aws.yml:generic-api-key:61 52cd40cefd3121eea2e21205e8208712b093529a:core/hosting/docker-compose/ee/docker-compose.dev.yml:generic-api-key:18 diff --git a/README.md b/README.md index 5b430fb991a..46db84da6d5 100644 --- a/README.md +++ b/README.md @@ -256,7 +256,7 @@ If Agenta is useful to you, star the repository and tell us what you build. ## Contributors ✨ -[![All Contributors](https://img.shields.io/badge/all_contributors-85-orange.svg?style=flat-square)](#contributors-) +[![All Contributors](https://img.shields.io/badge/all_contributors-86-orange.svg?style=flat-square)](#contributors-) Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): @@ -376,6 +376,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d suraj ✨
suraj ✨

💻 + Lily Sam
Lily Sam

💻 diff --git a/api/ee/src/dbs/postgres/sessions/records/dao.py b/api/ee/src/dbs/postgres/sessions/records/dao.py index 03d196a8e63..a0b74c1ee7a 100644 --- a/api/ee/src/dbs/postgres/sessions/records/dao.py +++ b/api/ee/src/dbs/postgres/sessions/records/dao.py @@ -99,10 +99,12 @@ async def delete_records_before_cutoff( type_=ARRAY(PG_UUID(as_uuid=True)), ) + # The key is (project_id, record_id). `RecordDBE.id` does not exist, so the + # earlier version of this statement raised before it deleted anything. expired = ( select( RecordDBE.project_id.label("project_id"), - RecordDBE.id.label("id"), + RecordDBE.record_id.label("record_id"), ) .where( RecordDBE.project_id == any_(project_ids_param), @@ -116,8 +118,8 @@ async def delete_records_before_cutoff( deleted = ( delete(RecordDBE) .where( - tuple_(RecordDBE.project_id, RecordDBE.id).in_( - select(expired.c.project_id, expired.c.id) + tuple_(RecordDBE.project_id, RecordDBE.record_id).in_( + select(expired.c.project_id, expired.c.record_id) ) ) .returning(literal(1).label("deleted")) diff --git a/api/ee/src/middlewares/throttling.py b/api/ee/src/middlewares/throttling.py index d8acc6afe22..30e1785ecbc 100644 --- a/api/ee/src/middlewares/throttling.py +++ b/api/ee/src/middlewares/throttling.py @@ -6,6 +6,7 @@ from oss.src.utils.caching import get_cache, set_cache from oss.src.utils.logging import get_module_logger +from oss.src.middlewares.auth import SECRET_RESOLVE_GRANT, request_has_grant from oss.src.utils.throttling import Algorithm, check_throttles from ee.src.core.access.entitlements.types import ( @@ -40,6 +41,14 @@ def _normalize_path(request: Request) -> str: return path +def _is_runner_record_ingest(request: Request, method: str, path: str) -> bool: + return ( + method == Method.POST.value + and path == "/sessions/records/ingest" + and request_has_grant(request, SECRET_RESOLVE_GRANT) + ) + + def _matches_endpoint( method: str, path: str, @@ -168,6 +177,11 @@ async def throttling_middleware(request: Request, call_next): if hasattr(request.state, "admin") and request.state.admin: return await call_next(request) + method = request.method.lower() + path = _normalize_path(request) + if _is_runner_record_ingest(request, method, path): + return await call_next(request) + organization_id = ( request.state.organization_id if hasattr(request.state, "organization_id") @@ -221,10 +235,6 @@ async def throttling_middleware(request: Request, call_next): if not throttles: return await call_next(request) - method = request.method.lower() - - path = _normalize_path(request) - # log.debug( # "[throttling] START", org=organization_id, plan=plan, method=method, path=path # ) diff --git a/api/ee/tests/pytest/unit/test_throttling.py b/api/ee/tests/pytest/unit/test_throttling.py new file mode 100644 index 00000000000..e8166864430 --- /dev/null +++ b/api/ee/tests/pytest/unit/test_throttling.py @@ -0,0 +1,101 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import Request, Response + +from ee.src.core.access.entitlements.types import ( + Bucket, + Category, + Mode, + Throttle, + Tracker, +) +from ee.src.middlewares.throttling import throttling_middleware +from oss.src.middlewares.auth import SECRET_RESOLVE_GRANT + + +def _request(path: str, *, grants: tuple[str, ...] = ()) -> Request: + request = Request( + { + "type": "http", + "method": "POST", + "path": path, + "root_path": "/api" if path.startswith("/api/") else "", + "headers": [], + } + ) + request.state.organization_id = "organization-1" + request.state.token_grants = grants + return request + + +async def test_runner_record_ingest_bypasses_plan_throttle(): + request = _request( + "/api/sessions/records/ingest", + grants=(SECRET_RESOLVE_GRANT,), + ) + call_next = AsyncMock(return_value=Response(status_code=204)) + + with ( + patch( + "ee.src.middlewares.throttling._get_plan", new_callable=AsyncMock + ) as get_plan, + patch( + "ee.src.middlewares.throttling.check_throttles", + new_callable=AsyncMock, + ) as check_throttles, + ): + response = await throttling_middleware(request, call_next) + + assert response.status_code == 204 + call_next.assert_awaited_once_with(request) + get_plan.assert_not_awaited() + check_throttles.assert_not_awaited() + + +@pytest.mark.parametrize( + ("path", "grants"), + [ + ("/sessions/records/ingest", ()), + ("/sessions/query", (SECRET_RESOLVE_GRANT,)), + ], +) +async def test_throttle_still_counts_browser_ingest_and_other_runner_routes( + path: str, + grants: tuple[str, ...], +): + request = _request(path, grants=grants) + call_next = AsyncMock(return_value=Response(status_code=204)) + standard = Throttle( + categories=[Category.STANDARD], + mode=Mode.INCLUDE, + bucket=Bucket(capacity=10, rate=10), + ) + allowed = SimpleNamespace( + allow=True, + tokens_remaining=9, + retry_after_seconds=0, + ) + + with ( + patch( + "ee.src.middlewares.throttling._get_plan", + new_callable=AsyncMock, + return_value="test-plan", + ) as get_plan, + patch( + "ee.src.middlewares.throttling.get_plan_entitlements", + return_value={Tracker.THROTTLES: [standard]}, + ), + patch( + "ee.src.middlewares.throttling.check_throttles", + new_callable=AsyncMock, + return_value=[allowed], + ) as check_throttles, + ): + response = await throttling_middleware(request, call_next) + + assert response.status_code == 204 + get_plan.assert_awaited_once_with("organization-1") + check_throttles.assert_awaited_once() diff --git a/api/entrypoints/routers.py b/api/entrypoints/routers.py index b01db2d512d..3683c9a9978 100644 --- a/api/entrypoints/routers.py +++ b/api/entrypoints/routers.py @@ -182,6 +182,11 @@ from oss.src.dbs.postgres.sessions.streams.dbes import SessionStreamDBE # noqa: F401 from oss.src.dbs.postgres.sessions.streams.dao import SessionStreamsDAO from oss.src.core.sessions.streams.service import SessionStreamsService +from oss.src.dbs.postgres.sessions.commands.dbes import SessionCommandDBE # noqa: F401 +from oss.src.dbs.postgres.sessions.commands.dao import SessionCommandsDAO +from oss.src.dbs.postgres.sessions.executions.dao import SessionExecutionsDAO +from oss.src.core.sessions.commands.service import SessionCommandsService +from oss.src.dbs.http.sessions.control_delivery_direct import DirectControlDelivery from oss.src.tasks.asyncio.sessions.orphan_sweep import orphan_sweep_loop from oss.src.dbs.redis.shared.engine import get_lock_engine @@ -279,8 +284,16 @@ async def lifespan(*args, **kwargs): except Exception as e: # noqa: BLE001 log.warning("Store bucket ensure failed at startup: %s", e) + # The execution watchdog. It needs the records plane to write the terminal outcome a + # dead runner owed, and the watch publisher so an open browser sees the turn close. _orphan_sweep_task = asyncio.create_task( - orphan_sweep_loop(_transactions_engine, _lock_engine) + orphan_sweep_loop( + _transactions_engine, + _lock_engine, + records_service=records_service, + watch_publisher=_sessions_watch_publisher, + commands_service=session_commands_service, + ) ) _attachment_sweep_task = asyncio.create_task( @@ -587,6 +600,8 @@ async def lifespan(*args, **kwargs): folders_dao = FoldersDAO(engine=_transactions_engine) session_streams_dao = SessionStreamsDAO(engine=_transactions_engine) session_turns_dao = SessionTurnsDAO(engine=_transactions_engine) +session_commands_dao = SessionCommandsDAO(engine=_transactions_engine) +session_executions_dao = SessionExecutionsDAO(engine=_transactions_engine) connections_dao = ConnectionsDAO(engine=_transactions_engine) mounts_dao = MountsDAO(engine=_transactions_engine) @@ -621,6 +636,7 @@ async def lifespan(*args, **kwargs): records_service = RecordsService( records_dao=records_dao, + executions_dao=session_executions_dao, ) @@ -838,6 +854,7 @@ async def lifespan(*args, **kwargs): interactions_service = SessionInteractionsService( interactions_dao=interactions_dao, watch_publisher=_sessions_watch_publisher, + records_service=records_service, ) triggers_service = TriggersService( @@ -1115,6 +1132,26 @@ async def _dispatch_detached_run(*, project_id, user_id, request) -> str: records_service=records_service, ) +# Durable session commands (Stop). The control-delivery adapter is chosen by one setting. +# `direct` posts the command to the runner's own /cancel over the hop that already carries hard +# kill; `long_poll` is not built yet, and naming it fails at boot rather than silently falling +# back to a transport the operator did not choose. +_control_adapter = (env.agenta.sessions.commands.adapter or "direct").strip().lower() +if _control_adapter != "direct": + raise RuntimeError( + f"AGENTA_SESSIONS_CONTROL_ADAPTER={_control_adapter!r} is not available in this build. " + "Only 'direct' is implemented; the long-poll adapter is a later change." + ) + +session_commands_service = SessionCommandsService( + commands_dao=session_commands_dao, + streams_service=session_streams_service, + interactions_service=interactions_service, + lock_engine=_lock_engine, + delivery=DirectControlDelivery(), + executions_dao=session_executions_dao, +) + sessions = SessionsRouter( streams_service=session_streams_service, records_service=records_service, @@ -1125,6 +1162,7 @@ async def _dispatch_detached_run(*, project_id, user_id, request) -> str: mounts_service=mounts_service, turns_service=session_turns_service, sessions_service=sessions_service, + commands_service=session_commands_service, respond_task=_interactions_worker.respond_interaction, interactions_dispatcher=_interactions_dispatcher, ) @@ -1599,6 +1637,12 @@ async def _dispatch_detached_run(*, project_id, user_id, request) -> str: tags=["Sessions"], ) +# After `root`, so the literal /sessions/ routes always win a path match. +app.include_router( + router=sessions.control.router, + tags=["Sessions"], +) + @app.get("/health", operation_id="health_check", tags=["Status"]) async def health_check(): diff --git a/api/entrypoints/worker_streams.py b/api/entrypoints/worker_streams.py index 18ee38bc887..e9125c1eb00 100644 --- a/api/entrypoints/worker_streams.py +++ b/api/entrypoints/worker_streams.py @@ -27,6 +27,10 @@ from oss.src.core.secrets.services import VaultService from oss.src.core.sessions.interactions.service import SessionInteractionsService from oss.src.core.sessions.records.service import RecordsService +from oss.src.core.sessions.records.streaming import ( + LIVE_FRAME_STREAM_NAME, + RECORD_STREAM_NAME, +) from oss.src.core.tracing.service import TracingService from oss.src.dbs.postgres.events.dao import EventsDAO from oss.src.dbs.postgres.secrets.dao import SecretsDAO @@ -37,6 +41,7 @@ from oss.src.dbs.redis.sessions.watch import SessionsWatchPublisher from oss.src.tasks.asyncio.events.worker import EventsWorker from oss.src.tasks.asyncio.sessions.records_worker import RecordsWorker +from oss.src.tasks.asyncio.sessions.live_relay_worker import LiveRelayWorker from oss.src.tasks.asyncio.shared.consumer import StreamConsumer from oss.src.tasks.asyncio.tracing.worker import TracingWorker from oss.src.tasks.asyncio.webhooks.dispatcher import WebhooksDispatcher @@ -85,7 +90,7 @@ async def _build_records_worker(redis_client: Redis) -> StreamConsumer: return RecordsWorker( service=RecordsService(records_dao=RecordsDAO()), redis_client=redis_client, - stream_name="streams:records", + stream_name=RECORD_STREAM_NAME, consumer_group="worker-records", # M3 live relay: post-append change notifications on the durable plane, # reusing this process's durable connection. @@ -97,6 +102,17 @@ async def _build_records_worker(redis_client: Redis) -> StreamConsumer: interactions_dao=SessionInteractionsDAO(), watch_publisher=watch_publisher, ), + # Redelivery bound for records the Postgres write rejected. + reclaim_min_idle_ms=env.agenta.sessions.records.reclaim_idle_ms, + max_deliveries=env.agenta.sessions.records.max_deliveries, + ) + + +async def _build_live_relay_worker(redis_client: Redis) -> StreamConsumer: + return LiveRelayWorker( + redis_client=redis_client, + stream_name=LIVE_FRAME_STREAM_NAME, + consumer_group="worker-session-live-relay", ) @@ -133,6 +149,22 @@ async def _build_events_worker(redis_client: Redis) -> StreamConsumer: ) +async def _initialize_consumer(consumer: StreamConsumer) -> None: + await consumer.create_consumer_group() + removed = await prune_idle_consumers( + url=env.redis.uri_durable, + queue_name=consumer.stream_name, + consumer_group_name=consumer.consumer_group, + keep=consumer.consumer_name, + ) + if removed: + log.info( + "[STREAMS] Pruned idle consumers", + stream=consumer.stream_name, + removed=removed, + ) + + async def main_async() -> int: try: streams = _selected_streams() @@ -162,19 +194,19 @@ async def main_async() -> int: ] for consumer in consumers: - await consumer.create_consumer_group() - removed = await prune_idle_consumers( - url=env.redis.uri_durable, - queue_name=consumer.stream_name, - consumer_group_name=consumer.consumer_group, - keep=consumer.consumer_name, - ) - if removed: - log.info( - "[STREAMS] Pruned idle consumers", - stream=consumer.stream_name, - removed=removed, + await _initialize_consumer(consumer) + + if env.sessions.shared_reader and "records" in streams: + try: + live_relay = await _build_live_relay_worker(redis_client) + await _initialize_consumer(live_relay) + except Exception: + log.error( + "[STREAMS] Live relay disabled after initialization failure", + exc_info=True, ) + else: + consumers.append(live_relay) log.info("[STREAMS] Starting worker-streams", selected=streams) diff --git a/api/oss/databases/postgres/migrations/core_oss/versions/oss000000022_add_session_commands.py b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000022_add_session_commands.py new file mode 100644 index 00000000000..d150e2b11ab --- /dev/null +++ b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000022_add_session_commands.py @@ -0,0 +1,164 @@ +"""add session commands, and the two session_streams columns a Stop needs + +A user Stop reached the runner only through the absence of a Redis lock, discovered on the next +heartbeat up to 30 seconds later. Nothing recorded that a Stop had been asked for, so a Stop +against an unreachable runner was simply lost and no execution ever reached a terminal outcome +anyone could read. + +`session_commands` is that record. One row per durable request to change an execution. `state` +is where the COMMAND is (pending, claimed, applied, obsolete); `outcome` is what happened to the +EXECUTION (stopped, not_running, superseded_by_newer_turn, failed, lost). The two are separate +columns because they answer different questions and settle at different times. + +Two columns join `session_streams`: + + * `stopping_turn_id` names the execution an accepted Stop is waiting on, written in the same + transaction as the command insert and cleared at settlement. + * `turn_started_at` records when the row's current `turn_id` started. Nothing else could serve + the stale-Stop guard: `updated_at` is the heartbeat timestamp and moves every 30 seconds, + runner-minted turn ids are uuid4 and carry no time, the Redis lock value is a bare turn id + that a Lua compare reads whole, and the `session_turns` append is fire-and-forget so a + running turn may have no row at all. + +Both are nullable and backfill to NULL. A row written before this migration yields no +comparison, and the guard then does not fire — deliberately, because a guard that refused every +Stop it could not verify would break the common case to protect a rare one. + +Revision ID: oss000000022 +Revises: oss000000021 +Create Date: 2026-09-02 23:30:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision: str = "oss000000022" +down_revision: Union[str, None] = "oss000000021" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "session_commands", + sa.Column("id", sa.UUID(as_uuid=True), nullable=False), + sa.Column("project_id", sa.UUID(as_uuid=True), nullable=False), + sa.Column("session_id", sa.String(), nullable=False), + sa.Column("kind", sa.String(), nullable=False), + sa.Column("target_turn_id", sa.String(), nullable=True), + sa.Column("expected_turn_id", sa.String(), nullable=True), + sa.Column("state", sa.String(), nullable=False), + sa.Column("claimed_by", sa.String(), nullable=True), + sa.Column("claim_expires_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column( + "claim_count", + sa.Integer(), + server_default="0", + nullable=False, + ), + sa.Column("outcome", sa.String(), nullable=True), + sa.Column("idempotency_key", sa.String(), nullable=True), + sa.Column("settled_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column("data", sa.JSON(), nullable=True), + sa.Column( + "flags", + postgresql.JSONB(none_as_null=True), + nullable=True, + ), + sa.Column( + "tags", + postgresql.JSONB(none_as_null=True), + nullable=True, + ), + sa.Column("meta", sa.JSON(), nullable=True), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.func.current_timestamp(), + nullable=True, + ), + sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column("created_by_id", sa.UUID(as_uuid=True), nullable=True), + sa.Column("updated_by_id", sa.UUID(as_uuid=True), nullable=True), + sa.Column("deleted_by_id", sa.UUID(as_uuid=True), nullable=True), + sa.CheckConstraint("kind IN ('cancel')", name="ck_session_commands_kind"), + sa.CheckConstraint( + "state IN ('pending', 'claimed', 'applied', 'obsolete')", + name="ck_session_commands_state", + ), + sa.ForeignKeyConstraint( + ["project_id"], + ["projects.id"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("project_id", "id"), + sa.UniqueConstraint( + "project_id", + "session_id", + "idempotency_key", + name="uq_session_commands_idempotency", + ), + ) + # One open command per target execution, enforced by the database because admission's + # read-then-insert races itself: two Stops in the same instant both find no open command. + op.create_index( + "uq_session_commands_open_target", + "session_commands", + ["project_id", "session_id", "kind", "target_turn_id"], + unique=True, + postgresql_where=sa.text( + "state IN ('pending', 'claimed') AND deleted_at IS NULL" + ), + ) + op.create_index( + "ix_session_commands_open", + "session_commands", + ["project_id", "session_id", "created_at"], + postgresql_where=sa.text( + "state IN ('pending', 'claimed') AND deleted_at IS NULL" + ), + ) + op.create_index( + "ix_session_commands_claims", + "session_commands", + ["claim_expires_at"], + postgresql_where=sa.text("state = 'claimed' AND deleted_at IS NULL"), + ) + op.create_index( + "ix_session_commands_project_session", + "session_commands", + ["project_id", "session_id", "created_at"], + ) + # The runner reports an outcome with the command id alone; it holds no project credential, + # so that read cannot use the primary key's leading column. + op.create_index( + "ix_session_commands_id", + "session_commands", + ["id"], + ) + + op.add_column( + "session_streams", + sa.Column("stopping_turn_id", sa.String(), nullable=True), + ) + op.add_column( + "session_streams", + sa.Column("turn_started_at", sa.TIMESTAMP(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("session_streams", "turn_started_at") + op.drop_column("session_streams", "stopping_turn_id") + op.drop_index("ix_session_commands_id", table_name="session_commands") + op.drop_index("ix_session_commands_project_session", table_name="session_commands") + op.drop_index("ix_session_commands_claims", table_name="session_commands") + op.drop_index("ix_session_commands_open", table_name="session_commands") + op.drop_index("uq_session_commands_open_target", table_name="session_commands") + op.drop_table("session_commands") diff --git a/api/oss/databases/postgres/migrations/core_oss/versions/oss000000023_add_session_executions.py b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000023_add_session_executions.py new file mode 100644 index 00000000000..1cc271cf519 --- /dev/null +++ b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000023_add_session_executions.py @@ -0,0 +1,43 @@ +"""add authoritative session execution terminal outcomes + +Revision ID: oss000000023 +Revises: oss000000022 +Create Date: 2026-09-03 22:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "oss000000023" +down_revision: Union[str, None] = "oss000000022" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "session_executions", + sa.Column("project_id", sa.UUID(as_uuid=True), nullable=False), + sa.Column("session_id", sa.String(), nullable=False), + sa.Column("execution_id", sa.String(), nullable=False), + sa.Column("terminal_outcome", sa.String(), nullable=False), + sa.Column("settled_by", sa.String(), nullable=False), + sa.Column("settled_at", sa.TIMESTAMP(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("project_id", "session_id", "execution_id"), + ) + op.create_index( + "ix_session_executions_project_session", + "session_executions", + ["project_id", "session_id"], + ) + + +def downgrade() -> None: + op.drop_index( + "ix_session_executions_project_session", table_name="session_executions" + ) + op.drop_table("session_executions") diff --git a/api/oss/databases/postgres/migrations/core_oss/versions/oss000000024_add_execution_redis_reconciliation.py b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000024_add_execution_redis_reconciliation.py new file mode 100644 index 00000000000..6d3e7c12b7f --- /dev/null +++ b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000024_add_execution_redis_reconciliation.py @@ -0,0 +1,41 @@ +"""track execution Redis reconciliation + +Revision ID: oss000000024 +Revises: oss000000023 +Create Date: 2026-09-03 22:30:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "oss000000024" +down_revision: Union[str, None] = "oss000000023" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "session_executions", + sa.Column("redis_reconciled_at", sa.TIMESTAMP(timezone=True), nullable=True), + ) + op.create_index( + "ix_session_executions_redis_unreconciled", + "session_executions", + ["settled_at"], + postgresql_where=sa.text( + "settled_by = 'runner' AND terminal_outcome = 'stopped' " + "AND redis_reconciled_at IS NULL" + ), + ) + + +def downgrade() -> None: + op.drop_index( + "ix_session_executions_redis_unreconciled", + table_name="session_executions", + ) + op.drop_column("session_executions", "redis_reconciled_at") diff --git a/api/oss/databases/postgres/migrations/core_oss/versions/oss000000026_add_execution_ending_marker.py b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000026_add_execution_ending_marker.py new file mode 100644 index 00000000000..f36af0bf0a8 --- /dev/null +++ b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000026_add_execution_ending_marker.py @@ -0,0 +1,38 @@ +"""add session execution ending marker + +Revision ID: oss000000026 +Revises: oss000000024 +Create Date: 2026-09-04 12:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "oss000000026" +down_revision: Union[str, None] = "oss000000024" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "session_executions", + sa.Column("ending_written_at", sa.TIMESTAMP(timezone=True), nullable=True), + ) + op.create_index( + "ix_session_executions_ending_unwritten", + "session_executions", + ["settled_at"], + postgresql_where=sa.text("ending_written_at IS NULL"), + ) + + +def downgrade() -> None: + op.drop_index( + "ix_session_executions_ending_unwritten", + table_name="session_executions", + ) + op.drop_column("session_executions", "ending_written_at") diff --git a/api/oss/databases/postgres/migrations/tracing_oss/versions/oss000000005_add_records_quarantined_at.py b/api/oss/databases/postgres/migrations/tracing_oss/versions/oss000000005_add_records_quarantined_at.py new file mode 100644 index 00000000000..1a5654f5a4e --- /dev/null +++ b/api/oss/databases/postgres/migrations/tracing_oss/versions/oss000000005_add_records_quarantined_at.py @@ -0,0 +1,35 @@ +"""add_records_quarantined_at + +Revision ID: oss000000005 +Revises: oss000000004 +Create Date: 2026-09-03 12:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "oss000000005" +down_revision: Union[str, None] = "oss000000004" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # A record that reached ingest for a turn the execution watchdog had already ended. + # Nullable and forward-fill only, like every other column on this table: the tracing DB + # is never backfilled, and no existing row can be classified retroactively anyway. + # + # No index. Every read that filters on it is already scoped to one project and one + # session by an existing index, and the column is null on all but a handful of rows. + op.add_column( + "records", + sa.Column("quarantined_at", sa.TIMESTAMP(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("records", "quarantined_at") diff --git a/api/oss/databases/postgres/migrations/tracing_oss/versions/oss000000006_add_session_sequence_cursors.py b/api/oss/databases/postgres/migrations/tracing_oss/versions/oss000000006_add_session_sequence_cursors.py new file mode 100644 index 00000000000..ca869d9ff9d --- /dev/null +++ b/api/oss/databases/postgres/migrations/tracing_oss/versions/oss000000006_add_session_sequence_cursors.py @@ -0,0 +1,63 @@ +"""add session sequence cursors + +Revision ID: oss000000006 +Revises: oss000000005 +Create Date: 2026-09-04 00:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "oss000000006" +down_revision: Union[str, None] = "oss000000005" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "session_sequence_cursors", + sa.Column("project_id", sa.UUID(), nullable=False), + sa.Column("session_id", sa.String(), nullable=False), + sa.Column("latest_sequence", sa.BigInteger(), nullable=False), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=True, + ), + sa.Column( + "updated_at", + sa.TIMESTAMP(timezone=True), + nullable=True, + ), + sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column("created_by_id", sa.UUID(), nullable=True), + sa.Column("updated_by_id", sa.UUID(), nullable=True), + sa.Column("deleted_by_id", sa.UUID(), nullable=True), + sa.PrimaryKeyConstraint("project_id", "session_id"), + ) + op.add_column("records", sa.Column("sequence", sa.BigInteger(), nullable=True)) + with op.get_context().autocommit_block(): + op.create_index( + "ux_records_session_id_sequence", + "records", + ["project_id", "session_id", "sequence"], + unique=True, + postgresql_concurrently=True, + ) + + +def downgrade() -> None: + with op.get_context().autocommit_block(): + op.drop_index( + "ux_records_session_id_sequence", + table_name="records", + postgresql_concurrently=True, + ) + op.drop_column("records", "sequence") + op.drop_table("session_sequence_cursors") diff --git a/api/oss/src/apis/fastapi/evaluations/router.py b/api/oss/src/apis/fastapi/evaluations/router.py index ef2204b683a..568d433102b 100644 --- a/api/oss/src/apis/fastapi/evaluations/router.py +++ b/api/oss/src/apis/fastapi/evaluations/router.py @@ -407,7 +407,7 @@ def __init__( operation_id="refresh_metrics", ) - # TODO: deprecate once web uses /mretrics/refresh + # TODO: deprecate once web uses /metrics/refresh # POST /api/evaluations/metrics/ self.router.add_api_route( path="/metrics/", diff --git a/api/oss/src/apis/fastapi/sessions/live_events.py b/api/oss/src/apis/fastapi/sessions/live_events.py new file mode 100644 index 00000000000..b7e24188c40 --- /dev/null +++ b/api/oss/src/apis/fastapi/sessions/live_events.py @@ -0,0 +1,188 @@ +import asyncio +import json +import math +from contextlib import suppress +from typing import Any, AsyncIterator, Awaitable, Callable, Optional + +from oss.src.core.sessions.records.dtos import ( + SessionDurableEvent, + SessionDurableEventsReplay, +) + +from oss.src.utils.logging import get_module_logger + +log = get_module_logger(__name__) + +HEARTBEAT_FRAME = ": heartbeat\n\n" +RELAY_CLOSE_EVENT = "relay-close" + + +def retry_frame(retry_milliseconds: int) -> str: + return f"retry: {retry_milliseconds}\n\n" + + +def ready_frame(*, watermark: Optional[int] = None) -> str: + payload = {} if watermark is None else {"watermark": watermark} + return f"event: ready\ndata: {json.dumps(payload)}\n\n" + + +def close_frame(*, reason: str, reconnect: bool) -> str: + payload = json.dumps({"reason": reason, "reconnect": reconnect}) + return f"event: {RELAY_CLOSE_EVENT}\ndata: {payload}\n\n" + + +def format_live_frame(raw: Any) -> Optional[str]: + try: + payload = json.loads(raw) + except (ValueError, TypeError): + return None + if not isinstance(payload, dict) or payload.get("kind") not in {"frame", "event"}: + return None + return f"data: {json.dumps(payload)}\n\n" + + +def format_durable_event(event: SessionDurableEvent) -> str: + return f"data: {event.model_dump_json()}\n\n" + + +async def live_event_stream( + *, + channel: str, + pubsub_factory: Callable[[], Any], + authorization_check: Callable[[], Awaitable[bool]], + authorization_recheck_seconds: float, + heartbeat_seconds: float, + retry_milliseconds: int, + buffer_limit: int, + after: int = 0, + replay_query: Optional[ + Callable[[int], Awaitable[SessionDurableEventsReplay]] + ] = None, +) -> AsyncIterator[str]: + queue: asyncio.Queue[str] = asyncio.Queue(maxsize=max(1, buffer_limit)) + stopped = asyncio.Event() + pubsub = pubsub_factory() + cursor = after + seen_event_ids: set[str] = set() + + def force_close(*, reason: str, reconnect: bool) -> None: + while not queue.empty(): + with suppress(asyncio.QueueEmpty): + queue.get_nowait() + queue.put_nowait(close_frame(reason=reason, reconnect=reconnect)) + stopped.set() + + def enqueue(frame: str) -> None: + try: + queue.put_nowait(frame) + except asyncio.QueueFull: + force_close(reason="slow_reader", reconnect=True) + + async def replay() -> int: + nonlocal cursor + if replay_query is None: + return cursor + result = await replay_query(cursor) + for event in result.events: + if event.frame_or_event_id in seen_event_ids: + continue + if event.sequence is not None and event.sequence <= cursor: + continue + seen_event_ids.add(event.frame_or_event_id) + # Replay is finite and must backpressure; only live producers may outrun readers. + await queue.put(format_durable_event(event)) + if event.sequence is not None: + cursor = event.sequence + cursor = max(cursor, result.watermark) + return cursor + + async def pump() -> None: + try: + await pubsub.subscribe(channel) + await queue.put(retry_frame(retry_milliseconds)) + # Unlike a live event's batch maximum, ready reports the authoritative session + # cursor returned by replay (or the requested `after` cursor without replay). + watermark = await replay() + await queue.put(ready_frame(watermark=watermark)) + loop = asyncio.get_running_loop() + last_authorization_check = loop.time() + poll_seconds = min( + 1.0, + heartbeat_seconds, + authorization_recheck_seconds, + ) + idle_polls_per_heartbeat = max( + 1, math.ceil(heartbeat_seconds / poll_seconds) + ) + idle_polls = 0 + + while not stopped.is_set(): + message = await pubsub.get_message( + ignore_subscribe_messages=True, + timeout=poll_seconds, + ) + now = loop.time() + if now - last_authorization_check >= authorization_recheck_seconds: + last_authorization_check = now + try: + authorized = await authorization_check() + except Exception: + authorized = False + if not authorized: + force_close(reason="authorization_revoked", reconnect=False) + return + + if message is None: + idle_polls += 1 + if idle_polls >= idle_polls_per_heartbeat: + idle_polls = 0 + enqueue(HEARTBEAT_FRAME) + continue + idle_polls = 0 + if message.get("type") != "message": + continue + try: + payload = json.loads(message.get("data")) + except (ValueError, TypeError): + continue + if ( + replay_query is not None + and isinstance(payload, dict) + and payload.get("kind") == "event" + ): + await replay() + continue + frame = format_live_frame(message.get("data")) + if frame is not None: + enqueue(frame) + except asyncio.CancelledError: + raise + except Exception: + log.warning( + "[SESSION-LIVE] relay reader failed", channel=channel, exc_info=True + ) + force_close(reason="relay_unavailable", reconnect=True) + finally: + try: + await pubsub.unsubscribe(channel) + except Exception: + log.warning("[SESSION-LIVE] pubsub unsubscribe failed", channel=channel) + try: + await pubsub.aclose() + except Exception: + log.warning("[SESSION-LIVE] pubsub close failed", channel=channel) + + task = asyncio.create_task(pump()) + try: + while True: + frame = await queue.get() + yield frame + if frame.startswith(f"event: {RELAY_CLOSE_EVENT}"): + break + if task.done() and queue.empty(): + break + finally: + stopped.set() + task.cancel() + with suppress(asyncio.CancelledError): + await task diff --git a/api/oss/src/apis/fastapi/sessions/models.py b/api/oss/src/apis/fastapi/sessions/models.py index 01f47695ce5..2b577657656 100644 --- a/api/oss/src/apis/fastapi/sessions/models.py +++ b/api/oss/src/apis/fastapi/sessions/models.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Annotated, Any, Dict, List, Literal, Optional +from typing import Annotated, Any, Dict, List, Literal, Optional, Union from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator @@ -13,7 +13,11 @@ SessionStream, SessionStreamQueryFlags, ) -from oss.src.core.sessions.records.dtos import SessionRecord +from oss.src.core.sessions.records.dtos import ( + SessionLiveFrame, + SessionRecord, + SessionRecordsReadState, +) from oss.src.core.sessions.interactions.dtos import ( SessionInteraction, SessionInteractionData, @@ -148,13 +152,41 @@ class SessionStreamsResponse(BaseModel): # --------------------------------------------------------------------------- +class SessionTranscriptWindowing(BaseModel): + """Deliberate exception to the shared cursor `Windowing`. + + `through_sequence` pins the snapshot, so offset paging over an append-only log inside that + bound is stable: a concurrent append raises `latest_sequence`, never the page contents. The + transcript reader also needs to seek within one pinned snapshot, which a forward-only cursor + cannot express. + """ + + offset: int = Field(default=0, ge=0) + limit: int = Field(default=100, ge=1, le=200) + through_sequence: int = Field(ge=0) + + class SessionRecordQueryRequest(BaseModel): session_id: str + windowing: Optional[SessionTranscriptWindowing] = None class SessionRecordsQueryResponse(BaseModel): count: int records: List[SessionRecord] + windowing: Optional[SessionTranscriptWindowing] = None + + +class SessionSnapshotPending(BaseModel): + inputs: List[Any] = Field(default_factory=list) + interactions: List[SessionInteraction] = Field(default_factory=list) + + +class SessionSnapshotResponse(BaseModel): + session: SessionStream + execution: Optional[SessionTurn] = None + pending: SessionSnapshotPending + read: SessionRecordsReadState class SessionRecordResponse(BaseModel): @@ -335,6 +367,7 @@ class SessionTurnsResponse(BaseModel): class SessionRecordIngestRequest(BaseModel): # project scope comes from the caller's credential, never the body session_id: str + kind: Optional[Literal["frame"]] = None # Optional stable id (uuid5) from the producer; absent when it has no stable key. record_id: Optional[UUID] = None record_index: Optional[int] = None @@ -346,3 +379,132 @@ class SessionRecordIngestRequest(BaseModel): # Both forward-fill only (tracing-DB rule) — absent on producers that predate this. turn_id: Optional[str] = None span_id: Optional[OTelSpanId] = None + version: Optional[Literal[1]] = None + execution_id: Optional[str] = None + frame_or_event_id: Optional[str] = None + frame_index: Optional[int] = Field(default=None, ge=0) + entity_id: Optional[str] = None + type: Optional[str] = None + payload: Optional[Dict[str, Any]] = None + created_at: Optional[datetime] = None + + @model_validator(mode="after") + def validate_live_frame(self) -> "SessionRecordIngestRequest": + if self.kind != "frame": + return self + required = ( + "version", + "execution_id", + "frame_or_event_id", + "frame_index", + "entity_id", + "type", + "payload", + "created_at", + ) + missing = [name for name in required if getattr(self, name) is None] + if missing: + raise ValueError(f"frame fields missing: {', '.join(missing)}") + SessionLiveFrame( + version=self.version, + kind="frame", + session_id=self.session_id, + execution_id=self.execution_id, + frame_or_event_id=self.frame_or_event_id, + frame_index=self.frame_index, + entity_id=self.entity_id, + type=self.type, + payload=self.payload, + created_at=self.created_at, + ) + return self + + +SessionRecordIngestBatch = Annotated[ + List[SessionRecordIngestRequest], + Field(min_length=1), +] +SessionRecordIngestBody = Union[ + SessionRecordIngestRequest, + SessionRecordIngestBatch, +] + + +# --------------------------------------------------------------------------- +# Session control: durable commands (Stop) +# --------------------------------------------------------------------------- + + +class SessionCancelRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + # Optional stale-request guard. When present, the API cancels only this execution and + # refuses the request if another one is running. When absent, it cancels whichever + # execution is active when the request is applied. A person never types this: the browser + # fills it from the session's own state, and a first-party client always sends it. + expected_execution_id: Optional[str] = Field( + default=None, + description=( + "Optional stale-request guard honored only in cancel mode; ignored for send, " + "steer, and attach." + ), + ) + + +class SessionCommandRef(BaseModel): + """The durable command an accepted request created. Identity and DELIVERY state only. + + A client must not read execution state from it. `state` says where the command is; the + session's own state says what the execution is doing. + """ + + id: UUID + state: Literal["pending", "claimed", "applied", "obsolete"] + + +class SessionExecutionRef(BaseModel): + """What the caller should render. `id` is null when the session was idle.""" + + id: Optional[str] = None + state: Literal["stopping", "idle"] + + +class SessionCancelResponse(BaseModel): + command: SessionCommandRef + execution: SessionExecutionRef + + +class SessionExecutionOutcome(BaseModel): + model_config = ConfigDict(extra="forbid") + + # The execution the runner acted on. Null when it held none. + id: Optional[str] = None + # stopped: cancelled as asked. not_running: no such execution on this runner. + # superseded_by_newer_turn: the held execution started after the command arrived. + # failed: the cancel itself failed. + state: Literal["stopped", "failed", "not_running", "superseded_by_newer_turn"] + # Short and human-readable, present only when `state` is "failed". + error: Optional[str] = Field(default=None, max_length=2000) + + +class SessionControlOutcomeRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + replica_id: str = Field(min_length=1, max_length=128) + # The command's terminal state. `applied` means the runner did the work; `obsolete` means + # there was nothing to do. + result: Literal["applied", "obsolete"] + execution: SessionExecutionOutcome + + +class SessionCommandSettlement(BaseModel): + id: UUID + state: Literal["applied", "obsolete"] + outcome: Literal[ + "stopped", "not_running", "superseded_by_newer_turn", "failed", "lost" + ] + settled_at: Optional[datetime] = None + + +class SessionControlOutcomeResponse(BaseModel): + command: SessionCommandSettlement diff --git a/api/oss/src/apis/fastapi/sessions/router.py b/api/oss/src/apis/fastapi/sessions/router.py index 3bf5eb22760..96281be9e4f 100644 --- a/api/oss/src/apis/fastapi/sessions/router.py +++ b/api/oss/src/apis/fastapi/sessions/router.py @@ -18,6 +18,7 @@ import re from functools import wraps +from secrets import compare_digest from uuid import UUID from fastapi import ( @@ -40,9 +41,15 @@ from oss.src.utils.exceptions import intercept_exceptions from oss.src.utils.logging import get_module_logger -from oss.src.dbs.redis.sessions.contract import project_watch_channel, watch_channel -from oss.src.dbs.redis.shared.engine import get_streams_engine +from oss.src.dbs.redis.sessions.contract import ( + live_events_channel, + project_watch_channel, + watch_channel, +) +from oss.src.dbs.redis.shared.engine import get_lock_engine, get_streams_engine +from oss.src.dbs.redis.sessions.locks import get_running_owner from oss.src.apis.fastapi.sessions.watch import watch_event_stream +from oss.src.apis.fastapi.sessions.live_events import live_event_stream from oss.src.core.access.permissions.types import Permission from oss.src.core.access.permissions.service import check_action_access @@ -50,6 +57,7 @@ # Core domain imports — new paths from oss.src.core.sessions.streams.dtos import ( + CommandMode, SessionHeartbeatRequest, SessionHeartbeatResult, SessionStreamCommandRequest, @@ -62,13 +70,28 @@ ConcurrencyLimitExceeded, SessionIdInvalid, SessionTurnInUse, + SessionTurnMismatch, SessionStreamAlreadyExists, SessionStreamNotFound, ) -from oss.src.core.sessions.streams.service import SessionStreamsService +from oss.src.core.sessions.streams.service import ( + SessionStreamsService, + derive_command_mode, +) +from oss.src.core.sessions.commands.service import SessionCommandsService +from oss.src.core.sessions.commands.types import ( + ExecutionExpectationFailed, + SessionCommandIdempotencyConflict, + SessionCommandNotClaimable, + SessionCommandNotFound, +) from oss.src.core.sessions.records.service import RecordsService -from oss.src.core.sessions.records.dtos import SessionRecordEvent -from oss.src.core.sessions.records.streaming import publish_record +from oss.src.core.sessions.records.dtos import ( + MAX_LIVE_FRAME_BYTES, + SessionLiveFrame, + SessionRecordEvent, +) +from oss.src.core.sessions.records.streaming import publish_live_frame, publish_record from oss.src.core.sessions.interactions.dtos import ( SessionInteractionCreate, SessionInteractionKind, @@ -118,16 +141,26 @@ from oss.src.core.workflows.service import WorkflowsService from oss.src.apis.fastapi.sessions.models import ( + SessionCancelRequest, + SessionCancelResponse, + SessionCommandRef, + SessionCommandSettlement, + SessionControlOutcomeRequest, + SessionControlOutcomeResponse, + SessionExecutionRef, # streams SessionDetachRequest, SessionStreamQueryRequest, SessionStreamResponse, SessionStreamsResponse, # records - SessionRecordIngestRequest, + SessionRecordIngestBody, SessionRecordQueryRequest, SessionRecordResponse, SessionRecordsQueryResponse, + SessionSnapshotPending, + SessionSnapshotResponse, + SessionTranscriptWindowing, # interactions SessionInteractionCancelStaleRequest, SessionInteractionCreateRequest, @@ -197,6 +230,15 @@ async def wrapper(*args, **kwargs): "liveness": e.liveness, }, ) from e + except SessionTurnMismatch as e: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "message": e.message, + "expected_execution_id": e.expected_turn_id, + "actual_execution_id": e.actual_turn_id, + }, + ) from e except ConcurrencyLimitExceeded as e: raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, @@ -289,9 +331,11 @@ def __init__( *, service: SessionStreamsService, interactions_service: SessionInteractionsService, + records_service: Optional[RecordsService] = None, ) -> None: self._service = service self._interactions_service = interactions_service + self._records_service = records_service self.router = APIRouter() # Unified collection surface on /sessions/streams/, keyed by ?session_id=. @@ -355,6 +399,14 @@ def __init__( tags=["Sessions"], response_model=None, ) + self.router.add_api_route( + "/sessions/{session_id}/events", + self.session_events, + methods=["GET"], + operation_id="watch_session_events", + tags=["Sessions"], + response_model=None, + ) self.router.add_api_route( "/sessions/watch", self.watch_project, @@ -371,6 +423,9 @@ async def set_session_stream( request: Request, payload: SessionStreamCommandRequest, ) -> SessionStreamCommandResponse: + # Use Redis time before database waits can reorder cancellation against a new turn. + arrived_at_ms = await self._service.clock_ms() + project_id = request.state.project_id user_id = request.state.user_id @@ -382,14 +437,45 @@ async def set_session_stream( if not has_permission: raise FORBIDDEN_EXCEPTION - await self._service.check_runner_concurrency_limit(project_id=project_id) + mode = derive_command_mode(payload) + + # A cancel starts nothing, so the per-project concurrency limit must not gate it. Before + # this, a project at its limit could not stop the very runs that held the limit — the one + # request that frees capacity was the one refused with 429. + if mode != CommandMode.cancel: + await self._service.check_runner_concurrency_limit(project_id=project_id) - return await self._service.command( + response = await self._service.command( + arrived_at_ms=arrived_at_ms, project_id=project_id, user_id=user_id, request=payload, ) + if mode == CommandMode.cancel: + # Close only the displaced turns' gates; the service publishes their watch events. + try: + for turn_id in response.cancelled_turn_ids: + await self._interactions_service.cancel_session_pending( + project_id=UUID(str(project_id)), + session_id=response.session_id, + only_turn_id=turn_id, + ) + if not response.cancelled_turn_ids: + await self._interactions_service.cancel_session_pending( + project_id=UUID(str(project_id)), + session_id=response.session_id, + ) + except Exception: + log.error( + "[SESSIONS] accepted Stop interaction cleanup failed", + exc_info=True, + project_id=str(project_id), + session_id=response.session_id, + ) + + return response + @intercept_exceptions() @_handle_session_exceptions() async def fetch_session_stream( @@ -488,6 +574,9 @@ async def heartbeat_session_stream( if not has_permission: raise FORBIDDEN_EXCEPTION + if payload.release_owner: + _assert_runner_token(request) + heartbeat = await self._service.heartbeat( project_id=project_id, request=payload, @@ -620,6 +709,60 @@ async def watch_session_stream( }, ) + @intercept_exceptions() + async def session_events( + self, + request: Request, + session_id: str, + after: int = Query(default=0, ge=0), + ) -> StreamingResponse: + if not env.sessions.shared_reader: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + + _validate_session_id_http(session_id) + project_id = str(request.state.project_id) + user_id = str(request.state.user_id) + + async def authorized() -> bool: + return await check_action_access( + user_uid=user_id, + project_id=project_id, + permission=Permission.VIEW_SESSIONS, + ) + + if not await authorized(): + raise FORBIDDEN_EXCEPTION + if self._records_service is None: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE) + + async def replay(cursor: int): + return await self._records_service.get_events_after( + project_id=UUID(project_id), + session_id=session_id, + after=cursor, + ) + + stream = live_event_stream( + channel=live_events_channel(project_id, session_id), + pubsub_factory=lambda: get_streams_engine().get_redis().pubsub(), + authorization_check=authorized, + authorization_recheck_seconds=env.sessions.live_auth_recheck_seconds, + heartbeat_seconds=env.sessions.watch_heartbeat_seconds, + retry_milliseconds=env.sessions.watch_retry_milliseconds, + buffer_limit=env.sessions.live_reader_buffer_limit, + after=after, + replay_query=replay, + ) + return StreamingResponse( + stream, + media_type="text/event-stream", + headers={ + "Cache-Control": "no-store", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + @intercept_exceptions() async def watch_project( self, @@ -712,10 +855,35 @@ async def query_records( ): raise FORBIDDEN_EXCEPTION - records = await self.records_service.get_records( - project_id=UUID(request.state.project_id), - session_id=query_request.session_id, - ) + records = ( + await self.records_service.get_records( + project_id=UUID(request.state.project_id), + session_id=query_request.session_id, + ) + if query_request.windowing is None + else None + ) + if query_request.windowing is not None: + page = await self.records_service.get_records_page( + project_id=UUID(request.state.project_id), + session_id=query_request.session_id, + offset=query_request.windowing.offset, + limit=query_request.windowing.limit, + through_sequence=query_request.windowing.through_sequence, + ) + return SessionRecordsQueryResponse( + count=len(page.records), + records=page.records, + windowing=SessionTranscriptWindowing( + offset=page.next_offset + if page.next_offset is not None + else page.offset, + limit=page.limit, + through_sequence=page.through_sequence, + ) + if page.next_offset is not None + else None, + ) return SessionRecordsQueryResponse( count=len(records), records=records, @@ -744,7 +912,7 @@ async def get_record_event( async def ingest_record_event( self, request: Request, - body: SessionRecordIngestRequest, + body: SessionRecordIngestBody, ) -> dict: project_id = request.state.project_id if not await check_action_access( @@ -754,6 +922,76 @@ async def ingest_record_event( ): raise FORBIDDEN_EXCEPTION + if isinstance(body, list): + if not body or any(item.kind != "frame" for item in body): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Batched record ingest accepts live frames only.", + ) + frames = body + else: + frames = [body] if body.kind == "frame" else [] + + if frames: + first = frames[0] + _validate_session_id_http(first.session_id) + if any( + frame.session_id != first.session_id + or frame.execution_id != first.execution_id + for frame in frames[1:] + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="A live frame batch must share one session and execution.", + ) + content_length = request.headers.get("content-length") + if content_length is not None: + try: + request_size = int(content_length) + except ValueError as error: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Content-Length must be an integer.", + ) from error + if request_size < 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Content-Length cannot be negative.", + ) + if request_size > MAX_LIVE_FRAME_BYTES: + raise HTTPException( + status_code=status.HTTP_413_CONTENT_TOO_LARGE, + detail=( + f"Live frame request exceeds {MAX_LIVE_FRAME_BYTES} bytes." + ), + ) + current_execution_id = await get_running_owner( + get_lock_engine(), + project_id=str(project_id), + session_id=first.session_id, + ) + if current_execution_id != first.execution_id: + raise FORBIDDEN_EXCEPTION + for frame in frames: + await publish_live_frame( + organization_id=UUID(request.state.organization_id), + project_id=UUID(project_id), + frame=SessionLiveFrame( + version=frame.version, + kind="frame", + session_id=frame.session_id, + execution_id=frame.execution_id, + frame_or_event_id=frame.frame_or_event_id, + frame_index=frame.frame_index, + entity_id=frame.entity_id, + type=frame.type, + payload=frame.payload, + created_at=frame.created_at, + ), + ) + return {"ok": True} + + assert not isinstance(body, list) await publish_record( organization_id=UUID(request.state.organization_id), project_id=UUID(project_id), @@ -1667,8 +1905,20 @@ class SessionsRootRouter: three mutations. """ - def __init__(self, *, sessions_service: SessionsService) -> None: + def __init__( + self, + *, + sessions_service: SessionsService, + streams_service: Optional[SessionStreamsService] = None, + records_service: Optional[RecordsService] = None, + interactions_service: Optional[SessionInteractionsService] = None, + turns_service: Optional[SessionTurnsService] = None, + ) -> None: self.sessions_service = sessions_service + self.streams_service = streams_service + self.records_service = records_service + self.interactions_service = interactions_service + self.turns_service = turns_service self.router = APIRouter() self.router.add_api_route( @@ -1709,6 +1959,73 @@ def __init__(self, *, sessions_service: SessionsService) -> None: response_model_exclude_none=True, tags=["Sessions"], ) + self.router.add_api_route( + "/sessions/{session_id}", + self.get_session_snapshot, + methods=["GET"], + operation_id="get_session_snapshot", + status_code=status.HTTP_200_OK, + response_model=SessionSnapshotResponse, + response_model_exclude_none=True, + tags=["Sessions"], + ) + + @intercept_exceptions() + @_handle_session_exceptions() + async def get_session_snapshot( + self, + request: Request, + session_id: str, + ) -> SessionSnapshotResponse: + if not env.sessions.shared_reader: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + _validate_session_id_http(session_id) + if not await check_action_access( + user_uid=str(request.state.user_id), + project_id=str(request.state.project_id), + permission=Permission.VIEW_SESSIONS, + ): + raise FORBIDDEN_EXCEPTION + if not all( + ( + self.streams_service, + self.records_service, + self.interactions_service, + self.turns_service, + ) + ): + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE) + + project_id = UUID(str(request.state.project_id)) + session = await self.streams_service.fetch( + project_id=project_id, + session_id=session_id, + ) + if session is None: + raise SessionStreamNotFound(session_id) + read = await self.records_service.get_read_state( + project_id=project_id, + session_id=session_id, + ) + if getattr(session, "history_incomplete", False): + read = read.model_copy(update={"history_complete": False}) + execution = await self.turns_service.latest_turn( + project_id=project_id, + session_id=session_id, + ) + interactions = await self.interactions_service.query_interactions( + project_id=project_id, + query=SessionInteractionQuery( + session_id=session_id, + status=SessionInteractionStatus.pending, + ), + ) + return SessionSnapshotResponse( + session=sanitize_session_stream(session), + execution=execution, + pending=SessionSnapshotPending(interactions=interactions), + read=read, + ) @intercept_exceptions() async def query_sessions( @@ -1845,6 +2162,214 @@ async def unarchive_session( ) +# --------------------------------------------------------------------------- +# Session control — durable commands (Stop) +# --------------------------------------------------------------------------- + + +def _handle_command_exceptions(): + """Map the commands plane's domain errors onto status codes. + + A separate decorator from `_handle_session_exceptions` so the two planes' error vocabularies + stay apart: a conflict here means "the execution you named is not the one running", which is + a different thing from the streams plane's "this session is already busy". + """ + + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + try: + return await func(*args, **kwargs) + except SessionIdInvalid as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=e.message, + ) from e + except ExecutionExpectationFailed as e: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "message": e.message, + "current_execution_id": e.current, + }, + ) from e + except SessionCommandIdempotencyConflict as e: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=e.message, + ) from e + except SessionCommandNotFound as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=e.message, + ) from e + except SessionCommandNotClaimable as e: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={"message": e.message, "state": e.state}, + ) from e + + return wrapper + + return decorator + + +class SessionControlRouter: + """The Stop plane: one public route and one internal one. + + `POST /sessions/{session_id}/cancel` is the product's Stop. It is deliberately NOT behind + the runner concurrency limit: refusing to STOP work because a project is at its run limit + would be the exact wrong answer to a busy project. + + `POST /sessions/control/commands/{command_id}/outcome` is how the runner reports what + happened. It authenticates with the shared runner token rather than a project credential, + because the runner holds no project credential of its own for a command it was handed. The + command id resolves the project, so a caller still cannot reach across tenants: it can only + settle a command whose id it already knows and that it currently holds the claim on. + """ + + def __init__( + self, + *, + commands_service: SessionCommandsService, + ) -> None: + self._service = commands_service + self.router = APIRouter() + + self.router.add_api_route( + "/sessions/{session_id}/cancel", + self.cancel_session_execution, + methods=["POST"], + operation_id="cancel_session_execution", + tags=["Sessions"], + ) + self.router.add_api_route( + "/sessions/control/commands/{command_id}/outcome", + self.report_command_outcome, + methods=["POST"], + operation_id="report_session_command_outcome", + tags=["Sessions"], + include_in_schema=False, + ) + + @intercept_exceptions() + @_handle_command_exceptions() + async def cancel_session_execution( + self, + request: Request, + session_id: str, + payload: Optional[SessionCancelRequest] = None, + ) -> JSONResponse: + project_id = request.state.project_id + user_id = request.state.user_id + + has_permission = await check_action_access( + user_uid=str(user_id), + project_id=str(project_id), + permission=Permission.RUN_SESSIONS, + ) + if not has_permission: + raise FORBIDDEN_EXCEPTION + + if not env.agenta.sessions.durable_stop: + legacy = await self._service.request_cancel_legacy( + project_id=UUID(str(project_id)), + user_id=UUID(str(user_id)), + session_id=session_id, + expected_execution_id=( + payload.expected_execution_id if payload else None + ), + ) + return JSONResponse( + status_code=status.HTTP_200_OK, + content=legacy.model_dump(mode="json"), + ) + + idempotency_key = request.headers.get("Idempotency-Key") + if idempotency_key is not None: + idempotency_key = ( + idempotency_key.strip()[:_MAX_IDEMPOTENCY_KEY_CHARACTERS] or None + ) + + admission = await self._service.request_cancel( + project_id=UUID(str(project_id)), + user_id=UUID(str(user_id)) if user_id else None, + session_id=session_id, + expected_execution_id=payload.expected_execution_id if payload else None, + idempotency_key=idempotency_key, + ) + + body = SessionCancelResponse( + command=SessionCommandRef( + id=admission.command.id, + state=admission.command.state.value, + ), + execution=SessionExecutionRef( + id=admission.execution_id, + state="stopping" if admission.accepted else "idle", + ), + ) + # 202 and not 200 for the accepted case: the work is not done when the response + # returns. The caller learns the outcome from the session's own state. + return JSONResponse( + status_code=( + status.HTTP_202_ACCEPTED if admission.accepted else status.HTTP_200_OK + ), + content=body.model_dump(mode="json"), + ) + + @intercept_exceptions() + @_handle_command_exceptions() + async def report_command_outcome( + self, + request: Request, + command_id: UUID, + payload: SessionControlOutcomeRequest, + ) -> SessionControlOutcomeResponse: + _assert_runner_token(request) + + settled = await self._service.report_outcome( + command_id=command_id, + replica_id=payload.replica_id, + result=payload.result, + execution_id=payload.execution.id, + execution_state=payload.execution.state, + error=payload.execution.error, + ) + return SessionControlOutcomeResponse( + command=SessionCommandSettlement( + id=settled.id, + state=settled.state.value, + outcome=settled.outcome.value if settled.outcome else "failed", + settled_at=settled.settled_at, + ) + ) + + +def _assert_runner_token(request: Request) -> None: + """The runner proves it is the platform runtime with the shared secret both sides hold. + + Constant-time compare, so a wrong token leaks no length or prefix through timing. A missing + configured token fails closed: an unset secret must never mean "let everyone in". + """ + expected = env.runner.token + if not expected: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="runner token is not configured on this deployment", + ) + presented = request.headers.get("X-Agenta-Runner-Token") or "" + if not presented: + authorization = request.headers.get("Authorization") or "" + if authorization.lower().startswith("bearer "): + presented = authorization[7:].strip() + if not compare_digest(presented.encode("utf-8"), expected.encode("utf-8")): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Unauthorized", + ) + + # --------------------------------------------------------------------------- # Top-level composer # --------------------------------------------------------------------------- @@ -1861,6 +2386,11 @@ class SessionsRouter: sessions_router.mounts.router → prefix /sessions sessions_router.turns.router → prefix /sessions/turns sessions_router.root.router → no prefix (paths include /sessions/query, /sessions/, /sessions/archive, /sessions/unarchive) + sessions_router.control.router → no prefix (paths include /sessions/{session_id}/cancel and /sessions/control/…) + + `control` MUST be mounted AFTER `root`. `/sessions/{session_id}/cancel` is a two-segment + path and `/sessions/query` is one, so they cannot actually collide — but mounting the + literal routes first keeps that true for any two-segment literal added later. """ def __init__( @@ -1875,12 +2405,14 @@ def __init__( mounts_service: MountsService, turns_service: SessionTurnsService, sessions_service: SessionsService, + commands_service: SessionCommandsService, respond_task: Optional[Any] = None, interactions_dispatcher: Optional[Any] = None, ) -> None: self.streams = SessionStreamsRouter( service=streams_service, interactions_service=interactions_service, + records_service=records_service, ) self.records = RecordsRouter(records_service=records_service) self.interactions = InteractionsRouter( @@ -1897,4 +2429,11 @@ def __init__( mounts_service=mounts_service, ) self.turns = SessionTurnsRouter(turns_service=turns_service) - self.root = SessionsRootRouter(sessions_service=sessions_service) + self.root = SessionsRootRouter( + sessions_service=sessions_service, + streams_service=streams_service, + records_service=records_service, + interactions_service=interactions_service, + turns_service=turns_service, + ) + self.control = SessionControlRouter(commands_service=commands_service) diff --git a/api/oss/src/apis/fastapi/sessions/utils.py b/api/oss/src/apis/fastapi/sessions/utils.py index 766831c1608..119412ef91d 100644 --- a/api/oss/src/apis/fastapi/sessions/utils.py +++ b/api/oss/src/apis/fastapi/sessions/utils.py @@ -15,6 +15,7 @@ from oss.src.dbs.postgres.sessions.streams.mappings import ( SESSION_RESERVED_TAG_NAMESPACE, ) +from oss.src.utils.env import env SessionStreamT = TypeVar("SessionStreamT", bound=SessionStream) @@ -266,4 +267,11 @@ def sanitize_session_stream( ) -> Optional[SessionStreamT]: if stream is None: return None - return stream.model_copy(update={"tags": sanitize_session_tags(stream.tags)}) + return stream.model_copy( + update={ + "tags": sanitize_session_tags(stream.tags), + "capabilities": stream.capabilities.model_copy( + update={"shared_reader": env.sessions.shared_reader} + ), + } + ) diff --git a/api/oss/src/apis/fastapi/workflows/router.py b/api/oss/src/apis/fastapi/workflows/router.py index 3e073b0fb19..f4117b0333d 100644 --- a/api/oss/src/apis/fastapi/workflows/router.py +++ b/api/oss/src/apis/fastapi/workflows/router.py @@ -1,5 +1,5 @@ from inspect import isawaitable -from typing import Optional +from typing import Any, Optional from uuid import UUID, uuid4 from fastapi import APIRouter, Request, status, HTTPException, Depends @@ -115,6 +115,79 @@ log = get_module_logger(__name__) +_SANDBOX_CREDENTIAL_PATH = ("parameters", "agent", "sandbox", "credentials") + + +def _overlaps_sandbox_credentials(path: tuple[str, ...]) -> bool: + if not path: + return False + common = min(len(path), len(_SANDBOX_CREDENTIAL_PATH)) + return path[:common] == _SANDBOX_CREDENTIAL_PATH[:common] + + +def _changes_sandbox_credentials(value: Any, path: tuple[str, ...] = ()) -> bool: + if hasattr(value, "model_dump"): + value = value.model_dump(mode="json", exclude_none=True) + if isinstance(value, dict): + for key, child in value.items(): + segments = tuple( + part for part in str(key).replace("/", ".").split(".") if part + ) + next_path = (*path, *segments) + if len(next_path) >= 2 and next_path[-2:] == ("sandbox", "credentials"): + return True + if _changes_sandbox_credentials(child, next_path): + return True + elif isinstance(value, list): + if ( + path[-1:] in (("target",), ("path",)) + and value + and all(isinstance(item, str) for item in value) + ): + if _overlaps_sandbox_credentials(tuple(value)): + return True + if path[-1:] == ("remove",): + for item in value: + if not isinstance(item, str): + continue + segments = tuple( + part for part in item.replace("/", ".").split(".") if part + ) + if _overlaps_sandbox_credentials(segments): + return True + return any(_changes_sandbox_credentials(item, path) for item in value) + return False + + +async def _require_secret_attachment_access(request: Request, payload: Any) -> None: + serialized = ( + payload.model_dump(mode="json", exclude_none=True) + if hasattr(payload, "model_dump") + else payload + ) + if not _changes_sandbox_credentials(serialized): + return + if not await check_action_access( # type: ignore + user_uid=request.state.user_id, + project_id=request.state.project_id, + permission=Permission.EDIT_SECRET, # type: ignore + ): + raise FORBIDDEN_EXCEPTION # type: ignore + + +async def _require_fork_secret_attachment_access( + request: Request, + workflows_service: WorkflowsService, + workflow_fork_request: WorkflowVariantForkRequest, +) -> None: + source_revision = await workflows_service.fetch_workflow_revision( + project_id=UUID(request.state.project_id), + workflow_variant_ref=workflow_fork_request.workflow_variant_ref, + workflow_revision_ref=workflow_fork_request.workflow_revision_ref, + ) + if source_revision is not None: + await _require_secret_attachment_access(request, source_revision) + class WorkflowsRouter: def __init__( @@ -1251,6 +1324,12 @@ async def fork_workflow_variant( ): raise FORBIDDEN_EXCEPTION # type: ignore + await _require_fork_secret_attachment_access( + request, + self.workflows_service, + workflow_fork_request, + ) + workflow_variant = await self.workflows_service.fork_workflow_variant( project_id=UUID(request.state.project_id), user_id=UUID(request.state.user_id), @@ -1288,6 +1367,10 @@ async def create_workflow_revision( ): raise FORBIDDEN_EXCEPTION # type: ignore + await _require_secret_attachment_access( + request, workflow_revision_create_request.workflow_revision + ) + workflow_revision = await self.workflows_service.commit_workflow_revision( project_id=UUID(request.state.project_id), user_id=UUID(request.state.user_id), @@ -1556,6 +1639,10 @@ async def _commit_workflow_revision( ): raise FORBIDDEN_EXCEPTION # type: ignore + await _require_secret_attachment_access( + request, workflow_revision_commit_request.workflow_revision + ) + if workflow_variant_id is not None and str(workflow_variant_id) != str( workflow_revision_commit_request.workflow_revision.workflow_variant_id ): diff --git a/api/oss/src/core/secrets/dtos.py b/api/oss/src/core/secrets/dtos.py index 9d0b69a526b..b8e3cb70e6f 100644 --- a/api/oss/src/core/secrets/dtos.py +++ b/api/oss/src/core/secrets/dtos.py @@ -101,6 +101,7 @@ class WebhookProviderDTO(BaseModel): class CustomSecretSettingsDTO(BaseModel): format: CustomSecretFormat + default_env_var: Optional[str] = None content: Optional[Union[str, Dict[str, Union[str, int, float, bool, None]]]] = None # text -> content is a str (stored verbatim); json -> a flat {str: primitive} map. diff --git a/api/oss/src/core/secrets/services.py b/api/oss/src/core/secrets/services.py index 2876986ec5c..604fe462728 100644 --- a/api/oss/src/core/secrets/services.py +++ b/api/oss/src/core/secrets/services.py @@ -230,6 +230,10 @@ def _resolve_update( stored_data=stored_secret_dto.data, update_data=resolved_update.secret.data, ) + _carry_over_custom_secret_metadata( + stored_data=stored_secret_dto.data, + update_data=resolved_update.secret.data, + ) # The payload was validated before the carry-over filled it in, so what the # validators actually saw was a value-less shape. Re-validate the merged result: # nothing reaches the row that a create of the same shape would have refused. @@ -242,6 +246,15 @@ def _resolve_update( return UpdateSecretDTO.model_validate(resolved_update.model_dump(mode="python")) +def _carry_over_custom_secret_metadata(*, stored_data: Any, update_data: Any) -> None: + stored = getattr(stored_data, "secret", None) + requested = getattr(update_data, "secret", None) + if stored is None or requested is None: + return + if "default_env_var" not in requested.model_fields_set: + requested.default_env_var = stored.default_env_var + + def _authorize_delete(stored_secret_dto: SecretResponseDTO) -> None: if stored_secret_dto.management is not None: raise ManagedSecretReadOnlyError() diff --git a/api/oss/src/core/sessions/commands/__init__.py b/api/oss/src/core/sessions/commands/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/api/oss/src/core/sessions/commands/dtos.py b/api/oss/src/core/sessions/commands/dtos.py new file mode 100644 index 00000000000..27b2f9c2ad2 --- /dev/null +++ b/api/oss/src/core/sessions/commands/dtos.py @@ -0,0 +1,123 @@ +"""Durable session commands — the data shapes. + +A command is one durable request to change an execution. Version one has one kind, `cancel`, +which the product calls Stop. + +Two ideas are kept apart on purpose, and the separation is the point of the whole record: + + * `state` says where the COMMAND is in its delivery (pending, claimed, applied, obsolete). + * `outcome` says what happened to the EXECUTION (stopped, not_running, ...). + +A client that draws a Stop button reads the execution; a client that retries safely reads the +command id. Merging them is what makes today's cancel ambiguous. +""" + +from datetime import datetime +from enum import Enum +from typing import Any, Dict, List, Optional +from uuid import UUID + +from pydantic import BaseModel + +from oss.src.core.shared.dtos import Identifier, Lifecycle + + +class SessionCommandKind(str, Enum): + cancel = "cancel" + + +class SessionCommandState(str, Enum): + """Where the command is in its delivery. `applied` and `obsolete` are terminal.""" + + pending = "pending" # durable, not yet taken by a runner + claimed = "claimed" # a runner holds a lease on it + applied = "applied" # the runner did the work and reported + obsolete = "obsolete" # there was nothing to do, or nobody could ever do it + + +class SessionCommandOutcome(str, Enum): + """What happened to the targeted execution. Null while the command is open.""" + + stopped = "stopped" # cancelled as asked + not_running = "not_running" # no such execution anywhere + superseded_by_newer_turn = ( + "superseded_by_newer_turn" # a later turn holds the session + ) + failed = "failed" # the cancel itself failed + lost = "lost" # nobody ever reported; the sweep settled it + + +class SessionCommand(Identifier, Lifecycle): + project_id: UUID + session_id: str + kind: SessionCommandKind + + # The execution the API resolved at admission and pinned. Null when nothing ran. + target_turn_id: Optional[str] = None + # The execution the caller asserted was running, stored exactly as sent. Null when none. + expected_turn_id: Optional[str] = None + + # The command's own arguments. Empty for `cancel`; reserved for steer and queue. + data: Optional[Dict[str, Any]] = None + + state: SessionCommandState + claimed_by: Optional[str] = None + claim_expires_at: Optional[datetime] = None + claim_count: int = 0 + + outcome: Optional[SessionCommandOutcome] = None + idempotency_key: Optional[str] = None + settled_at: Optional[datetime] = None + + tags: Optional[Dict[str, Any]] = None + meta: Optional[Dict[str, Any]] = None + + +class SessionCommandCreate(BaseModel): + """One insert. `state`/`outcome`/`settled_at` are carried because admission can insert a + command that is ALREADY settled (nothing was running, or a newer turn took the session), + and that must be one write, not an insert followed by an update.""" + + project_id: UUID + session_id: str + kind: SessionCommandKind = SessionCommandKind.cancel + + target_turn_id: Optional[str] = None + expected_turn_id: Optional[str] = None + data: Optional[Dict[str, Any]] = None + + state: SessionCommandState = SessionCommandState.pending + outcome: Optional[SessionCommandOutcome] = None + settled_at: Optional[datetime] = None + + idempotency_key: Optional[str] = None + + # The instant the service stamped as the request's arrival. It is stored as `created_at` + # rather than left to the server default, so the value the stale-Stop guard COMPARED is the + # value the row CARRIES. A guard that compares one timestamp and stores another is not a + # guard the runner can repeat. + created_at: Optional[datetime] = None + + +class SessionCommandSettle(BaseModel): + """The terminal transition, guarded on the states the caller expects to find. + + A SET and not one state, because the outcome report races the claim that is taken on the + runner's behalf. Admission inserts the command `pending`, hands it to the runner, and only + then writes `claimed`; a runner that aborts fast reports its outcome while the row is still + `pending`. Guarding on `claimed` alone refused that report with a conflict and left the + command open until the sweep called it lost. Both states are legitimate at the moment of the + write, so the compare-and-set covers both. + + `replica_id` guards a settlement that follows a claim: only the replica that holds the + claim may write the outcome. A `pending` row has no claim to violate, so the guard admits a + null `claimed_by` as well. It is None altogether when the API itself settles a command + nobody ever took, which is the `not_held` case and the sweep's `lost` case. + """ + + project_id: UUID + command_id: UUID + state: SessionCommandState + outcome: SessionCommandOutcome + expected_states: List[SessionCommandState] = [SessionCommandState.claimed] + replica_id: Optional[str] = None diff --git a/api/oss/src/core/sessions/commands/interfaces.py b/api/oss/src/core/sessions/commands/interfaces.py new file mode 100644 index 00000000000..9f87cd73106 --- /dev/null +++ b/api/oss/src/core/sessions/commands/interfaces.py @@ -0,0 +1,194 @@ +"""The two ports of the session commands plane. + +`SessionCommandsDAOInterface` is storage. `ControlDeliveryPort` is transport: how the API +reaches whichever runner process holds a session. Durability, authorization, idempotency, the +state machine and terminal settlement live in the service and must not move into an adapter. +""" + +from abc import ABC, abstractmethod +from datetime import datetime +from typing import Any, AsyncContextManager, List, NamedTuple, Optional +from uuid import UUID + +from pydantic import BaseModel + +from oss.src.core.sessions.commands.dtos import ( + SessionCommand, + SessionCommandCreate, + SessionCommandKind, + SessionCommandSettle, +) + + +class SessionScope(BaseModel): + """One session a runner holds warm. The routing input of a claim.""" + + project_id: UUID + session_id: str + + +class CommandCreateResult(NamedTuple): + """The stored command and whether this call inserted it.""" + + command: SessionCommand + inserted: bool + + +class DeliveryReceipt(BaseModel): + """What the TRANSPORT learned, never what happened to the execution. + + * `accepted` — a runner took the command and will report through the outcome route. + * `unreachable` — the transport failed. The command is durable, so a later claim or the + settlement sweep recovers it. + * `not_held` — a reachable runner said it does not hold that session, which lets the + service settle at once instead of waiting for the deadline. + """ + + status: str # "accepted" | "unreachable" | "not_held" + detail: Optional[str] = None + # Which runner process took it, when the transport learned that. The service uses it as the + # claim owner, so the outcome route's guard reads the same way on every transport. + replica_id: Optional[str] = None + + +class ControlDeliveryPort(ABC): + """How the API reaches the runner that holds a session. Transport only.""" + + @abstractmethod + async def deliver(self, *, command: SessionCommand) -> DeliveryReceipt: + """Make `command` reachable by whoever holds its session, promptly. + + Best effort: a failure here never fails admission, because the command is already + durable. + """ + + @abstractmethod + async def acknowledge(self, *, command_id: UUID, replica_id: str) -> None: + """Record that a replica took the command, for adapters that keep their own delivery + bookkeeping. A no-op where the claim compare-and-set already IS the acknowledgement.""" + + +class SessionCommandsDAOInterface(ABC): + @abstractmethod + def transaction(self) -> AsyncContextManager[Any]: + """Open a transaction that sibling session DAOs can share.""" + + @abstractmethod + async def create_command( + self, + *, + user_id: Optional[UUID], + command: SessionCommandCreate, + stopping_turn_id: Optional[str] = None, + ) -> SessionCommand: + """Insert one command and, in the SAME transaction, stamp the session row's + `stopping_turn_id`. Idempotent on `(project_id, session_id, idempotency_key)`.""" + + @abstractmethod + async def create_command_with_status( + self, + *, + user_id: Optional[UUID], + command: SessionCommandCreate, + stopping_turn_id: Optional[str] = None, + ) -> CommandCreateResult: + """Create a command and report whether this call inserted it.""" + + @abstractmethod + async def fetch_by_idempotency_key( + self, + *, + project_id: UUID, + session_id: str, + idempotency_key: str, + ) -> Optional[SessionCommand]: + """The command previously created for this session-scoped retry key.""" + + @abstractmethod + async def fetch_open_command( + self, + *, + project_id: UUID, + session_id: str, + kind: SessionCommandKind, + target_turn_id: Optional[str], + ) -> Optional[SessionCommand]: + """The open (`pending` or `claimed`) command for this exact target, if one exists. + This is what collapses two Stops in a row onto one command.""" + + @abstractmethod + async def fetch_command( + self, + *, + command_id: UUID, + project_id: Optional[UUID] = None, + ) -> Optional[SessionCommand]: + """One command by id. `project_id` is optional because the runner reports an outcome + with the command id alone and holds no project credential.""" + + @abstractmethod + async def claim_commands( + self, + *, + sessions: List[SessionScope], + replica_id: str, + lease_seconds: int, + limit: int, + ) -> List[SessionCommand]: + """Take up to `limit` pending commands for these sessions. Compare-and-set, so two API + replicas serving two claims at once never hand out the same command twice.""" + + @abstractmethod + async def claim_for_delivery( + self, + *, + project_id: UUID, + command_id: UUID, + replica_id: str, + lease_seconds: int, + ) -> Optional[SessionCommand]: + """Move ONE command from `pending` to `claimed` for a runner that just accepted it over + a direct call. The long-poll adapter reaches the same transition through + `claim_commands`; both exist so the outcome route's guard reads the same either way.""" + + @abstractmethod + async def record_delivery_attempt( + self, + *, + project_id: UUID, + command_id: UUID, + now: datetime, + max_deliveries: int, + ) -> Optional[SessionCommand]: + """Reserve one bounded delivery attempt and return the updated command.""" + + @abstractmethod + async def settle_command( + self, + *, + settle: SessionCommandSettle, + transaction: Optional[Any] = None, + ) -> Optional[SessionCommand]: + """Terminal transition, guarded on `state='claimed' AND claimed_by=:replica_id`. + None means the claim had expired or somebody else settled it first.""" + + @abstractmethod + async def clear_stopping_turn( + self, + *, + project_id: UUID, + session_id: str, + turn_id: Optional[str] = None, + ) -> None: + """Clear `session_streams.stopping_turn_id`. With `turn_id`, only when it matches, so a + late settlement cannot clear a NEWER Stop's marker.""" + + @abstractmethod + async def expire_claims( + self, + *, + now: datetime, + max_deliveries: int, + pending_before: Optional[datetime] = None, + ) -> List[SessionCommand]: + """Pending or claimed commands old enough for recovery.""" diff --git a/api/oss/src/core/sessions/commands/service.py b/api/oss/src/core/sessions/commands/service.py new file mode 100644 index 00000000000..3382652cdbb --- /dev/null +++ b/api/oss/src/core/sessions/commands/service.py @@ -0,0 +1,797 @@ +"""Durable session commands — admission, delivery and settlement. + +Version one has one command kind, `cancel`, which the product calls Stop. + +WHAT STOP MEANS HERE. Stop ends the WORK, not the session. The sandbox stays warm, the native +harness session stays resumable, and the next message continues the same conversation. That is +why this service never force-deletes the Redis `alive` key: it leaves it to its own time to +live, exactly as the end of an ordinary turn does. Force-deleting `alive` is what makes today's +cancel read as a session teardown. + +THE ORDER OF ADMISSION. + + 1. Stamp the arrival time FIRST, before reading anything. + 2. Resolve the target execution once, from Redis `running`, falling back to `alive`. + 3. Apply the three late-Stop guards (below). + 4. Insert the command and stamp `session_streams.stopping_turn_id` in ONE transaction. + 5. Only then call the runner. Delivery failure never fails the request, because the command + is already durable. + +Redis is not written at admission. The stopping execution keeps `alive` and `running` while it +stops, which is what prevents a second message from starting underneath it. + +THE LATE-STOP GUARDS. A Stop that arrives after its turn ended must not kill the next turn. + + * The caller's `expected_execution_id`, when sent, must name the running execution. It does + not, the request is refused with a conflict and nothing is written. + * When no expectation was sent and the running execution started AFTER this request arrived, + the command is inserted already settled and targets nothing. + * The target is resolved once and pinned. A turn that starts later has a different id, so a + pinned command can never reach it. The runner repeats the comparison against its own memory, + which is exact. +""" + +from datetime import datetime, timedelta, timezone +from typing import Any, List, Optional, Tuple +from uuid import UUID + +from oss.src.core.sessions.commands.dtos import ( + SessionCommand, + SessionCommandCreate, + SessionCommandKind, + SessionCommandOutcome, + SessionCommandSettle, + SessionCommandState, +) +from oss.src.core.sessions.commands.interfaces import ( + CommandCreateResult, + ControlDeliveryPort, + SessionCommandsDAOInterface, +) +from oss.src.core.sessions.commands.types import ( + ExecutionExpectationFailed, + SessionCommandIdempotencyConflict, + SessionCommandNotClaimable, + SessionCommandNotFound, +) +from oss.src.core.sessions.executions.interfaces import SessionExecutionsDAOInterface +from oss.src.core.sessions.interactions.service import SessionInteractionsService +from oss.src.core.sessions.streams.dtos import ( + SessionStreamCommandRequest, + SessionStreamCommandResponse, +) +from oss.src.core.sessions.streams.service import SessionStreamsService +from oss.src.core.sessions.streams.types import SessionIdInvalid, SessionTurnMismatch +from oss.src.dbs.redis.shared.engine import LockEngine +from oss.src.dbs.redis.sessions.contract import ( + HEARTBEAT_INTERVAL_SECONDS, + validate_session_id, +) +from oss.src.dbs.redis.sessions.locks import ( + get_alive_owner, + get_owner, + get_running_owner, + reconcile_stopped_turn, +) +from oss.src.utils.env import env +from oss.src.utils.logging import get_module_logger + +log = get_module_logger(__name__) + + +class CancelAdmission: + """What admission decided, in the shape the route answers with.""" + + def __init__( + self, + *, + command: SessionCommand, + execution_id: Optional[str], + accepted: bool, + ) -> None: + self.command = command + # What the caller should render: the execution being stopped, or nothing. + self.execution_id = execution_id + # True when an execution was running or parked and the command is on its way. The route + # answers 202 for it and 200 otherwise. + self.accepted = accepted + + +class _SettlementRejected(Exception): + pass + + +class SessionCommandsService: + def __init__( + self, + *, + commands_dao: SessionCommandsDAOInterface, + streams_service: SessionStreamsService, + interactions_service: SessionInteractionsService, + lock_engine: LockEngine, + delivery: ControlDeliveryPort, + executions_dao: Optional[SessionExecutionsDAOInterface] = None, + ) -> None: + self._dao = commands_dao + self._streams = streams_service + self._interactions = interactions_service + self._lock = lock_engine + self._delivery = delivery + self._executions = executions_dao + + # -- admission ---------------------------------------------------------- # + + async def request_cancel_legacy( + self, + *, + project_id: UUID, + user_id: UUID, + session_id: str, + expected_execution_id: Optional[str] = None, + ) -> SessionStreamCommandResponse: + """Use the heartbeat-carried Stop path kept for rollout rollback.""" + try: + return await self._streams.command( + project_id=project_id, + user_id=user_id, + request=SessionStreamCommandRequest( + session_id=session_id, + expected_execution_id=expected_execution_id, + ), + ) + except SessionTurnMismatch as error: + raise ExecutionExpectationFailed( + expected=error.expected_turn_id, + current=error.actual_turn_id, + ) from error + + async def request_cancel( + self, + *, + project_id: UUID, + user_id: Optional[UUID], + session_id: str, + expected_execution_id: Optional[str] = None, + idempotency_key: Optional[str] = None, + ) -> CancelAdmission: + if not validate_session_id(session_id): + raise SessionIdInvalid(session_id) + + # FIRST, before any read. The value compared below is the value stored as the row's + # `created_at`, so the runner can repeat the same comparison against its own memory. + received_at = datetime.now(timezone.utc) + + if idempotency_key is not None: + existing = await self._dao.fetch_by_idempotency_key( + project_id=project_id, + session_id=session_id, + idempotency_key=idempotency_key, + ) + if existing is not None: + if existing.expected_turn_id != expected_execution_id: + raise SessionCommandIdempotencyConflict( + idempotency_key=idempotency_key + ) + return self._admission_for_existing(existing) + + target_turn_id, turn_started_at = await self._resolve_target( + project_id=project_id, + session_id=session_id, + expected_turn_id=expected_execution_id, + ) + + if ( + expected_execution_id is not None + and target_turn_id != expected_execution_id + ): + # Compared against the TARGET, which is `running` with a fallback to `alive`, and + # never against `running` alone. An execution parked on an approval has released + # `running` and still holds `alive` under the same turn id, and it is exactly the + # execution the user is looking at when they press Stop on the approval card. The + # browser always sends the id it streamed, so comparing against `running` alone + # refused every named Stop on a parked approval while the same Stop without an + # expectation was accepted — the guard fired on the one case it exists to allow. + # + # Nothing is inserted and nothing is delivered. The caller was looking at a run + # that has already ended, and its next read tells it so. + raise ExecutionExpectationFailed( + expected=expected_execution_id, current=target_turn_id + ) + + if target_turn_id is None: + # No eligible execution is running. Record the intent so a retry with the same key + # gets the same answer, and settle it in the same write. + created = await self._insert( + project_id=project_id, + user_id=user_id, + session_id=session_id, + received_at=received_at, + target_turn_id=None, + expected_turn_id=expected_execution_id, + idempotency_key=idempotency_key, + state=SessionCommandState.obsolete, + outcome=SessionCommandOutcome.not_running, + ) + if not created.inserted: + return self._admission_for_existing(created.command) + command = created.command + return CancelAdmission(command=command, execution_id=None, accepted=False) + + if ( + expected_execution_id is None + and turn_started_at is not None + and turn_started_at > received_at + ): + # The execution now running began AFTER the user pressed Stop, so it is not the one + # they meant. Do not target it, do not touch Redis, and tell the caller there is + # nothing of theirs left to stop. + created = await self._insert( + project_id=project_id, + user_id=user_id, + session_id=session_id, + received_at=received_at, + target_turn_id=None, + expected_turn_id=None, + idempotency_key=idempotency_key, + state=SessionCommandState.obsolete, + outcome=SessionCommandOutcome.superseded_by_newer_turn, + ) + if not created.inserted: + return self._admission_for_existing(created.command) + command = created.command + return CancelAdmission(command=command, execution_id=None, accepted=False) + + # Two Stops in a row are one intent. Collapse onto the open command for the same target + # BEFORE inserting, so this holds even when the caller sends a different idempotency key. + open_command = await self._dao.fetch_open_command( + project_id=project_id, + session_id=session_id, + kind=SessionCommandKind.cancel, + target_turn_id=target_turn_id, + ) + if open_command is not None: + if open_command.state == SessionCommandState.pending: + # Nobody has taken it. The first delivery may have failed, so try again; the + # runner deduplicates by command id, so a duplicate arrival aborts nothing twice. + await self._deliver(open_command) + return CancelAdmission( + command=open_command, + execution_id=target_turn_id, + accepted=True, + ) + + created = await self._insert( + project_id=project_id, + user_id=user_id, + session_id=session_id, + received_at=received_at, + target_turn_id=target_turn_id, + expected_turn_id=expected_execution_id, + idempotency_key=idempotency_key, + state=SessionCommandState.pending, + outcome=None, + stopping_turn_id=target_turn_id, + ) + if not created.inserted: + return self._admission_for_existing(created.command) + command = created.command + # The row is committed. Everything from here is promptness, not correctness. + await self._deliver(command) + return CancelAdmission( + command=command, execution_id=target_turn_id, accepted=True + ) + + async def _resolve_target( + self, + *, + project_id: UUID, + session_id: str, + expected_turn_id: Optional[str], + ) -> Tuple[Optional[str], Optional[datetime]]: + """The execution to stop, and when it started. + + An unfenced Stop targets only `running`. A named Stop may fall back to `alive` so it can + still reach the parked approval the caller observed. + """ + turn_id = await get_running_owner( + self._lock, project_id=str(project_id), session_id=session_id + ) + if turn_id is None and expected_turn_id is not None: + turn_id = await get_alive_owner( + self._lock, project_id=str(project_id), session_id=session_id + ) + if turn_id is None: + return None, None + + stream = await self._streams.fetch_header( + project_id=project_id, session_id=session_id + ) + started_at = None + if stream is not None and stream.turn_id == turn_id: + # Only when the row agrees about WHICH turn is running. A start time read off a row + # that names a different turn would compare two unrelated things. + started_at = stream.turn_started_at + return turn_id, started_at + + async def _insert( + self, + *, + project_id: UUID, + user_id: Optional[UUID], + session_id: str, + received_at: datetime, + target_turn_id: Optional[str], + expected_turn_id: Optional[str], + idempotency_key: Optional[str], + state: SessionCommandState, + outcome: Optional[SessionCommandOutcome], + stopping_turn_id: Optional[str] = None, + ) -> CommandCreateResult: + return await self._dao.create_command_with_status( + user_id=user_id, + command=SessionCommandCreate( + project_id=project_id, + session_id=session_id, + kind=SessionCommandKind.cancel, + target_turn_id=target_turn_id, + expected_turn_id=expected_turn_id, + state=state, + outcome=outcome, + settled_at=received_at if outcome is not None else None, + idempotency_key=idempotency_key, + created_at=received_at, + ), + stopping_turn_id=stopping_turn_id, + ) + + @staticmethod + def _admission_for_existing(command: SessionCommand) -> CancelAdmission: + """Replay the command's original target without delivering it again.""" + return CancelAdmission( + command=command, + execution_id=command.target_turn_id, + accepted=command.target_turn_id is not None, + ) + + # -- delivery ----------------------------------------------------------- # + + async def _deliver(self, command: SessionCommand) -> None: + """Hand the command to the transport, then record what the transport learned. + + Never raises. The user's request has already succeeded by the time this runs. + """ + command = await self._dao.record_delivery_attempt( + project_id=command.project_id, + command_id=command.id, + now=datetime.now(timezone.utc), + max_deliveries=env.agenta.sessions.commands.max_deliveries, + ) + if command is None: + return + + try: + receipt = await self._delivery.deliver(command=command) + except Exception as e: # noqa: BLE001 — transport failure is never a request failure + log.warning( + "control delivery raised for command=%s session=%s: %s", + command.id, + command.session_id, + e, + ) + return + + if receipt.status == "accepted": + # Take the claim on the runner's behalf, so the outcome route's guard reads the same + # way on every transport: only the holder of the claim writes the outcome. + await self._dao.claim_for_delivery( + project_id=command.project_id, + command_id=command.id, + replica_id=receipt.replica_id or "direct", + lease_seconds=env.agenta.sessions.commands.lease_seconds, + ) + return + + if receipt.status == "not_held": + await self._settle_not_held(command) + return + + log.warning( + "control delivery unreachable for command=%s session=%s: %s", + command.id, + command.session_id, + receipt.detail or "no detail", + ) + + async def _settle_not_held(self, command: SessionCommand) -> None: + """A reachable runner said it does not hold this session. Two different things look + alike here, and the user must not be told the wrong one. + + `running` is the discriminator, not the heartbeat. A `not_held` while SOME execution + holds `running` means a process is executing this session and it is not the one we + called. Settle that `lost`, so the user learns the Stop failed, and log it at error + level. + + With no `running` execution anywhere, nothing is executing and the work the user meant + to stop is over. That is the everyday case: the turn ended a moment before the Stop + arrived, the runner had already dropped it, and the answer is `not_running`. Judging it + on the heartbeat instead called every one of those a failed Stop, because a turn that + has just ended leaves `alive` set and a fresh beat behind it, exactly as a running one + does. + """ + outcome = SessionCommandOutcome.not_running + running_owner = await get_running_owner( + self._lock, + project_id=str(command.project_id), + session_id=command.session_id, + ) + if running_owner is not None and await self._session_is_beating( + project_id=command.project_id, session_id=command.session_id + ): + outcome = SessionCommandOutcome.lost + # Name the process that DOES hold the session, so the log says where the Stop + # should have gone rather than only that it did not arrive. + owner = await get_owner( + self._lock, + project_id=str(command.project_id), + session_id=command.session_id, + ) + log.error( + "control delivery: the runner answered not_held for session=%s while " + "execution %s holds `running` and the row is beating. A process is executing " + "that session and it is not the one we called, so this deployment has more " + "than one runner replica and the direct adapter cannot route to it. Settling " + "the command lost, so the user is told the Stop failed rather than that the " + "work had already finished. command=%s target_turn=%s owner_replica=%s", + command.session_id, + running_owner, + command.id, + command.target_turn_id, + owner or "unknown", + ) + await self.settle( + command_id=command.id, + project_id=command.project_id, + replica_id=None, + expected_states=[SessionCommandState.pending], + state=SessionCommandState.obsolete, + outcome=outcome, + execution_id=command.target_turn_id, + ) + + async def _session_is_beating(self, *, project_id: UUID, session_id: str) -> bool: + """Is a runner process keeping this session's row fresh right now?""" + stream = await self._streams.fetch_header( + project_id=project_id, session_id=session_id + ) + if stream is None or stream.updated_at is None: + return False + if not (stream.flags and stream.flags.is_alive): + return False + updated_at = stream.updated_at + if updated_at.tzinfo is None: + updated_at = updated_at.replace(tzinfo=timezone.utc) + age = (datetime.now(timezone.utc) - updated_at).total_seconds() + return age < HEARTBEAT_INTERVAL_SECONDS * 2 + + async def settle_abandoned_commands(self, *, now: datetime) -> int: + max_deliveries = env.agenta.sessions.commands.max_deliveries + abandoned = await self._dao.expire_claims( + now=now, + max_deliveries=max_deliveries, + pending_before=now + - timedelta(seconds=env.agenta.sessions.commands.admission_timeout_seconds), + ) + settled = 0 + for command in abandoned: + beating = await self._session_is_beating( + project_id=command.project_id, + session_id=command.session_id, + ) + if beating and command.claim_count < max_deliveries: + await self._deliver(command) + continue + + result = await self.settle( + command_id=command.id, + project_id=command.project_id, + replica_id=None, + expected_states=[ + SessionCommandState.pending, + SessionCommandState.claimed, + ], + state=SessionCommandState.obsolete, + outcome=SessionCommandOutcome.lost, + execution_id=command.target_turn_id, + ) + if result is not None: + settled += 1 + return settled + + # -- settlement --------------------------------------------------------- # + + async def settle_execution_lost( + self, + *, + project_id: UUID, + session_id: str, + execution_id: str, + settled_at: datetime, + transaction: Optional[Any] = None, + ) -> bool: + if self._executions is None: + return True + result = await self._executions.settle( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + terminal_outcome=SessionCommandOutcome.lost.value, + settled_by="watchdog", + settled_at=settled_at, + transaction=transaction, + ) + winner = result.settlement + return result.won or ( + winner.terminal_outcome == SessionCommandOutcome.lost.value + and winner.settled_by == "watchdog" + ) + + async def repair_terminal_redis(self) -> int: + if self._executions is None: + return 0 + misses = await self._executions.list_redis_unreconciled(limit=200) + repaired = 0 + for execution in misses: + await self._reconcile_stopped_redis( + project_id=execution.project_id, + session_id=execution.session_id, + execution_id=execution.execution_id, + ) + repaired += 1 + return repaired + + async def _reconcile_stopped_redis( + self, + *, + project_id: UUID, + session_id: str, + execution_id: str, + ) -> None: + await reconcile_stopped_turn( + self._lock, + project_id=str(project_id), + session_id=session_id, + turn_id=execution_id, + ) + if self._executions is not None: + await self._executions.mark_redis_reconciled( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + ) + + async def report_outcome( + self, + *, + command_id: UUID, + replica_id: str, + result: str, + execution_id: Optional[str], + execution_state: str, + error: Optional[str] = None, + ) -> SessionCommand: + """The runner reporting what happened to the execution. Both adapters land here, so + settlement has one path on every transport.""" + command = await self._dao.fetch_command(command_id=command_id) + if command is None: + raise SessionCommandNotFound(command_id=str(command_id)) + + outcome = _OUTCOME_BY_EXECUTION_STATE.get(execution_state) + if outcome is None: + outcome = SessionCommandOutcome.failed + state = ( + SessionCommandState.applied + if result == "applied" + else SessionCommandState.obsolete + ) + if error: + log.warning( + "session command %s reported a failed cancel for execution=%s: %s", + command_id, + execution_id, + error[:2000], + ) + + settled = await self.settle( + command_id=command_id, + project_id=command.project_id, + replica_id=replica_id, + # Both, and checked at the moment of the write. Admission inserts `pending`, + # delivers, and only then writes `claimed` on the runner's behalf, so a runner that + # aborts fast reports its outcome while the row is still `pending`. Guarding on + # `claimed` alone refused that report with a conflict and left a correctly stopped + # execution sitting `claimed` until the sweep called it lost — the user watching + # "stopping" for the whole sweep window, and a Stop that worked recorded as lost. + expected_states=[ + SessionCommandState.pending, + SessionCommandState.claimed, + ], + state=state, + outcome=outcome, + execution_id=execution_id or command.target_turn_id, + ) + if settled is None: + stored = await self._dao.fetch_command(command_id=command_id) + raise SessionCommandNotClaimable( + command_id=str(command_id), + state=stored.state.value if stored else "unknown", + ) + return settled + + async def settle( + self, + *, + command_id: UUID, + project_id: UUID, + replica_id: Optional[str], + expected_states: List[SessionCommandState], + state: SessionCommandState, + outcome: SessionCommandOutcome, + execution_id: Optional[str], + ) -> Optional[SessionCommand]: + """Settle the command and the execution together, guarded on the command's state. + + The guard is what makes this idempotent: a second report finds a terminal row, changes + nothing, and the side effects below do not run twice. + """ + transition = SessionCommandSettle( + project_id=project_id, + command_id=command_id, + state=state, + outcome=outcome, + expected_states=expected_states, + replica_id=replica_id, + ) + atomic_core_settlement = self._executions is not None + cancelled_interactions = 0 + if atomic_core_settlement: + stored_command = await self._dao.fetch_command(command_id=command_id) + if stored_command is None: + return None + terminal = outcome in ( + SessionCommandOutcome.stopped, + SessionCommandOutcome.lost, + ) + settled_by = ( + "watchdog" + if outcome == SessionCommandOutcome.lost + else "runner" + if terminal + else None + ) + try: + async with self._dao.transaction() as transaction: + settled = await self._dao.settle_command( + settle=transition, + transaction=transaction, + ) + if settled is None: + raise _SettlementRejected + + if execution_id and terminal and settled_by: + result = await self._executions.settle( + project_id=project_id, + session_id=stored_command.session_id, + execution_id=execution_id, + terminal_outcome=outcome.value, + settled_by=settled_by, + transaction=transaction, + ) + winner = result.settlement + if not result.won and ( + winner.terminal_outcome != outcome.value + or winner.settled_by != settled_by + ): + raise _SettlementRejected + + await self._streams.settle_command( + project_id=project_id, + session_id=stored_command.session_id, + turn_id=execution_id, + mirror_stopped=outcome == SessionCommandOutcome.stopped, + transaction=transaction, + ) + if execution_id and outcome in ( + SessionCommandOutcome.stopped, + SessionCommandOutcome.not_running, + SessionCommandOutcome.lost, + ): + cancelled_interactions = ( + await self._interactions.cancel_session_pending( + project_id=project_id, + session_id=stored_command.session_id, + only_turn_id=execution_id, + transaction=transaction, + publish=False, + ) + ) + except _SettlementRejected: + return None + else: + settled = await self._dao.settle_command(settle=transition) + if settled is None: + return None + + session_id = settled.session_id + target = settled.target_turn_id + + if cancelled_interactions: + await self._interactions.publish_session_pending_cancelled( + project_id=project_id, + session_id=session_id, + ) + + if not atomic_core_settlement: + await self._dao.clear_stopping_turn( + project_id=project_id, + session_id=session_id, + turn_id=target, + ) + + if outcome == SessionCommandOutcome.stopped and target: + # Order matters. Tombstone first, so a late beat from the stopped execution cannot + # re-arm the locks it is about to lose; that beat would otherwise find `alive` free + # and take it straight back under the same turn id. + await self._reconcile_stopped_redis( + project_id=project_id, + session_id=session_id, + execution_id=target, + ) + # `alive` is deliberately left to its own time to live, exactly as the end of a + # normal turn leaves it. Warm resume is the required outcome of Stop, so the session + # must end up in the state a finished turn leaves it in, not in a torn-down one. + + # Mirror the nest onto the row HERE, because nothing else will. The tombstone + # above refuses the stopped execution's own final `is_running=false` beat before it + # can reach the heartbeat's mirror write, and the read model the product polls + # (`query_streams`) reads Postgres and never Redis. Skipping this leaves the row + # saying `is_running: true` until the orphan sweep collapses it, so the tab that + # pressed Stop shows a "running somewhere else" strip over its own session. + if not atomic_core_settlement: + await self._streams.mirror_liveness( + project_id=project_id, + session_id=session_id, + ) + + if outcome in ( + SessionCommandOutcome.stopped, + SessionCommandOutcome.not_running, + SessionCommandOutcome.lost, + ): + if target and not atomic_core_settlement: + # An approval card whose execution was stopped is a card whose buttons do + # nothing. Scoped to this execution, so a newer turn's gates survive. + await self._interactions.cancel_session_pending( + project_id=project_id, + session_id=session_id, + only_turn_id=target, + command_id=command_id, + ) + await self._streams.publish_session_ended( + project_id=project_id, + session_id=session_id, + ) + return settled + + +# The runner names what happened to the EXECUTION; the command's `outcome` column stores it. +_OUTCOME_BY_EXECUTION_STATE = { + "stopped": SessionCommandOutcome.stopped, + "not_running": SessionCommandOutcome.not_running, + "superseded_by_newer_turn": SessionCommandOutcome.superseded_by_newer_turn, + "failed": SessionCommandOutcome.failed, +} + +__all__ = [ + "CancelAdmission", + "SessionCommandsService", +] diff --git a/api/oss/src/core/sessions/commands/types.py b/api/oss/src/core/sessions/commands/types.py new file mode 100644 index 00000000000..47092a44c01 --- /dev/null +++ b/api/oss/src/core/sessions/commands/types.py @@ -0,0 +1,51 @@ +"""Domain errors of the session commands plane. The router maps each to a status code.""" + +from typing import Optional + + +class SessionCommandError(Exception): + """Base of every commands-plane domain error.""" + + +class ExecutionExpectationFailed(SessionCommandError): + """`expected_execution_id` does not name the execution that is running. + + Carries the current execution id (or None) so the caller can refresh rather than guess. + """ + + def __init__(self, *, expected: str, current: Optional[str]) -> None: + self.expected = expected + self.current = current + self.message = ( + f"expected execution '{expected}' is not the running execution " + f"(current: {current or 'none'})" + ) + super().__init__(self.message) + + +class SessionCommandIdempotencyConflict(SessionCommandError): + """An idempotency key was reused for a different cancel request.""" + + def __init__(self, *, idempotency_key: str) -> None: + self.idempotency_key = idempotency_key + self.message = ( + f"idempotency key '{idempotency_key}' belongs to a different request" + ) + super().__init__(self.message) + + +class SessionCommandNotFound(SessionCommandError): + def __init__(self, *, command_id: str) -> None: + self.command_id = command_id + self.message = f"no session command with id '{command_id}'" + super().__init__(self.message) + + +class SessionCommandNotClaimable(SessionCommandError): + """A settle arrived for a command this replica does not hold, or that is already terminal.""" + + def __init__(self, *, command_id: str, state: str) -> None: + self.command_id = command_id + self.state = state + self.message = f"session command '{command_id}' is '{state}' and cannot be settled by this caller" + super().__init__(self.message) diff --git a/api/oss/src/core/sessions/executions/__init__.py b/api/oss/src/core/sessions/executions/__init__.py new file mode 100644 index 00000000000..02e10675c1a --- /dev/null +++ b/api/oss/src/core/sessions/executions/__init__.py @@ -0,0 +1 @@ +"""Execution terminal-state contracts.""" diff --git a/api/oss/src/core/sessions/executions/dtos.py b/api/oss/src/core/sessions/executions/dtos.py new file mode 100644 index 00000000000..84c3887161e --- /dev/null +++ b/api/oss/src/core/sessions/executions/dtos.py @@ -0,0 +1,21 @@ +from datetime import datetime +from typing import Optional +from uuid import UUID + +from pydantic import BaseModel + + +class SessionExecutionSettlement(BaseModel): + project_id: UUID + session_id: str + execution_id: str + terminal_outcome: str + settled_by: str + settled_at: datetime + ending_written_at: Optional[datetime] = None + redis_reconciled_at: Optional[datetime] = None + + +class SessionExecutionSettlementResult(BaseModel): + settlement: SessionExecutionSettlement + won: bool diff --git a/api/oss/src/core/sessions/executions/interfaces.py b/api/oss/src/core/sessions/executions/interfaces.py new file mode 100644 index 00000000000..92e87e927c2 --- /dev/null +++ b/api/oss/src/core/sessions/executions/interfaces.py @@ -0,0 +1,62 @@ +from abc import ABC, abstractmethod +from datetime import datetime +from typing import Any, Dict, List, Optional, Sequence, Tuple +from uuid import UUID + +from oss.src.core.sessions.executions.dtos import ( + SessionExecutionSettlement, + SessionExecutionSettlementResult, +) + + +class SessionExecutionsDAOInterface(ABC): + @abstractmethod + async def settle( + self, + *, + project_id: UUID, + session_id: str, + execution_id: str, + terminal_outcome: str, + settled_by: str, + settled_at: Optional[datetime] = None, + transaction: Optional[Any] = None, + ) -> SessionExecutionSettlementResult: + """Compare-and-set one terminal outcome and return the stored winner.""" + + @abstractmethod + async def query_settled( + self, + *, + project_id: UUID, + keys: Sequence[Tuple[str, str]], + ) -> Dict[Tuple[str, str], SessionExecutionSettlement]: + """Fetch terminal state for `(session_id, execution_id)` keys.""" + + @abstractmethod + async def mark_endings_written( + self, + *, + project_id: UUID, + keys: Sequence[Tuple[str, str]], + written_at: Optional[datetime] = None, + ) -> None: + """Mark terminal executions whose transcript ending has been written.""" + + @abstractmethod + async def list_redis_unreconciled( + self, + *, + limit: int, + ) -> List[SessionExecutionSettlement]: + """Runner settlements whose post-commit Redis projection is incomplete.""" + + @abstractmethod + async def mark_redis_reconciled( + self, + *, + project_id: UUID, + session_id: str, + execution_id: str, + ) -> None: + """Record completion of the idempotent post-commit Redis projection.""" diff --git a/api/oss/src/core/sessions/interactions/interfaces.py b/api/oss/src/core/sessions/interactions/interfaces.py index 60336b51395..7a11646a6b4 100644 --- a/api/oss/src/core/sessions/interactions/interfaces.py +++ b/api/oss/src/core/sessions/interactions/interfaces.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import List, Optional +from typing import Any, List, Optional from uuid import UUID from oss.src.core.sessions.interactions.dtos import ( @@ -47,7 +47,8 @@ async def cancel_session_pending( except_turn_id: Optional[str] = None, except_tokens: Optional[List[str]] = None, only_turn_id: Optional[str] = None, - ) -> int: ... + transaction: Optional[Any] = None, + ) -> List[SessionInteraction]: ... @abstractmethod async def query_interactions( diff --git a/api/oss/src/core/sessions/interactions/service.py b/api/oss/src/core/sessions/interactions/service.py index 02d685404f8..14c5187174a 100644 --- a/api/oss/src/core/sessions/interactions/service.py +++ b/api/oss/src/core/sessions/interactions/service.py @@ -1,5 +1,5 @@ -from typing import List, Optional -from uuid import UUID +from typing import Any, List, Optional +from uuid import NAMESPACE_DNS, UUID, uuid5 from oss.src.core.sessions.interactions.dtos import ( SessionInteraction, @@ -11,12 +11,19 @@ SessionInteractionsDAOInterface, ) from oss.src.core.sessions.interactions.types import InteractionNotFound +from oss.src.core.sessions.records.dtos import SessionRecordEvent +from oss.src.core.sessions.records.service import RecordsService from oss.src.core.shared.dtos import Windowing from oss.src.dbs.redis.sessions.contract import ( WATCH_INTERACTION_PENDING, WATCH_INTERACTION_RESOLVED, ) from oss.src.core.sessions.watch.interfaces import SessionsWatchPublisherInterface +from oss.src.utils.logging import get_module_logger + + +_RECORD_NAMESPACE = uuid5(uuid5(NAMESPACE_DNS, "agenta"), "records") +log = get_module_logger(__name__) class SessionInteractionsService: @@ -25,9 +32,11 @@ def __init__( *, interactions_dao: SessionInteractionsDAOInterface, watch_publisher: Optional[SessionsWatchPublisherInterface] = None, + records_service: Optional[RecordsService] = None, ) -> None: self.interactions_dao = interactions_dao self._watch = watch_publisher + self._records = records_service async def _publish_interaction( self, *, project_id: UUID, session_id: str, status: str @@ -102,6 +111,9 @@ async def cancel_session_pending( except_turn_id: Optional[str] = None, except_tokens: Optional[List[str]] = None, only_turn_id: Optional[str] = None, + command_id: Optional[UUID] = None, + transaction: Optional[Any] = None, + publish: bool = True, ) -> int: cancelled = await self.interactions_dao.cancel_session_pending( project_id=project_id, @@ -109,14 +121,58 @@ async def cancel_session_pending( except_turn_id=except_turn_id, except_tokens=except_tokens, only_turn_id=only_turn_id, + transaction=transaction, ) - if cancelled: - await self._publish_interaction( - project_id=project_id, - session_id=session_id, - status=WATCH_INTERACTION_RESOLVED, + if cancelled and command_id is not None and self._records is not None: + try: + await self._records.append_many( + events=[ + SessionRecordEvent( + project_id=project_id, + session_id=interaction.session_id, + record_id=uuid5( + _RECORD_NAMESPACE, + f"{interaction.session_id}:{interaction.token}:" + f"interaction_response:{interaction.turn_id or ''}", + ), + record_type="interaction_response", + record_source="agent", + attributes={ + "type": "interaction_response", + "id": interaction.token, + "kind": interaction.kind.value, + "payload": { + "outcome": "cancelled", + "turnId": interaction.turn_id, + "commandId": str(command_id), + }, + }, + turn_id=interaction.turn_id, + ) + for interaction in cancelled + ] + ) + except Exception: + log.warning( + "Failed to append cancellation records for session=%s command=%s", + session_id, + command_id, + exc_info=True, + ) + if cancelled and publish: + await self.publish_session_pending_cancelled( + project_id=project_id, session_id=session_id ) - return cancelled + return len(cancelled) + + async def publish_session_pending_cancelled( + self, *, project_id: UUID, session_id: str + ) -> None: + await self._publish_interaction( + project_id=project_id, + session_id=session_id, + status=WATCH_INTERACTION_RESOLVED, + ) async def query_interactions( self, diff --git a/api/oss/src/core/sessions/records/dtos.py b/api/oss/src/core/sessions/records/dtos.py index 5785b392b36..d86bc391799 100644 --- a/api/oss/src/core/sessions/records/dtos.py +++ b/api/oss/src/core/sessions/records/dtos.py @@ -1,14 +1,31 @@ from datetime import datetime -from typing import Optional, Any, Dict +from typing import Annotated, Optional, Any, Dict, Literal, Union from uuid import UUID -from pydantic import BaseModel, Field +from orjson import dumps +from pydantic import BaseModel, Field, model_validator from oss.src.core.shared.dtos import Lifecycle, OTelSpanId # The DAO truncates at the SQL level (`left(attributes->>'text', ...)`) — this bound # just keeps the DTO honest about that contract for any other producer. SESSION_MESSAGE_PREVIEW_TEXT_LIMIT = 240 +MAX_LIVE_FRAME_BYTES = 64 * 1024 + +# The runner's terminal per-turn record type, mirrored from +# services/runner/src/protocol.ts (`{ type: "done" }`). Also spelled in the records DAO and +# the ingest worker, which read the same marker off their own layers. +TERMINAL_RECORD_TYPE = "done" + +# Who wrote a terminal record, stamped into `attributes` by the writer. +# +# Only the platform ever sets it: the ingest route builds `SessionRecordEvent` field by field +# from the request body and has no path to this key, so a runner cannot claim to be the +# watchdog. It exists because the two endings are otherwise identical — the watchdog copies +# the runner's `{"type": "done"}` deliberately, so one outcome never reaches a user in two +# wordings — and the late-record guard has to tell them apart. +RECORD_SETTLED_BY_ATTRIBUTE = "settled_by" +SETTLED_BY_WATCHDOG = "watchdog" class SessionRecordEvent(BaseModel): @@ -26,6 +43,173 @@ class SessionRecordEvent(BaseModel): turn_id: Optional[str] = None span_id: Optional[OTelSpanId] = None + # Set ONLY by the ingest guard in `RecordsService.append_many`, never by a producer: the + # ingest route builds this DTO field by field and never reads this one off the wire. A + # non-null value means the record arrived for a turn the watchdog had already ended, so it + # is kept as evidence and left out of the transcript. See `RecordsService.append_many`. + quarantined_at: Optional[datetime] = None + + +class SessionLiveFrame(BaseModel): + version: Literal[1] + kind: Literal["frame"] + session_id: str + execution_id: str + frame_or_event_id: str + frame_index: int = Field(ge=0) + entity_id: str + type: str + payload: Dict[str, Any] + created_at: datetime + + @model_validator(mode="after") + def validate_serialized_size(self) -> "SessionLiveFrame": + size = len(dumps(self.model_dump(mode="json"))) + if size > MAX_LIVE_FRAME_BYTES: + raise ValueError( + f"serialized live frame exceeds {MAX_LIVE_FRAME_BYTES} bytes" + ) + return self + + +class SessionExecutionError(BaseModel): + code: str + message: str + retryable: bool + details: Optional[Dict[str, Any]] = None + + +class ExecutionStartedPayload(BaseModel): + started_at: datetime + + +class ExecutionStoppedPayload(BaseModel): + stopped_at: datetime + reason: str + command_id: Optional[str] = None + + +class ExecutionFailedPayload(BaseModel): + failed_at: datetime + error: SessionExecutionError + + +class ExecutionLostPayload(BaseModel): + lost_at: datetime + reason: str + history_complete: Literal[False] + + +class MessageCompletedPayload(BaseModel): + message_id: str + role: str + content: Any + finish_reason: Optional[str] = None + + +class ToolCompletedPayload(BaseModel): + tool_call_id: str + name: str + input: Any + output: Any = None + error: Any = None + status: str + + +class InteractionChangedPayload(BaseModel): + interaction_id: str + kind: Optional[str] = None + + +class SessionDurableEventBase(BaseModel): + """Durable relay wire envelope. + + ``watermark`` is a non-negative integer. On a live event it is the highest sequence + committed for that session in the publishing records-worker batch; on the SSE ``ready`` + frame the same field name is the authoritative session sequence cursor after replay. A + client that receives a ready frame without it keeps the requested ``after`` cursor. + """ + + version: Literal[1] = 1 + kind: Literal["event"] = "event" + session_id: str + execution_id: str + frame_or_event_id: str + entity_id: str + sequence: Optional[int] = Field(default=None, ge=1) + watermark: int = Field(ge=0) + created_at: datetime + + +class ExecutionStartedEvent(SessionDurableEventBase): + type: Literal["execution.started"] + payload: ExecutionStartedPayload + + +class ExecutionStoppedEvent(SessionDurableEventBase): + type: Literal["execution.stopped"] + payload: ExecutionStoppedPayload + + +class ExecutionFailedEvent(SessionDurableEventBase): + type: Literal["execution.failed"] + payload: ExecutionFailedPayload + + +class ExecutionLostEvent(SessionDurableEventBase): + type: Literal["execution.lost"] + payload: ExecutionLostPayload + + +class MessageCompletedEvent(SessionDurableEventBase): + type: Literal["message.completed"] + payload: MessageCompletedPayload + + +class ToolCompletedEvent(SessionDurableEventBase): + type: Literal["tool.completed"] + payload: ToolCompletedPayload + + +class InteractionRequestedEvent(SessionDurableEventBase): + type: Literal["interaction.requested"] + payload: InteractionChangedPayload + + +class InteractionRespondedEvent(SessionDurableEventBase): + type: Literal["interaction.responded"] + payload: InteractionChangedPayload + + +SessionDurableEvent = Annotated[ + Union[ + ExecutionStartedEvent, + ExecutionStoppedEvent, + ExecutionFailedEvent, + ExecutionLostEvent, + MessageCompletedEvent, + ToolCompletedEvent, + InteractionRequestedEvent, + InteractionRespondedEvent, + ], + Field(discriminator="type"), +] + + +class SessionDurableEventsReplay(BaseModel): + events: list[SessionDurableEvent] + watermark: int = Field(ge=0) + + +SESSION_DURABLE_EVENT_TYPES = { + "execution.started", + "execution.stopped", + "execution.failed", + "execution.lost", + "message.completed", + "tool.completed", +} + class SessionRecord(Lifecycle): record_id: UUID @@ -33,6 +217,8 @@ class SessionRecord(Lifecycle): session_id: str project_id: UUID + sequence: Optional[int] = None + record_index: Optional[int] = None timestamp: Optional[datetime] = None record_type: Optional[str] = None @@ -42,6 +228,11 @@ class SessionRecord(Lifecycle): turn_id: Optional[str] = None span_id: Optional[OTelSpanId] = None + # Non-null when this record was written for an already-settled turn. Reads that rebuild a + # transcript filter these out at the DAO; the column is exposed so support and billing can + # still see the work the agent did after the platform closed the turn. + quarantined_at: Optional[datetime] = None + class SessionMessagePreview(BaseModel): """The last thing said in a session, for a list row. @@ -60,3 +251,21 @@ class SessionMessagePreview(BaseModel): class SessionRecordQuery(BaseModel): session_id: str + + +class SessionRecordsReadState(BaseModel): + latest_sequence: int = Field(ge=0) + history_complete: bool + + +class SessionRecordsPage(BaseModel): + records: list[SessionRecord] + offset: int = Field(ge=0) + limit: int = Field(ge=1) + next_offset: Optional[int] = Field(default=None, ge=0) + through_sequence: int = Field(ge=0) + + +class SessionRecordsReplay(BaseModel): + records: list[SessionRecord] + watermark: int = Field(ge=0) diff --git a/api/oss/src/core/sessions/records/events.py b/api/oss/src/core/sessions/records/events.py new file mode 100644 index 00000000000..1a105e5d5bb --- /dev/null +++ b/api/oss/src/core/sessions/records/events.py @@ -0,0 +1,197 @@ +from typing import Any, Dict, List, Optional + +from pydantic import TypeAdapter, ValidationError + +from oss.src.core.sessions.records.dtos import ( + SESSION_DURABLE_EVENT_TYPES, + InteractionRequestedEvent, + InteractionRespondedEvent, + MessageCompletedEvent, + SessionDurableEvent, + SessionRecord, + ToolCompletedEvent, +) + + +_EVENT_ADAPTER = TypeAdapter(SessionDurableEvent) + + +def _event_base( + record: SessionRecord, + *, + entity_id: str, + include_legacy: bool, + watermark: int, +) -> Optional[Dict[str, Any]]: + created_at = record.timestamp or record.created_at + execution_id = record.turn_id or (record.attributes or {}).get("execution_id") + if ( + (record.sequence is None and not include_legacy) + or created_at is None + or not execution_id + ): + return None + return { + "version": 1, + "kind": "event", + "session_id": record.session_id, + "execution_id": str(execution_id), + "frame_or_event_id": str(record.record_id), + "entity_id": entity_id, + "sequence": record.sequence, + "watermark": watermark, + "created_at": created_at, + } + + +def _direct_event( + record: SessionRecord, *, include_legacy: bool, watermark: int +) -> Optional[SessionDurableEvent]: + if record.record_type not in SESSION_DURABLE_EVENT_TYPES: + return None + attributes = dict(record.attributes or {}) + payload = attributes.pop("payload", None) + # `attributes` is an open dict filled from the ingest wire, so `payload` can be any JSON + # value. A non-dict one must read as absent: `.get` on it raises outside the try below, + # and that failure poisons the whole batch on every redelivery. + if not isinstance(payload, dict): + attributes.pop("type", None) + attributes.pop("execution_id", None) + payload = attributes + entity_id = ( + payload.get("message_id") + or payload.get("tool_call_id") + or record.turn_id + or attributes.get("execution_id") + ) + base = _event_base( + record, + entity_id=str(entity_id or record.record_id), + include_legacy=include_legacy, + watermark=watermark, + ) + if base is None: + return None + try: + return _EVENT_ADAPTER.validate_python( + {**base, "type": record.record_type, "payload": payload} + ) + except ValidationError: + return None + + +def durable_events_from_records( + records: List[SessionRecord], + *, + include_legacy: bool = False, + watermark: Optional[int] = None, +) -> List[SessionDurableEvent]: + events: List[SessionDurableEvent] = [] + tool_calls: Dict[tuple[str, str], Dict[str, Any]] = {} + resolved_watermark = ( + watermark + if watermark is not None + else max((record.sequence or 0 for record in records), default=0) + ) + + for record in records: + # Some DAO decorators and legacy test doubles return commit sentinels rather than hydrated + # rows. They still count as committed appends, but cannot describe a durable relay event. + if not isinstance(record, SessionRecord): + continue + attributes = record.attributes or {} + direct = _direct_event( + record, + include_legacy=include_legacy, + watermark=resolved_watermark, + ) + if direct is not None: + events.append(direct) + continue + + entity_id = str( + attributes.get("message_id") + or attributes.get("tool_call_id") + or attributes.get("id") + or record.record_id + ) + base = _event_base( + record, + entity_id=entity_id, + include_legacy=include_legacy, + watermark=resolved_watermark, + ) + if base is None: + continue + + if record.record_type in {"interaction_request", "interaction_response"}: + payload = { + "interaction_id": entity_id, + "kind": attributes.get("kind"), + } + if record.record_type == "interaction_request": + events.append( + InteractionRequestedEvent( + **base, + type="interaction.requested", + payload=payload, + ) + ) + else: + events.append( + InteractionRespondedEvent( + **base, + type="interaction.responded", + payload=payload, + ) + ) + continue + + if record.record_type == "message": + role = ( + "assistant" if record.record_source == "agent" else record.record_source + ) + events.append( + MessageCompletedEvent( + **base, + type="message.completed", + payload={ + "message_id": entity_id, + "role": role or "assistant", + "content": attributes.get( + "content", attributes.get("text", "") + ), + "finish_reason": attributes.get("finish_reason"), + }, + ) + ) + continue + + tool_key = (str(record.turn_id), entity_id) + if record.record_type == "tool_call": + tool_calls[tool_key] = attributes + continue + if record.record_type != "tool_result": + continue + + call = tool_calls.get(tool_key, {}) + is_error = bool(attributes.get("isError")) + output = attributes.get("data", attributes.get("output")) + events.append( + ToolCompletedEvent( + **base, + type="tool.completed", + payload={ + "tool_call_id": entity_id, + "name": str( + call.get("name") or attributes.get("name") or "unknown" + ), + "input": call.get("input", attributes.get("input")), + "output": None if is_error else output, + "error": output if is_error else None, + "status": "error" if is_error else "completed", + }, + ) + ) + + return events diff --git a/api/oss/src/core/sessions/records/interfaces.py b/api/oss/src/core/sessions/records/interfaces.py index d0dba2ec9f3..a6e516a4005 100644 --- a/api/oss/src/core/sessions/records/interfaces.py +++ b/api/oss/src/core/sessions/records/interfaces.py @@ -1,10 +1,13 @@ -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Sequence, Set, Tuple from uuid import UUID from oss.src.core.sessions.records.dtos import ( SessionMessagePreview, SessionRecord, SessionRecordEvent, + SessionRecordsPage, + SessionRecordsReplay, + SessionRecordsReadState, ) @@ -32,6 +35,34 @@ async def get_records( ) -> List[SessionRecord]: raise NotImplementedError + async def get_records_page( + self, + *, + project_id: UUID, + session_id: str, + offset: int, + limit: int, + through_sequence: int, + ) -> SessionRecordsPage: + raise NotImplementedError + + async def get_read_state( + self, + *, + project_id: UUID, + session_id: str, + ) -> SessionRecordsReadState: + raise NotImplementedError + + async def get_records_after( + self, + *, + project_id: UUID, + session_id: str, + after: int, + ) -> SessionRecordsReplay: + raise NotImplementedError + async def get_event( self, *, @@ -47,3 +78,17 @@ async def latest_message_per_session( session_ids: List[str], ) -> Dict[str, SessionMessagePreview]: raise NotImplementedError + + async def settled_turns( + self, + *, + project_id: UUID, + keys: Sequence[Tuple[str, str]], + settled_by: Optional[str] = None, + ) -> Set[Tuple[str, str]]: + """Which of these `(session_id, turn_id)` pairs already carry a terminal record. + + `settled_by` narrows the answer to endings that one writer wrote; see the DAO. + """ + + raise NotImplementedError diff --git a/api/oss/src/core/sessions/records/service.py b/api/oss/src/core/sessions/records/service.py index 79d8a7e2393..cdbac8957d9 100644 --- a/api/oss/src/core/sessions/records/service.py +++ b/api/oss/src/core/sessions/records/service.py @@ -1,17 +1,48 @@ -from typing import Any, Dict, List, Optional +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Sequence, Set, Tuple from uuid import UUID from oss.src.core.sessions.records.dtos import ( + RECORD_SETTLED_BY_ATTRIBUTE, + SETTLED_BY_WATCHDOG, + TERMINAL_RECORD_TYPE, + SessionDurableEventsReplay, SessionMessagePreview, SessionRecord, SessionRecordEvent, + SessionRecordsPage, + SessionRecordsReadState, ) +from oss.src.core.sessions.executions.dtos import SessionExecutionSettlement +from oss.src.core.sessions.executions.interfaces import SessionExecutionsDAOInterface +from oss.src.core.sessions.records.events import durable_events_from_records from oss.src.core.sessions.records.interfaces import RecordsDAOInterface +from oss.src.utils.env import env +from oss.src.utils.logging import get_module_logger + +log = get_module_logger(__name__) + + +def _written_by_watchdog(event: SessionRecordEvent) -> bool: + """Did the platform write this record, rather than a runner? + + Only the watchdog stamps the marker, and only the platform can: the ingest route builds + `SessionRecordEvent` field by field out of the request body and never reads this key off + the wire, so a runner cannot present itself as the watchdog to get past the guard below. + """ + return (event.attributes or {}).get( + RECORD_SETTLED_BY_ATTRIBUTE + ) == SETTLED_BY_WATCHDOG class RecordsService: - def __init__(self, records_dao: RecordsDAOInterface): + def __init__( + self, + records_dao: RecordsDAOInterface, + executions_dao: Optional[SessionExecutionsDAOInterface] = None, + ): self.records_dao = records_dao + self.executions_dao = executions_dao async def append( self, @@ -26,7 +57,230 @@ async def append_many( *, events: List[SessionRecordEvent], ) -> List[SessionRecord]: - return await self.records_dao.append_many(events=events) + """Append a batch, quarantining anything that arrives after the platform ended its turn. + + RFC "Required behavior / Execution" item 3: after an execution reaches its terminal + outcome, later non-terminal output for it is rejected or quarantined. This is where + that happens, because ingest is the only place both writers meet. + + The case is real and was caught live. A runner wedges, the watchdog writes the turn's + `error` and `done` on its behalf, and the runner then THAWS and submits everything it + had buffered — a tool call, its result, a `usage`, and a second `done`. Nothing + downstream could refuse it: the runner-side gate in `server.ts` knows only about + endings that request wrote itself, and the reader was left with a failure notice + followed by the work the agent went on to do. + + Quarantine rather than reject, deliberately. The tail is real work: a late `usage` + carries token accounting that is real money, and the tool result is the first thing a + support engineer asks for. A dropped record cannot be looked at later; a marked one + can, and it is already invisible to every read that rebuilds a transcript. + """ + if not events: + return [] + + guarded = await self._handle_late_events(events=events) + appended = await self.records_dao.append_many(events=guarded) + await self._mark_endings_written(events=guarded) + return appended + + async def _mark_endings_written( + self, + *, + events: List[SessionRecordEvent], + ) -> None: + if self.executions_dao is None or not env.agenta.sessions.durable_stop: + return + + endings: Dict[UUID, Set[Tuple[str, str]]] = {} + for event in events: + if ( + event.record_type != TERMINAL_RECORD_TYPE + or not event.turn_id + or event.quarantined_at is not None + ): + continue + endings.setdefault(event.project_id, set()).add( + (event.session_id, event.turn_id) + ) + + for project_id, keys in endings.items(): + try: + await self.executions_dao.mark_endings_written( + project_id=project_id, + keys=sorted(keys), + ) + except Exception: + log.warning( + "[RECORDS] Execution ending marker update failed; record remains appended", + project_id=str(project_id), + exc_info=True, + ) + + async def _handle_late_events( + self, + *, + events: List[SessionRecordEvent], + ) -> List[SessionRecordEvent]: + """Stamp `quarantined_at` on every event belonging to a settled turn. + + Scoped as narrowly as the invariant allows, in three ways. + + * Only turns the WATCHDOG ended. A turn that reached its own honest ending — an + ordinary Stop, a normal completion — is untouched, so the runner's single ending + always lands and a `usage` that trails its own `done` through the stream is still + ordinary history. + * Only records the watchdog did not write. Its own `error` is not a terminal record, + so a redelivery of it after its `done` had landed would otherwise quarantine the + very ending it belongs to. + * Terminal records included. A late `done` is quarantined like the rest of the tail, + which is what keeps ONE effective ending: folding it into the watchdog's would + rewrite the record the user has already read, and hide that two writers disagreed. + + A batch that carries the watchdog's own `done` settles that turn for the rest of the + same batch. Ingest batches up to fifty messages, and the thawed runner's tail can + share one with the ending that beat it by a second. + + A failed lookup quarantines nothing and appends everything. Losing a record is worse + than showing one that should have been hidden, and the next delivery gets another go. + """ + if not env.agenta.sessions.durable_stop: + return events + + if self.executions_dao is not None: + return await self._handle_by_execution_state(events=events) + + candidates: Dict[UUID, Set[Tuple[str, str]]] = {} + for event in events: + if not event.turn_id or _written_by_watchdog(event): + continue + candidates.setdefault(event.project_id, set()).add( + (event.session_id, event.turn_id) + ) + + if not candidates: + return events + + settled: Dict[UUID, Set[Tuple[str, str]]] = {} + for project_id, keys in candidates.items(): + try: + settled[project_id] = await self.records_dao.settled_turns( + project_id=project_id, + keys=sorted(keys), + settled_by=SETTLED_BY_WATCHDOG, + ) + except Exception: + log.warning( + "[RECORDS] Late-record lookup failed; appending the batch unguarded", + project_id=str(project_id), + exc_info=True, + ) + settled[project_id] = set() + + for event in events: + if ( + _written_by_watchdog(event) + and event.record_type == TERMINAL_RECORD_TYPE + and event.turn_id + ): + settled.setdefault(event.project_id, set()).add( + (event.session_id, event.turn_id) + ) + + now = datetime.now(timezone.utc) + guarded: List[SessionRecordEvent] = [] + for event in events: + is_late = ( + event.turn_id is not None + and not _written_by_watchdog(event) + and (event.session_id, event.turn_id) + in settled.get(event.project_id, set()) + ) + if not is_late: + guarded.append(event) + continue + + action = env.agenta.sessions.late_output + log.warning( + "[RECORDS] %s a record for a turn the watchdog had already ended", + "Rejected" if action == "reject" else "Quarantined", + project_id=str(event.project_id), + session_id=event.session_id, + turn_id=event.turn_id, + record_type=event.record_type, + record_id=str(event.record_id) if event.record_id else None, + ) + if action == "reject": + continue + guarded.append(event.model_copy(update={"quarantined_at": now})) + + return guarded + + async def _handle_by_execution_state( + self, + *, + events: List[SessionRecordEvent], + ) -> List[SessionRecordEvent]: + candidates: Dict[UUID, Set[Tuple[str, str]]] = {} + for event in events: + if event.turn_id: + candidates.setdefault(event.project_id, set()).add( + (event.session_id, event.turn_id) + ) + + if not candidates: + return events + + settled: Dict[UUID, Dict[Tuple[str, str], SessionExecutionSettlement]] = {} + for project_id, keys in candidates.items(): + try: + settled[project_id] = await self.executions_dao.query_settled( + project_id=project_id, + keys=sorted(keys), + ) + except Exception: + log.warning( + "[RECORDS] Terminal execution lookup failed; appending the batch unguarded", + project_id=str(project_id), + exc_info=True, + ) + settled[project_id] = {} + + now = datetime.now(timezone.utc) + guarded: List[SessionRecordEvent] = [] + for event in events: + if not event.turn_id: + guarded.append(event) + continue + terminal = settled.get(event.project_id, {}).get( + (event.session_id, event.turn_id) + ) + if terminal is None: + guarded.append(event) + continue + + writer = "watchdog" if _written_by_watchdog(event) else "runner" + is_late = terminal.settled_by != writer and terminal.terminal_outcome in ( + "lost", + "stopped", + ) + if not is_late: + guarded.append(event) + continue + + action = env.agenta.sessions.late_output + log.warning( + "[RECORDS] %s a record for an execution that is already terminal", + "Rejected" if action == "reject" else "Quarantined", + project_id=str(event.project_id), + session_id=event.session_id, + turn_id=event.turn_id, + record_type=event.record_type, + record_id=str(event.record_id) if event.record_id else None, + ) + if action == "quarantine": + guarded.append(event.model_copy(update={"quarantined_at": now})) + + return guarded async def get_records( self, @@ -50,6 +304,55 @@ async def get_event( record_id=record_id, ) + async def get_records_page( + self, + *, + project_id: UUID, + session_id: str, + offset: int, + limit: int, + through_sequence: int, + ) -> SessionRecordsPage: + return await self.records_dao.get_records_page( + project_id=project_id, + session_id=session_id, + offset=offset, + limit=limit, + through_sequence=through_sequence, + ) + + async def get_read_state( + self, + *, + project_id: UUID, + session_id: str, + ) -> SessionRecordsReadState: + return await self.records_dao.get_read_state( + project_id=project_id, + session_id=session_id, + ) + + async def get_events_after( + self, + *, + project_id: UUID, + session_id: str, + after: int, + ): + replay = await self.records_dao.get_records_after( + project_id=project_id, + session_id=session_id, + after=after, + ) + return SessionDurableEventsReplay( + events=durable_events_from_records( + replay.records, + include_legacy=after == 0, + watermark=replay.watermark, + ), + watermark=replay.watermark, + ) + async def latest_message_per_session( self, *, @@ -64,3 +367,20 @@ async def latest_message_per_session( project_id=project_id, session_ids=session_ids, ) + + async def settled_turns( + self, + *, + project_id: UUID, + keys: Sequence[Tuple[str, str]], + settled_by: Optional[str] = None, + ) -> Set[Tuple[str, str]]: + """One batched lookup for a whole watchdog pass — never one call per candidate.""" + if not keys: + return set() + + return await self.records_dao.settled_turns( + project_id=project_id, + keys=keys, + settled_by=settled_by, + ) diff --git a/api/oss/src/core/sessions/records/streaming.py b/api/oss/src/core/sessions/records/streaming.py index 722d31c1296..bbb42c68918 100644 --- a/api/oss/src/core/sessions/records/streaming.py +++ b/api/oss/src/core/sessions/records/streaming.py @@ -1,5 +1,6 @@ import zlib -from typing import Optional +from datetime import datetime, timezone +from typing import Literal, Optional, Union from uuid import UUID from orjson import dumps, loads @@ -10,7 +11,12 @@ except ImportError: AsyncpgUUID = None -from oss.src.core.sessions.records.dtos import SessionRecordEvent +from oss.src.core.sessions.records.dtos import ( + MAX_LIVE_FRAME_BYTES, + SessionDurableEvent, + SessionLiveFrame, + SessionRecordEvent, +) from oss.src.dbs.redis.shared.engine import get_streams_engine from oss.src.utils.env import env from oss.src.utils.logging import get_module_logger @@ -24,6 +30,10 @@ _TRUNCATION_MARKER = "…[truncated]" +RECORD_STREAM_NAME = "streams:records" +LIVE_FRAME_STREAM_NAME = "streams:session-live-frames" +_FRAME_TRIM_INTERVAL = 64 + def _orjson_default(obj): if AsyncpgUUID is not None and isinstance(obj, AsyncpgUUID): @@ -82,6 +92,24 @@ def _get_redis(): return engine.get_redis() if engine else None +async def trim_live_stream(redis) -> None: + """Trim expired disposable frames independently of the durable record queue.""" + age_boundary = int( + ( + datetime.now(timezone.utc).timestamp() + - env.sessions.live_frame_max_age_seconds + ) + * 1000 + ) + # Approximate: the frames are disposable, so a few extra entries per listpack cost nothing + # and exact trimming makes every call O(N) in the evicted entries. + await redis.xtrim( + LIVE_FRAME_STREAM_NAME, + minid=f"{age_boundary}-0", + approximate=True, + ) + + class RecordMessage(BaseModel): """Wire format for the dedicated record Redis stream.""" @@ -91,10 +119,122 @@ class RecordMessage(BaseModel): record_event: SessionRecordEvent +class LiveFrameMessage(BaseModel): + organization_id: Optional[UUID] = None + project_id: UUID + kind: Literal["frame"] = "frame" + frame: SessionLiveFrame + + +class DurableEventMessage(BaseModel): + organization_id: Optional[UUID] = None + project_id: UUID + kind: Literal["event"] = "event" + event: SessionDurableEvent + + +LiveRelayMessage = Union[LiveFrameMessage, DurableEventMessage] + + +def deserialize_live_relay_message(*, payload: bytes) -> LiveRelayMessage: + raw = loads(zlib.decompress(payload)) + if raw.get("kind") == "frame": + return LiveFrameMessage.model_validate(raw) + if raw.get("kind") == "event": + return DurableEventMessage.model_validate(raw) + raise ValueError("message is not a live relay envelope") + + def deserialize_record(*, payload: bytes) -> RecordMessage: - payload = zlib.decompress(payload) - raw = loads(payload) - return RecordMessage.model_validate(raw) + return RecordMessage.model_validate(loads(zlib.decompress(payload))) + + +async def _append_live_relay_message(redis, *, message: dict) -> None: + await redis.xadd( + name=LIVE_FRAME_STREAM_NAME, + fields={"data": zlib.compress(dumps(message, default=_orjson_default))}, + maxlen=env.sessions.live_stream_maxlen, + # Approximate for the same reason as `trim_live_stream`: this runs on every relay write. + approximate=True, + ) + + +async def publish_live_frame( + *, + organization_id: Optional[UUID] = None, + project_id: UUID, + frame: SessionLiveFrame, +) -> bool: + redis = _get_redis() + if redis is None: + log.warning("[RECORDS] Durable Redis not configured; frame not published") + return False + + try: + frame_bytes = dumps(frame.model_dump(mode="json"), default=_orjson_default) + if len(frame_bytes) > MAX_LIVE_FRAME_BYTES: + log.warning( + "[RECORDS] Live frame exceeds size limit", + session_id=frame.session_id, + execution_id=frame.execution_id, + frame_index=frame.frame_index, + ) + return False + message = { + "organization_id": str(organization_id) if organization_id else None, + "project_id": str(project_id), + "kind": "frame", + "frame": frame.model_dump(mode="json"), + } + await _append_live_relay_message(redis, message=message) + if frame.frame_index % _FRAME_TRIM_INTERVAL == 0: + try: + await trim_live_stream(redis) + except Exception: + log.warning("[RECORDS] Live stream trim failed", exc_info=True) + return True + except Exception: + log.error( + "[RECORDS] Failed to publish frame", + session_id=frame.session_id, + execution_id=frame.execution_id, + frame_index=frame.frame_index, + exc_info=True, + ) + return False + + +async def publish_durable_event( + *, + organization_id: Optional[UUID] = None, + project_id: UUID, + event: SessionDurableEvent, +) -> bool: + redis = _get_redis() + if redis is None: + log.warning( + "[RECORDS] Durable Redis not configured; durable event not published" + ) + return False + + try: + message = { + "organization_id": str(organization_id) if organization_id else None, + "project_id": str(project_id), + "kind": "event", + "event": event.model_dump(mode="json"), + } + await _append_live_relay_message(redis, message=message) + return True + except Exception: + log.error( + "[RECORDS] Failed to publish durable event", + session_id=event.session_id, + execution_id=event.execution_id, + sequence=event.sequence, + exc_info=True, + ) + return False async def publish_record( @@ -146,7 +286,7 @@ async def publish_record( event_bytes = zlib.compress(event_bytes) await redis.xadd( - name="streams:records", + name=RECORD_STREAM_NAME, fields={"data": event_bytes}, maxlen=MAXLEN_STREAMS_RECORDS, approximate=True, diff --git a/api/oss/src/core/sessions/streams/dtos.py b/api/oss/src/core/sessions/streams/dtos.py index c20aa5575d3..1953728b317 100644 --- a/api/oss/src/core/sessions/streams/dtos.py +++ b/api/oss/src/core/sessions/streams/dtos.py @@ -34,13 +34,26 @@ class SessionStreamQueryFlags(BaseModel): is_attached: Optional[bool] = None +class SessionCapabilities(BaseModel): + shared_reader: bool = Field( + default=False, + description="Deployment-wide shared-reader switch; version one has no project allowlist.", + ) + + class SessionStream(Identifier, Header, Lifecycle): project_id: UUID session_id: str flags: SessionStreamFlags = SessionStreamFlags() + capabilities: SessionCapabilities = SessionCapabilities() tags: Optional[Dict[str, Any]] = None meta: Optional[Dict[str, Any]] = None turn_id: Optional[str] = None + # When `turn_id` started. Stamped only when the id changes, so repeated heartbeats never + # move it. The stale-Stop guard compares a cancel request's arrival time against this. + turn_started_at: Optional[datetime] = None + # The execution an accepted Stop is waiting on. Null when nothing is stopping. + stopping_turn_id: Optional[str] = None # What this session runs. Filled once, from the first beat that knows — turn appends # are fire-and-forget, so a session whose only reference carrier was a dropped append # is unopenable forever. @@ -75,6 +88,10 @@ class SessionStreamEdit(Header): tags: Optional[Dict[str, Any]] = None meta: Optional[Dict[str, Any]] = None turn_id: Optional[str] = None + # Internal heartbeat fence. When present, the DAO updates only this still-current, + # non-terminal execution generation. Excluded from serialization because it is a write + # precondition, not stream state. + expected_turn_id: Optional[str] = Field(default=None, exclude=True) class SessionStreamHeaderEdit(Header): @@ -143,6 +160,45 @@ class SessionStreamCommandRequest(BaseModel): data: Optional[WorkflowServiceRequestData] = None force: bool = False detached: bool = False # fire-and-forget mode + # A stale-request guard for cancel mode only; send, steer, and attach ignore it. + expected_execution_id: Optional[str] = Field( + default=None, + description=( + "Optional stale-request guard honored only in cancel mode; ignored for send, " + "steer, and attach." + ), + ) + + @field_validator("expected_execution_id") + @classmethod + def _blank_expected_execution_id_means_absent( + cls, value: Optional[str] + ) -> Optional[str]: + if value is None: + return None + return value.strip() or None + + # Cancel guard (RFC D-010). Public name; internally this IS a turn id — the coordination + # plane's word for one execution of a session. The RFC calls it an execution id, so the + # public DTO keeps that name and the service maps it onto `turn_id` at the boundary. + # Optional by decision: external callers may cancel blind. When present, cancel touches + # that turn or nothing. + expected_execution_id: Optional[str] = None + + @field_validator("expected_execution_id") + @classmethod + def _blank_expected_execution_id_means_absent( + cls, value: Optional[str] + ) -> Optional[str]: + """A whitespace-only guard is a client bug, not a request to cancel a turn named "". + + Reading it as "no guard" is the safe failure: the caller falls back to the arrival-time + check instead of matching a turn id nothing can hold. + """ + if value is None: + return None + trimmed = value.strip() + return trimmed or None class SessionStreamCommandResponse(BaseModel): @@ -151,6 +207,9 @@ class SessionStreamCommandResponse(BaseModel): turn_id: Optional[str] = None watcher_id: Optional[str] = None detached: bool = False + # Cancel only: every turn this cancel tombstoned. Usually one. It is a list because + # `alive` and `running` can be held by different turns during a handover, and both die. + cancelled_turn_ids: List[str] = Field(default_factory=list) class SessionHeartbeatRequest(BaseModel): @@ -169,6 +228,14 @@ class SessionHeartbeatRequest(BaseModel): is_running: bool = True name: Optional[str] = None references: Optional[List[SessionReference]] = None + # The INVERSE beat, sent once per session as a runner shuts down: hand the affinity key + # back instead of renewing it. `claim_owner` never steals, so a replica that dies still + # holding `owner:session:` locks the session out of every other replica for the rest + # of OWNER_TTL_SECONDS — a local-provider session then refuses every message until the + # lease expires. The release is conditional on still being the owner, so it can never + # take a session from a live replica. Everything else about the beat is skipped: a + # departing runner asserts no liveness and no turn. + release_owner: bool = False class SessionLiveness(BaseModel): diff --git a/api/oss/src/core/sessions/streams/interfaces.py b/api/oss/src/core/sessions/streams/interfaces.py index 7c2e740f202..412b62aac38 100644 --- a/api/oss/src/core/sessions/streams/interfaces.py +++ b/api/oss/src/core/sessions/streams/interfaces.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import List, Optional +from typing import Any, List, Optional from uuid import UUID from oss.src.core.sessions.streams.dtos import ( @@ -16,6 +16,18 @@ class SessionStreamsDAOInterface(ABC): + @abstractmethod + async def settle_command( + self, + *, + project_id: UUID, + session_id: str, + turn_id: Optional[str], + mirror_stopped: bool, + transaction: Optional[Any] = None, + ) -> None: + """Clear this command's marker and project its stopped state.""" + @abstractmethod async def create( self, diff --git a/api/oss/src/core/sessions/streams/runner_client.py b/api/oss/src/core/sessions/streams/runner_client.py index 45e6689aca8..39c3f8dd1f1 100644 --- a/api/oss/src/core/sessions/streams/runner_client.py +++ b/api/oss/src/core/sessions/streams/runner_client.py @@ -17,6 +17,8 @@ the runner's own orphan sweep / idle-TTL eviction is the fallback net for a missed signal. """ +from typing import NamedTuple, Optional + import httpx from oss.src.utils.env import env @@ -60,3 +62,109 @@ async def kill_runner_sandbox(*, project_id: str, session_id: str) -> bool: except httpx.HTTPError as e: log.warning("kill: runner /kill call failed for session=%s: %s", session_id, e) return False + + +_CANCEL_TIMEOUT_SECONDS = 5.0 + + +class RunnerCancelResult: + """What the direct hop learned, as three named cases. + + * `accepted` — the runner holds the session and took the command. The outcome arrives + later on the outcome route, never in this response. + * `not_held` — the runner answered, and it does not hold that session. + * `unreachable` — no answer, a non-2xx that is not 404, or no runner configured at all. + """ + + accepted = "accepted" + not_held = "not_held" + unreachable = "unreachable" + + +class RunnerCancelResponse(NamedTuple): + """The acknowledgement, and WHICH runner process gave it. + + `replica_id` is what the API records as the claim holder, so the outcome route's guard + (`state='claimed' AND claimed_by=:replica_id`) matches the id the runner reports with. Take + it from the answer rather than assuming one: a claim written under a name the runner does + not use refuses the runner's own outcome report, which leaves the command open and the + session marked stopping forever. + """ + + status: str + replica_id: Optional[str] = None + + +async def cancel_runner_execution( + *, + command_id: str, + project_id: str, + session_id: str, + target_turn_id: Optional[str], + created_at: str, + timeout_seconds: float = _CANCEL_TIMEOUT_SECONDS, +) -> RunnerCancelResponse: + """POST the runner's `/cancel`. Returns the acknowledgement and the answering replica. + + Never raises. The command row is already committed when this runs, so a failure here costs + promptness, not the Stop: a later claim or the settlement sweep still reaches it. + + The body is camelCase because the runner's own HTTP surface is (see its `/kill`). + """ + base_url = env.runner.internal_url + token = env.runner.token + if not base_url or not token: + log.warning( + "cancel: no runner internal_url/token configured; command %s cannot be delivered", + command_id, + ) + return RunnerCancelResponse(RunnerCancelResult.unreachable) + + url = base_url.rstrip("/") + "/cancel" + try: + async with httpx.AsyncClient(timeout=timeout_seconds) as client: + response = await client.post( + url, + json={ + "commandId": command_id, + "projectId": project_id, + "sessionId": session_id, + "targetTurnId": target_turn_id, + "createdAt": created_at, + }, + headers={"Authorization": f"Bearer {token}"}, + ) + except httpx.HTTPError as e: + log.warning( + "cancel: runner /cancel call failed for session=%s command=%s: %s", + session_id, + command_id, + e, + ) + return RunnerCancelResponse(RunnerCancelResult.unreachable) + + if response.status_code == 404: + return RunnerCancelResponse(RunnerCancelResult.not_held) + if response.status_code >= 300: + log.warning( + "cancel: runner /cancel returned %s for session=%s command=%s", + response.status_code, + session_id, + command_id, + ) + return RunnerCancelResponse(RunnerCancelResult.unreachable) + + replica_id = None + try: + payload = response.json() + if isinstance(payload, dict): + replica_id = payload.get("replicaId") + except ValueError: + # A 2xx with no JSON body still means accepted; the claim then falls back to a + # placeholder and the runner's report is refused, so log it rather than hide it. + log.warning( + "cancel: runner /cancel answered %s with no JSON body for command=%s", + response.status_code, + command_id, + ) + return RunnerCancelResponse(RunnerCancelResult.accepted, replica_id) diff --git a/api/oss/src/core/sessions/streams/service.py b/api/oss/src/core/sessions/streams/service.py index bc67feb58c0..9d0df4d577e 100644 --- a/api/oss/src/core/sessions/streams/service.py +++ b/api/oss/src/core/sessions/streams/service.py @@ -23,16 +23,18 @@ CONCURRENCY_LIMIT, WATCH_LIFECYCLE_ENDED, WATCH_LIFECYCLE_RUNNING, + owner_replica_id, validate_session_id as _validate_session_id_fn, ) from oss.src.core.sessions.watch.interfaces import SessionsWatchPublisherInterface from oss.src.dbs.redis.sessions.locks import ( - acquire_alive, + acquire_alive_with_start, acquire_running, claim_owner, - clear_running, + claim_owner_value, + clear_owner, + displace_turns, release_running, - force_cancel_alive, force_clear_owner, get_alive_owner, get_owner, @@ -40,10 +42,13 @@ get_session_liveness, is_turn_superseded, mark_turn_superseded, + record_turn_start, + redis_time_ms, refresh_alive, refresh_running, release_alive, release_attached, + release_owner_value, steal_attached, ) @@ -66,6 +71,7 @@ SessionIdInvalid, SessionStreamAlreadyExists, SessionTurnInUse, + SessionTurnMismatch, ) from oss.src.core.sessions.streams.interfaces import SessionStreamsDAOInterface from oss.src.core.sessions.streams.runner_client import kill_runner_sandbox @@ -136,6 +142,24 @@ def derive_session_name(inputs: Optional[Dict[str, Any]]) -> Optional[str]: return normalize_session_name(_first_user_message_text(messages)) +def derive_command_mode(request: SessionStreamCommandRequest) -> CommandMode: + """The inputs x force matrix, as one function. + + Module-level because the route needs the mode BEFORE the service runs: a cancel must not be + refused by the per-project concurrency limit, and that check happens at the route. Keeping the + derivation in one place is what stops the two from disagreeing about what a cancel is. + """ + has_inputs = bool(request.data and request.data.inputs) + + if has_inputs and not request.force: + return CommandMode.send + if has_inputs and request.force: + return CommandMode.steer + if not has_inputs and not request.force: + return CommandMode.cancel + return CommandMode.attach + + class SessionStreamsService: def __init__( self, @@ -166,38 +190,34 @@ async def _supersede_turns( turn_id=turn_id, ) - async def _displace_turns(self, *, project_id: UUID, session_id: str) -> None: - """Tear alive+running off whichever turn holds them, tombstoning it first. - - The order is the point. Clearing first leaves a window in which the turn being - displaced heartbeats, finds `alive` free and nx-acquires it straight back - a - cancelled session then reads as alive for a whole ALIVE_TTL. Tombstoning first makes - that beat refuse itself. The keys are still re-read after the clear, so a turn that - took them inside the window is tombstoned too. - """ - await self._supersede_turns( - project_id=project_id, - session_id=session_id, - turn_ids=( - await get_alive_owner( - self._lock, project_id=str(project_id), session_id=session_id - ), - await get_running_owner( - self._lock, project_id=str(project_id), session_id=session_id - ), - ), - ) - displaced_alive = await force_cancel_alive( - self._lock, project_id=str(project_id), session_id=session_id - ) - displaced_running = await clear_running( - self._lock, project_id=str(project_id), session_id=session_id - ) - await self._supersede_turns( - project_id=project_id, + async def _displace_turns( + self, + *, + project_id: UUID, + session_id: str, + expected_turn_id: Optional[str] = None, + arrived_at_ms: Optional[int] = None, + running_only: bool = False, + ) -> List[str]: + """Atomically guard, tombstone, and clear the alive/running owners.""" + accepted, actual_turn_id, displaced = await displace_turns( + self._lock, + project_id=str(project_id), session_id=session_id, - turn_ids=(displaced_alive, displaced_running), + expected_turn_id=expected_turn_id, + arrived_at_ms=arrived_at_ms, + running_only=running_only, ) + if not accepted: + raise SessionTurnMismatch( + session_id, + actual_turn_id=actual_turn_id, + expected_turn_id=expected_turn_id, + ) + return displaced + + async def clock_ms(self) -> int: + return await redis_time_ms(self._lock) async def _publish_lifecycle( self, *, project_id: UUID, session_id: str, state: str @@ -210,6 +230,19 @@ async def _publish_lifecycle( state=state, ) + async def publish_session_ended(self, *, project_id: UUID, session_id: str) -> None: + """Announce that a turn ended, on the channel every open browser already listens to. + + Public because the durable-command plane settles a Stop and has to publish the same + notification the ordinary end-of-turn path publishes. There is one `ended` event, not a + Stop-shaped one and a turn-shaped one; a client cannot be asked to tell them apart. + """ + await self._publish_lifecycle( + project_id=project_id, + session_id=session_id, + state=WATCH_LIFECYCLE_ENDED, + ) + async def _publish_changed(self, *, project_id: UUID, session_id: str) -> None: if self._watch is None: return @@ -219,6 +252,7 @@ async def _publish_changed(self, *, project_id: UUID, session_id: str) -> None: entity="session", id=session_id, ) + except Exception: log.warning( "[WATCH] session change publish failed", @@ -226,25 +260,41 @@ async def _publish_changed(self, *, project_id: UUID, session_id: str) -> None: session_id=session_id, ) + async def settle_command( + self, + *, + project_id: UUID, + session_id: str, + turn_id: Optional[str], + mirror_stopped: bool, + transaction: Optional[Any] = None, + ) -> None: + await self._dao.settle_command( + project_id=project_id, + session_id=session_id, + turn_id=turn_id, + mirror_stopped=mirror_stopped, + transaction=transaction, + ) + async def command( self, *, project_id: UUID, user_id: UUID, request: SessionStreamCommandRequest, + arrived_at_ms: Optional[int] = None, ) -> SessionStreamCommandResponse: _validate_session_id(request.session_id) - has_inputs = bool(request.data and request.data.inputs) + # When the request reached the process, for the stale-cancel guard. The router stamps it + # before its permission and concurrency checks, which are database round trips; stamping + # here instead would leave the guard almost no window. Defaulted so a caller that does not + # stamp still gets a check, just a narrower one. + if arrived_at_ms is None: + arrived_at_ms = await self.clock_ms() - if has_inputs and not request.force: - mode = CommandMode.send - elif has_inputs and request.force: - mode = CommandMode.steer - elif not has_inputs and not request.force: - mode = CommandMode.cancel - else: - mode = CommandMode.attach + mode = derive_command_mode(request) session_id = request.session_id proposed_name = derive_session_name( @@ -286,21 +336,33 @@ async def command( ) elif mode == CommandMode.cancel: - await self._displace_turns(project_id=project_id, session_id=session_id) - await self._mark_stream_ended( + cancelled_turn_ids = await self._displace_turns( project_id=project_id, - user_id=user_id, session_id=session_id, + expected_turn_id=request.expected_execution_id, + arrived_at_ms=arrived_at_ms, + running_only=request.expected_execution_id is None, ) - await self._publish_lifecycle( - project_id=project_id, - session_id=session_id, - state=WATCH_LIFECYCLE_ENDED, - ) + if cancelled_turn_ids: + await self._mark_stream_ended( + project_id=project_id, + user_id=user_id, + session_id=session_id, + ) + await self._publish_lifecycle( + project_id=project_id, + session_id=session_id, + state=WATCH_LIFECYCLE_ENDED, + ) return SessionStreamCommandResponse( mode=mode, session_id=session_id, + # The turn this cancel actually ended. The caller (the router) needs it to + # cancel that turn's pending gates, and it is the id a client should echo back + # as `expected_execution_id` on a retry. + turn_id=cancelled_turn_ids[0] if cancelled_turn_ids else None, detached=True, + cancelled_turn_ids=cancelled_turn_ids, ) else: # ATTACH @@ -403,6 +465,87 @@ async def kill( session_id=session_id, ) + async def _reclaim_affinity_from_a_departed_replica( + self, + *, + project_id: UUID, + request: SessionHeartbeatRequest, + incumbent_value: str, + ) -> str: + """Take `owner:session:` from a replica that holds no running turn on it. + + `owner` exists to say which box is SERVING the session, and only an in-flight turn's + heartbeat ever refreshes it. So a claim held by a replica with no running turn is not + protecting anything: it is the residue of a runner that stopped beating. A runner that + dies without a graceful shutdown (SIGKILL, OOM, a crashed node, `docker restart -t 0`) + always leaves exactly that, because nothing releases the key on its way out and + `claim_owner` never steals. The replacement replica then loses every beat for the rest + of OWNER_TTL_SECONDS, and the runner reads that refusal as "another turn owns this + session" and refuses the user's next message for two minutes. + + `running` is the discriminator, the same one the alive-lock handover below uses. A live + turn holds it under its own id for the whole turn and re-arms it every beat, so a + replica that is genuinely serving the session can never be mistaken for a departed one. + A `running` lock held by the CALLER's own turn is not an obstacle: `_start_turn` arms + alive and running before the runner's first beat, so an API-minted turn legitimately + arrives here with its own lock already in place. + + Only the beat of a real, running turn may reclaim. A turn-end beat asserts nothing + about who should serve the session next, and a beat with no turn id proves no work. + + KNOWN LIMIT. A turn parked awaiting an approval also holds `alive` with no `running`, + so on a MULTI-replica deployment a second replica can take affinity from a live first + one and the handover below then tombstones the parked turn, killing the pending + approval. That outcome is not new: nothing refreshes `owner` on a parked session, so the + key expires after OWNER_TTL_SECONDS and the same handover follows. This only makes it up + to that TTL sooner, and only on a topology the direct control adapter cannot route to + anyway (`core/sessions/commands/service.py`). On a single replica the caller already + equals the owner and this method is never entered. + + Returns the owner after the attempt: the caller when the reclaim landed, otherwise + whoever holds the key, which is what the refusal above must report. + """ + incumbent = owner_replica_id(incumbent_value) + if not (request.turn_id and request.is_running): + return incumbent + + running_owner = await get_running_owner( + self._lock, + project_id=str(project_id), + session_id=request.session_id, + ) + if running_owner is not None and running_owner != request.turn_id: + return incumbent + + # Release-if-owner, then the ordinary non-stealing claim. Two atomic steps rather than + # one so no new script is needed, and the gap is safe in both directions: a concurrent + # claim by a third replica makes the release a no-op and the claim below returns that + # replica, so this path can never hand the session to the wrong caller. + await release_owner_value( + self._lock, + project_id=str(project_id), + session_id=request.session_id, + owner_value=incumbent_value, + ) + owner = await claim_owner( + self._lock, + project_id=str(project_id), + session_id=request.session_id, + replica_id=request.replica_id, + turn_id=request.turn_id, + ) + if owner == request.replica_id: + log.info( + "sessions: reclaimed session affinity from a replica with no running turn", + extra={ + "session_id": request.session_id, + "departed_replica_id": incumbent, + "replica_id": request.replica_id, + "turn_id": request.turn_id, + }, + ) + return owner + async def heartbeat( self, *, @@ -419,6 +562,49 @@ async def heartbeat( """ _validate_session_id(request.session_id) + # The shutdown beat: hand the affinity key back and touch nothing else. It runs FIRST, + # before the superseded check and before any lock is read or written, because a + # departing runner asserts nothing about turns — it only stops holding the session. + # `clear_owner` is release-if-owner, so a beat from a replica that no longer owns the + # session is a no-op and can never take affinity from a live one. Without this the + # next replica is refused for the rest of OWNER_TTL_SECONDS (`claim_owner` never + # steals), which on the local sandbox provider is a two-minute outage after every + # runner restart. + if request.release_owner: + released = await clear_owner( + self._lock, + project_id=str(project_id), + session_id=request.session_id, + replica_id=request.replica_id, + ) + stream = await self._dao.get_by_session_id( + project_id=project_id, + session_id=request.session_id, + ) + owner = await get_owner( + self._lock, + project_id=str(project_id), + session_id=request.session_id, + ) + log.info( + "sessions: released session ownership", + extra={ + "session_id": request.session_id, + "replica_id": request.replica_id, + "released": released, + "owner_after": owner, + }, + ) + # `replica_id` means "who owns this session now". After a successful release + # nobody does, and the caller is the one entitled to hear that, so report the + # caller's own id rather than inventing an owner. `is_current_turn` is False + # because this beat refreshed no turn. + return SessionHeartbeatResult( + stream=stream, + replica_id=owner or request.replica_id, + is_current_turn=False, + ) + # A turn that was already displaced (handover, cancel, steer, kill, sweep) is dead # forever: refuse the beat before it touches ANY lock or the row. This is what keeps # the ambiguous "`alive` held by another turn + no `running`" state safe to resolve as @@ -455,12 +641,23 @@ async def heartbeat( # replica_id claims affinity without stealing from a live different owner; turn_id # separately refreshes the alive/running TTLs. `owner` is the actual winner (this # replica if it won or already held it, another replica otherwise). - owner = await claim_owner( + owner_value = await claim_owner_value( self._lock, project_id=str(project_id), session_id=request.session_id, replica_id=request.replica_id, + turn_id=request.turn_id, ) + owner = owner_replica_id(owner_value) + # A different replica holds affinity. That claim is worth honouring only while it + # protects a turn, so before refusing, check whether it still protects one. + if owner != request.replica_id: + owner = await self._reclaim_affinity_from_a_departed_replica( + project_id=project_id, + request=request, + incumbent_value=owner_value, + ) + # A replica that lost the claim owns nothing here: mutating the nest would let it # overwrite the winner's turn locks and stream row. Report the true owner and stop. if owner != request.replica_id: @@ -510,7 +707,7 @@ async def heartbeat( session_id=request.session_id, turn_id=request.turn_id, ): - acquired = await acquire_alive( + acquired = await acquire_alive_with_start( self._lock, project_id=str(project_id), session_id=request.session_id, @@ -558,7 +755,7 @@ async def heartbeat( session_id=request.session_id, turn_id=displaced, ) - acquired = await acquire_alive( + acquired = await acquire_alive_with_start( self._lock, project_id=str(project_id), session_id=request.session_id, @@ -566,6 +763,13 @@ async def heartbeat( ) if not acquired or turn_was_established: is_current_turn = False + if is_current_turn: + await record_turn_start( + self._lock, + project_id=str(project_id), + session_id=request.session_id, + turn_id=request.turn_id, + ) if not await refresh_running( self._lock, project_id=str(project_id), @@ -640,6 +844,7 @@ async def heartbeat( proposed_name = normalize_session_name(request.name) proposed_references = request.references or None + created = False if prior_stream is None: try: stream = await self._dao.create( @@ -653,6 +858,7 @@ async def heartbeat( references=proposed_references, ), ) + created = True except SessionStreamAlreadyExists: # `_start_turn` won the first-touch race; fall through and update its row. stream = None @@ -686,8 +892,21 @@ async def heartbeat( project_id=project_id, user_id=None, session_id=request.session_id, - stream=SessionStreamEdit(flags=flags, turn_id=durable_turn_id), + stream=SessionStreamEdit( + flags=flags, + turn_id=durable_turn_id, + expected_turn_id=request.turn_id if turn_was_established else None, + ), ) + if stream is None and turn_was_established: + # The guarded row write lost to settlement or to a new generation. Redis may + # already have been refreshed, but this beat no longer owns durable state and + # must tell the runner to stop. + is_current_turn = False + stream = await self._dao.get_by_session_id( + project_id=project_id, + session_id=request.session_id, + ) # `running` lifecycle for the path that actually runs turns. `_start_turn` publishes it # for send/steer, but the runner mints its own turn id and only ever heartbeats, so @@ -707,6 +926,15 @@ async def heartbeat( state=WATCH_LIFECYCLE_RUNNING, ) + # A create is the session entering the project's lists, and `lifecycle` above rides the + # SESSION channel, which no list subscribes to. Without this a runner-first session + # reached no open list until that tab reloaded. + if created: + await self._publish_changed( + project_id=project_id, + session_id=request.session_id, + ) + return SessionHeartbeatResult( stream=stream, replica_id=owner, @@ -874,10 +1102,13 @@ async def hard_delete( """Hard delete the merged stream row (S7 delete fan-out, WP5). Distinct from `kill`, which only soft-deletes via `delete_by_session_id`.""" _validate_session_id(session_id) - return await self._dao.hard_delete_by_session_id( + deleted = await self._dao.hard_delete_by_session_id( project_id=project_id, session_id=session_id, ) + if deleted: + await self._publish_changed(project_id=project_id, session_id=session_id) + return deleted async def archive( self, @@ -889,11 +1120,14 @@ async def archive( """Soft-archive the stream row: set `archived_at` (hidden but restorable), distinct from kill's `deleted_at` so a killed session stays listed. Returns the archived confirmation.""" _validate_session_id(session_id) - return await self._dao.set_archived_by_session_id( + archived = await self._dao.set_archived_by_session_id( project_id=project_id, user_id=user_id, session_id=session_id, ) + if archived is not None: + await self._publish_changed(project_id=project_id, session_id=session_id) + return archived async def unarchive( self, @@ -904,11 +1138,14 @@ async def unarchive( ) -> Optional[SessionStream]: """Reverse of `archive`: clears `archived_at`, restoring the session to the list.""" _validate_session_id(session_id) - return await self._dao.clear_archived_by_session_id( + restored = await self._dao.clear_archived_by_session_id( project_id=project_id, user_id=user_id, session_id=session_id, ) + if restored is not None: + await self._publish_changed(project_id=project_id, session_id=session_id) + return restored async def check_runner_concurrency_limit(self, *, project_id: UUID) -> None: """Raise ConcurrencyLimitExceeded if the per-project limit is reached.""" @@ -925,7 +1162,7 @@ async def _start_turn( name: Optional[str] = None, ) -> str: turn_id = str(uuid.uuid7()) - acquired = await acquire_alive( + acquired = await acquire_alive_with_start( self._lock, project_id=str(project_id), session_id=session_id, @@ -950,6 +1187,10 @@ async def _start_turn( session_id=session_id, ) created = False + # Set by every branch that puts the row back in front of a list: a fresh create, a killed + # tombstone re-nested, an archived row un-hidden. Each is once-per-session, unlike the + # per-turn `lifecycle` below. + listed = False if stream is None: try: await self._dao.create( @@ -963,6 +1204,7 @@ async def _start_turn( ), ) created = True + listed = True except SessionStreamAlreadyExists: # The unique slot is held by either a concurrent first touch (live row) or a # soft-deleted tombstone (STOP_KILLS_SESSION / archive). The update reconciles @@ -983,6 +1225,7 @@ async def _start_turn( user_id=user_id, session_id=session_id, ) + listed = True updated = await self._dao.update( project_id=project_id, user_id=user_id, @@ -997,6 +1240,7 @@ async def _start_turn( user_id=user_id, session_id=session_id, ) + listed = True # Same fill-once proposal the runner's beat makes, so a browser session is # titled even when the client's auto-title effect never runs. if name and updated is not None and updated.name is None: @@ -1010,8 +1254,37 @@ async def _start_turn( session_id=session_id, state=WATCH_LIFECYCLE_RUNNING, ) + if listed: + await self._publish_changed(project_id=project_id, session_id=session_id) return turn_id + async def mirror_liveness( + self, + *, + project_id: UUID, + session_id: str, + user_id: Optional[UUID] = None, + ) -> None: + """Write the Redis nest onto the row, for a caller that changed the nest itself. + + Durable Stop settlement is that caller, and it is the one nest change no heartbeat can + mirror. Settlement tombstones the stopped execution BEFORE it releases `running`, so the + runner's own final `is_running=false` beat is refused by the tombstone check in + `heartbeat` above and returns before the mirror write at the end of that method. The + order cannot be swapped: a late beat that found `alive` free would take it straight back + under the dead turn's id. Without this method the row therefore keeps `is_running: true` + until the orphan sweep collapses it minutes later, and `query_streams` reads Postgres + alone, so the tab that pressed Stop sees its own session running somewhere else. + + Re-reads Redis rather than writing a literal `false`, so a newer turn that has already + taken `running` is reported, not erased. + """ + await self._mirror_flags( + project_id=project_id, + user_id=user_id, + session_id=session_id, + ) + async def _mirror_flags( self, *, diff --git a/api/oss/src/core/sessions/streams/types.py b/api/oss/src/core/sessions/streams/types.py index d55490c499a..feea49415f8 100644 --- a/api/oss/src/core/sessions/streams/types.py +++ b/api/oss/src/core/sessions/streams/types.py @@ -1,5 +1,7 @@ """Domain exceptions for session streams.""" +from typing import Optional + class SessionStreamError(Exception): """Base exception for session stream errors.""" @@ -36,6 +38,40 @@ def __init__(self, session_id: str, liveness: dict): super().__init__(self.message) +class SessionTurnMismatch(SessionStreamError): + """Raised when a cancel would displace a turn the caller did not mean to cancel. + + Two ways to get here, one meaning: the Stop is stale. Either the caller named a turn + (`expected_execution_id`) and a different one now holds the session, or the caller named + none and the holding turn started after the cancel arrived. Both are the stop-then-send + race: the turn the user meant has already ended and the next one has taken the session. + """ + + def __init__( + self, + session_id: str, + *, + actual_turn_id: Optional[str] = None, + expected_turn_id: Optional[str] = None, + ) -> None: + self.session_id = session_id + self.actual_turn_id = actual_turn_id + self.expected_turn_id = expected_turn_id + if expected_turn_id: + self.message = ( + f"Session '{session_id}' is running turn '{actual_turn_id}'," + f" not the expected turn '{expected_turn_id}'." + " Nothing was cancelled." + ) + else: + self.message = ( + f"Session '{session_id}' started turn '{actual_turn_id}' after this" + " cancel arrived, so the cancel is stale. Nothing was cancelled." + " Send `expected_execution_id` to cancel a specific turn." + ) + super().__init__(self.message) + + class ConcurrencyLimitExceeded(SessionStreamError): """Raised when the per-project concurrent-run limit is exceeded.""" diff --git a/api/oss/src/core/workflows/change_set.py b/api/oss/src/core/workflows/change_set.py index 12b894152c9..f0a01c5532a 100644 --- a/api/oss/src/core/workflows/change_set.py +++ b/api/oss/src/core/workflows/change_set.py @@ -576,6 +576,7 @@ def _plain_prefix(prefix: Sequence[str], segments: Sequence[Segment]) -> bool: ["parameters", "agent", "runner", "permissions"], ["parameters", "agent", "sandbox", "kind"], ["parameters", "agent", "sandbox", "permissions"], + ["parameters", "agent", "sandbox", "credentials"], ), ) diff --git a/api/oss/src/core/workflows/static_catalog.py b/api/oss/src/core/workflows/static_catalog.py index bdd10c423a6..4d8cf51473c 100644 --- a/api/oss/src/core/workflows/static_catalog.py +++ b/api/oss/src/core/workflows/static_catalog.py @@ -26,6 +26,8 @@ from agenta.sdk.agents.platform.workflow import ( REQUEST_CONNECTION_TOOL_NAME, REQUEST_CONNECTION_WORKFLOW_SLUG, + REQUEST_SECRET_TOOL_NAME, + REQUEST_SECRET_WORKFLOW_SLUG, ) from agenta.sdk.agents.skills.models import SkillTemplate from agenta.sdk.engines.running.utils import ( @@ -152,6 +154,43 @@ def _client_tool_revision() -> WorkflowRevision: ) +def _request_secret_revision() -> WorkflowRevision: + return WorkflowRevision( + name="Request secret", + description="Ask the user to configure a custom secret for this agent.", + data=WorkflowRevisionData( + uri="client:tool:request_secret:v0", + parameters={ + "tool": { + "type": "client", + "name": REQUEST_SECRET_TOOL_NAME, + "description": "Pause the run and ask the user to configure a custom secret.", + "input_schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Readable credential name.", + }, + "env_var": { + "type": "string", + "description": "Suggested environment variable name.", + }, + "reason": { + "type": "string", + "description": "Why the credential is required.", + }, + }, + "required": ["name", "env_var", "reason"], + "additionalProperties": False, + }, + "render": {"kind": "secret"}, + } + }, + ), + ) + + REQUEST_INPUT_TOOL_NAME = "request_input" @@ -255,6 +294,12 @@ def _build_kit_revision() -> WorkflowRevision: "v1": _client_tool_revision(), }, }, + REQUEST_SECRET_WORKFLOW_SLUG: { + "kind": "tool", + "embeddable": True, + "latest": "v1", + "versions": {"v1": _request_secret_revision()}, + }, REQUEST_INPUT_WORKFLOW_SLUG: { "kind": "tool", "embeddable": True, diff --git a/api/oss/src/dbs/http/__init__.py b/api/oss/src/dbs/http/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/api/oss/src/dbs/http/sessions/__init__.py b/api/oss/src/dbs/http/sessions/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/api/oss/src/dbs/http/sessions/control_delivery_direct.py b/api/oss/src/dbs/http/sessions/control_delivery_direct.py new file mode 100644 index 00000000000..dd470dcfb4d --- /dev/null +++ b/api/oss/src/dbs/http/sessions/control_delivery_direct.py @@ -0,0 +1,81 @@ +"""The direct-call control-delivery adapter. + +The API posts the command to the runner's own `/cancel`, over the same authenticated hop that +already carries hard kill. There is no held connection, no poll loop and no per-session Redis +channel: one runner process, one request. + +WHAT THIS ADAPTER IS NOT ALLOWED TO DO. Durability, authorization, idempotency, the state +machine and terminal settlement all live in `SessionCommandsService`. This file is transport. +Replacing it with a long-poll adapter must change no route, no data shape and no transition. + +THE ORDER IS NOT NEGOTIABLE. The command row is committed BEFORE `deliver` is called. Calling +first and recording afterwards would give back every failure the record exists to close: a crash +between the call and the insert leaves an aborted execution with no terminal outcome written +anywhere. + +WHERE IT FAILS, AND HOW THAT IS MADE LOUD. `env.runner.internal_url` is one service address. +Behind a load balancer with two runner replicas the call reaches the right process only by luck. +That failure is quiet at the transport level, because the wrong process honestly answers "I do +not hold that session" — the same answer a session that really ended gives. + +The detector is exact, and it is NOT in this file. A `not_held` for a session whose row says +alive with a heartbeat younger than one interval means some process is running that session and +it is not the one we just called; nothing else produces that. It needs the session row, so it +lives in `SessionCommandsService._settle_not_held`, next to the settlement it decides: the +command settles `lost` rather than `not_running`, so the user is told the Stop failed instead of +being told the work had already finished. + +There is deliberately no replica census here. An earlier version counted the replica ids that +had heartbeated recently and refused to deliver when it saw more than one. It refused after +every ordinary runner restart, because a runner mints a fresh id at boot when +`AGENTA_RUNNER_REPLICA_ID` is unset, so its own previous id was still inside the window. That +broke Stop for the whole window after every deploy, which is worse than the failure it guarded. +""" + +from typing import Optional +from uuid import UUID + +from oss.src.core.sessions.commands.dtos import SessionCommand +from oss.src.core.sessions.commands.interfaces import ( + ControlDeliveryPort, + DeliveryReceipt, +) +from oss.src.core.sessions.streams.runner_client import ( + RunnerCancelResult, + cancel_runner_execution, +) +from oss.src.utils.env import env +from oss.src.utils.logging import get_module_logger + +log = get_module_logger(__name__) + + +class DirectControlDelivery(ControlDeliveryPort): + def __init__(self, *, timeout_seconds: Optional[float] = None) -> None: + self._timeout = ( + timeout_seconds + if timeout_seconds is not None + else env.agenta.sessions.commands.delivery_timeout_seconds + ) + + async def deliver(self, *, command: SessionCommand) -> DeliveryReceipt: + answer = await cancel_runner_execution( + command_id=str(command.id), + project_id=str(command.project_id), + session_id=command.session_id, + target_turn_id=command.target_turn_id, + created_at=command.created_at.isoformat() if command.created_at else "", + timeout_seconds=self._timeout, + ) + if answer.status == RunnerCancelResult.accepted: + # The answering replica's own id, so the claim the service writes matches the id + # the runner reports its outcome with. + return DeliveryReceipt(status="accepted", replica_id=answer.replica_id) + if answer.status == RunnerCancelResult.not_held: + return DeliveryReceipt(status="not_held") + return DeliveryReceipt(status="unreachable") + + async def acknowledge(self, *, command_id: UUID, replica_id: str) -> None: + """A no-op: the claim compare-and-set in the DAO IS the acknowledgement, and the direct + adapter keeps no delivery bookkeeping of its own.""" + return None diff --git a/api/oss/src/dbs/postgres/sessions/commands/__init__.py b/api/oss/src/dbs/postgres/sessions/commands/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/api/oss/src/dbs/postgres/sessions/commands/dao.py b/api/oss/src/dbs/postgres/sessions/commands/dao.py new file mode 100644 index 00000000000..482e33d0e7f --- /dev/null +++ b/api/oss/src/dbs/postgres/sessions/commands/dao.py @@ -0,0 +1,505 @@ +"""Storage for durable session commands. + +Every state transition is one `UPDATE ... WHERE RETURNING *`, decided by +`scalar_one_or_none()`. That is what makes two API replicas unable to both win a claim or both +write a terminal outcome, and it is the same pattern +`SessionInteractionsDAO.transition_interaction` already uses. +""" + +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional +from uuid import UUID + +from sqlalchemy import and_, func, or_, select, update as sa_update +from sqlalchemy.exc import IntegrityError + +from oss.src.utils.logging import get_module_logger + +from oss.src.core.sessions.commands.dtos import ( + SessionCommand, + SessionCommandCreate, + SessionCommandKind, + SessionCommandSettle, + SessionCommandState, +) +from oss.src.core.sessions.commands.interfaces import ( + CommandCreateResult, + SessionCommandsDAOInterface, + SessionScope, +) +from oss.src.dbs.postgres.sessions.commands.dbes import SessionCommandDBE +from oss.src.dbs.postgres.sessions.commands.mappings import ( + map_command_dbe_to_dto, + map_command_dto_to_dbe_create, +) +from oss.src.dbs.postgres.sessions.streams.dbes import SessionStreamDBE +from oss.src.dbs.postgres.shared.engine import ( + TransactionsEngine, + get_transactions_engine, +) + +log = get_module_logger(__name__) + +_OPEN_STATES = (SessionCommandState.pending.value, SessionCommandState.claimed.value) + + +def _map_commands_skipping_unmappable( + rows: List[SessionCommandDBE], + *, + context: str, +) -> List[SessionCommand]: + """Map a batch of command rows to DTOs, skipping any row this API cannot map. + + A newer API replica can write a command `kind` (or state, or outcome) an older replica's + enums do not know; `map_command_dbe_to_dto` then raises `ValueError` on that row. Both the + abandoned-command sweep and a runner's claim read a whole batch before acting on any of it, + so one such row used to poison the entire batch -- the ValueError escaped the list + comprehension and nothing was settled or claimed. Skip the rows this API cannot act on, + warn once per batch with their kinds and count, and return the rest. The unknown row is + left untouched for a replica that knows its kind; this never changes the enum or the write + path. `context` names the batch in the warning (for example "abandoned" or "claimed"). + """ + mapped: List[SessionCommand] = [] + skipped: Dict[str, int] = {} + for dbe in rows: + try: + mapped.append(map_command_dbe_to_dto(dbe)) + except ValueError: + kind = str(dbe.kind) + skipped[kind] = skipped.get(kind, 0) + 1 + if skipped: + by_kind = ", ".join( + f"{kind}={count}" for kind, count in sorted(skipped.items()) + ) + log.warning( + "commands: skipped %d %s row(s) this API cannot map (by kind: %s)", + sum(skipped.values()), + context, + by_kind, + ) + return mapped + + +class SessionCommandsDAO(SessionCommandsDAOInterface): + def __init__(self, engine: TransactionsEngine = None): + if engine is None: + engine = get_transactions_engine() + self.engine = engine + + def transaction(self): + return self.engine.session() + + async def create_command( + self, + *, + user_id: Optional[UUID], + command: SessionCommandCreate, + stopping_turn_id: Optional[str] = None, + ) -> SessionCommand: + result = await self.create_command_with_status( + user_id=user_id, + command=command, + stopping_turn_id=stopping_turn_id, + ) + return result.command + + async def create_command_with_status( + self, + *, + user_id: Optional[UUID], + command: SessionCommandCreate, + stopping_turn_id: Optional[str] = None, + ) -> CommandCreateResult: + """Insert the command and stamp the session row's `stopping_turn_id` together. + + One transaction, on purpose. A user whose Stop was recorded but whose session row never + learned it is waiting has a session that renders as plainly running while a command + exists to stop it, and nothing later reconciles the two. + + `session_streams` is written from here rather than through the streams DAO because + sharing one transaction is the whole requirement, and the streams DAO opens its own. + """ + dbe = map_command_dto_to_dbe_create(user_id=user_id, command=command) + + try: + async with self.engine.session() as session: + session.add(dbe) + if stopping_turn_id is not None: + await session.execute( + sa_update(SessionStreamDBE) + .where( + SessionStreamDBE.project_id == command.project_id, + SessionStreamDBE.session_id == command.session_id, + SessionStreamDBE.deleted_at.is_(None), + ) + .values(stopping_turn_id=stopping_turn_id) + ) + await session.commit() + await session.refresh(dbe) + return CommandCreateResult( + command=map_command_dbe_to_dto(dbe), inserted=True + ) + except IntegrityError: + # One of two unique constraints refused this insert, and both mean the same thing: + # a command for this intent already exists. Return it rather than a second command. + # + # uq_session_commands_idempotency — the caller retried with the same key. + # uq_session_commands_open_target — another request is already stopping this + # execution, which is what makes two Stops in + # the SAME INSTANT one command. Admission's own + # read cannot see a row that has not committed + # yet, so the database is the decider. + if command.idempotency_key is not None: + existing = await self.fetch_by_idempotency_key( + project_id=command.project_id, + session_id=command.session_id, + idempotency_key=command.idempotency_key, + ) + if existing is not None: + return CommandCreateResult(command=existing, inserted=False) + open_command = await self.fetch_open_command( + project_id=command.project_id, + session_id=command.session_id, + kind=command.kind, + target_turn_id=command.target_turn_id, + ) + if open_command is None: + raise + return CommandCreateResult(command=open_command, inserted=False) + + async def fetch_by_idempotency_key( + self, + *, + project_id: UUID, + session_id: str, + idempotency_key: str, + ) -> Optional[SessionCommand]: + async with self.engine.session() as session: + stmt = select(SessionCommandDBE).where( + SessionCommandDBE.project_id == project_id, + SessionCommandDBE.session_id == session_id, + SessionCommandDBE.idempotency_key == idempotency_key, + ) + result = await session.execute(stmt) + dbe = result.scalar_one_or_none() + return map_command_dbe_to_dto(dbe) if dbe is not None else None + + async def fetch_open_command( + self, + *, + project_id: UUID, + session_id: str, + kind: SessionCommandKind, + target_turn_id: Optional[str], + ) -> Optional[SessionCommand]: + async with self.engine.session() as session: + stmt = ( + select(SessionCommandDBE) + .where( + SessionCommandDBE.project_id == project_id, + SessionCommandDBE.session_id == session_id, + SessionCommandDBE.kind == kind.value, + SessionCommandDBE.state.in_(_OPEN_STATES), + SessionCommandDBE.deleted_at.is_(None), + ( + SessionCommandDBE.target_turn_id.is_(None) + if target_turn_id is None + else SessionCommandDBE.target_turn_id == target_turn_id + ), + ) + .order_by(SessionCommandDBE.created_at.desc()) + .limit(1) + ) + result = await session.execute(stmt) + dbe = result.scalar_one_or_none() + return map_command_dbe_to_dto(dbe) if dbe is not None else None + + async def fetch_command( + self, + *, + command_id: UUID, + project_id: Optional[UUID] = None, + ) -> Optional[SessionCommand]: + async with self.engine.session() as session: + stmt = select(SessionCommandDBE).where( + SessionCommandDBE.id == command_id, + ) + if project_id is not None: + stmt = stmt.where(SessionCommandDBE.project_id == project_id) + result = await session.execute(stmt) + dbe = result.scalars().first() + return map_command_dbe_to_dto(dbe) if dbe is not None else None + + async def claim_commands( + self, + *, + sessions: List[SessionScope], + replica_id: str, + lease_seconds: int, + limit: int, + ) -> List[SessionCommand]: + """Take pending commands for the sessions the caller declares it holds warm. + + The runner declaring what it holds is the routing input, not a replica id: a parked + session's Redis owner key expires, but the session is still in the runner's pool. + """ + if not sessions or limit <= 0: + return [] + + scope_filter = or_( + *[ + and_( + SessionCommandDBE.project_id == scope.project_id, + SessionCommandDBE.session_id == scope.session_id, + ) + for scope in sessions + ] + ) + + async with self.engine.session() as session: + selectable = ( + select(SessionCommandDBE.project_id, SessionCommandDBE.id) + .where( + SessionCommandDBE.state == SessionCommandState.pending.value, + SessionCommandDBE.deleted_at.is_(None), + scope_filter, + ) + .order_by(SessionCommandDBE.created_at) + .limit(limit) + # Two API replicas serving two claims at the same time must neither block on + # each other nor hand out the same command twice. + .with_for_update(skip_locked=True) + ) + rows = (await session.execute(selectable)).all() + if not rows: + await session.commit() + return [] + + keys = or_( + *[ + and_( + SessionCommandDBE.project_id == row[0], + SessionCommandDBE.id == row[1], + ) + for row in rows + ] + ) + now = datetime.now(timezone.utc) + stmt = ( + sa_update(SessionCommandDBE) + .where( + keys, + SessionCommandDBE.state == SessionCommandState.pending.value, + ) + .values( + state=SessionCommandState.claimed.value, + claimed_by=replica_id, + claim_expires_at=now + timedelta(seconds=lease_seconds), + claim_count=SessionCommandDBE.claim_count + 1, + updated_at=now, + ) + .returning(SessionCommandDBE) + ) + claimed = (await session.execute(stmt)).scalars().all() + await session.commit() + return _map_commands_skipping_unmappable(claimed, context="claimed") + + async def claim_for_delivery( + self, + *, + project_id: UUID, + command_id: UUID, + replica_id: str, + lease_seconds: int, + ) -> Optional[SessionCommand]: + """`pending` to `claimed` for one named command, after a runner accepted it directly. + + None means somebody else already took or settled it, which is not an error: the runner + that answered will still report, and the outcome route decides on the stored state. + The delivery budget was already consumed by `record_delivery_attempt`; incrementing it + again here would charge one direct delivery twice. Long-poll claims use `claim_commands`, + which performs its own increment. + """ + async with self.engine.session() as session: + now = datetime.now(timezone.utc) + stmt = ( + sa_update(SessionCommandDBE) + .where( + SessionCommandDBE.project_id == project_id, + SessionCommandDBE.id == command_id, + SessionCommandDBE.state == SessionCommandState.pending.value, + ) + .values( + state=SessionCommandState.claimed.value, + claimed_by=replica_id, + claim_expires_at=now + timedelta(seconds=lease_seconds), + updated_at=now, + ) + .returning(SessionCommandDBE) + ) + result = await session.execute(stmt) + dbe = result.scalar_one_or_none() + await session.commit() + return map_command_dbe_to_dto(dbe) if dbe is not None else None + + async def record_delivery_attempt( + self, + *, + project_id: UUID, + command_id: UUID, + now: datetime, + max_deliveries: int, + ) -> Optional[SessionCommand]: + stmt = ( + sa_update(SessionCommandDBE) + .where( + SessionCommandDBE.project_id == project_id, + SessionCommandDBE.id == command_id, + SessionCommandDBE.state.in_(_OPEN_STATES), + SessionCommandDBE.claim_count < max_deliveries, + or_( + SessionCommandDBE.state == SessionCommandState.pending.value, + SessionCommandDBE.claim_expires_at < now, + ), + ) + .values( + state=SessionCommandState.pending.value, + claimed_by=None, + claim_expires_at=None, + claim_count=SessionCommandDBE.claim_count + 1, + updated_at=now, + ) + .returning(SessionCommandDBE) + ) + async with self.engine.session() as session: + result = await session.execute(stmt) + dbe = result.scalar_one_or_none() + await session.commit() + return map_command_dbe_to_dto(dbe) if dbe is not None else None + + async def settle_command( + self, + *, + settle: SessionCommandSettle, + transaction: Optional[Any] = None, + ) -> Optional[SessionCommand]: + """Terminal transition. None means the command was in none of the states the caller + expected, so the caller reads the stored row and answers 409 instead of letting a runner + retry. + + One statement, so the guard is evaluated at the moment of the write. Reading the state + first and updating after would reopen the very race this exists to close: the claim can + commit between the read and the write. + """ + + async def execute(session: Any) -> Optional[SessionCommand]: + now = datetime.now(timezone.utc) + stmt = sa_update(SessionCommandDBE).where( + SessionCommandDBE.project_id == settle.project_id, + SessionCommandDBE.id == settle.command_id, + SessionCommandDBE.state.in_( + [state.value for state in settle.expected_states] + ), + ) + if settle.replica_id is not None: + # Only the replica holding the claim may write the outcome. A row still + # `pending` holds no claim, and refusing it there is what turned a correct + # abort into a command the sweep later called lost. + stmt = stmt.where( + or_( + SessionCommandDBE.claimed_by.is_(None), + SessionCommandDBE.claimed_by == settle.replica_id, + ) + ) + stmt = stmt.values( + state=settle.state.value, + outcome=settle.outcome.value, + settled_at=now, + updated_at=now, + ).returning(SessionCommandDBE) + result = await session.execute(stmt) + dbe = result.scalar_one_or_none() + return map_command_dbe_to_dto(dbe) if dbe is not None else None + + if transaction is not None: + return await execute(transaction) + async with self.engine.session() as session: + return await execute(session) + + async def clear_stopping_turn( + self, + *, + project_id: UUID, + session_id: str, + turn_id: Optional[str] = None, + ) -> None: + async with self.engine.session() as session: + stmt = ( + sa_update(SessionStreamDBE) + .where( + SessionStreamDBE.project_id == project_id, + SessionStreamDBE.session_id == session_id, + ) + .values(stopping_turn_id=None) + ) + if turn_id is not None: + # Only clear OUR marker. A settlement that arrives after a second Stop was + # admitted must not tell the browser the newer Stop already finished. + stmt = stmt.where(SessionStreamDBE.stopping_turn_id == turn_id) + await session.execute(stmt) + await session.commit() + + async def expire_claims( + self, + *, + now: datetime, + max_deliveries: int, + pending_before: Optional[datetime] = None, + ) -> List[SessionCommand]: + async with self.engine.session() as session: + abandoned = and_( + SessionCommandDBE.state == SessionCommandState.claimed.value, + SessionCommandDBE.claim_expires_at < now, + ) + if pending_before is not None: + abandoned = or_( + abandoned, + and_( + SessionCommandDBE.state == SessionCommandState.pending.value, + func.coalesce( + SessionCommandDBE.updated_at, + SessionCommandDBE.created_at, + ) + < pending_before, + ), + ) + stmt = ( + select(SessionCommandDBE) + .where( + SessionCommandDBE.deleted_at.is_(None), + abandoned, + ) + .order_by( + func.coalesce( + SessionCommandDBE.claim_expires_at, + SessionCommandDBE.updated_at, + SessionCommandDBE.created_at, + ) + ) + .limit(200) + ) + result = await session.execute(stmt) + rows = result.scalars().all() + return _map_commands_skipping_unmappable(rows, context="abandoned") + + async def count_open(self, *, project_id: UUID, session_id: str) -> int: + """Open commands for a session. Diagnostics and tests only.""" + async with self.engine.session() as session: + stmt = select(func.count()).where( + SessionCommandDBE.project_id == project_id, + SessionCommandDBE.session_id == session_id, + SessionCommandDBE.state.in_(_OPEN_STATES), + SessionCommandDBE.deleted_at.is_(None), + ) + result = await session.execute(stmt) + return int(result.scalar() or 0) diff --git a/api/oss/src/dbs/postgres/sessions/commands/dbas.py b/api/oss/src/dbs/postgres/sessions/commands/dbas.py new file mode 100644 index 00000000000..0163f162bb7 --- /dev/null +++ b/api/oss/src/dbs/postgres/sessions/commands/dbas.py @@ -0,0 +1,52 @@ +from sqlalchemy import Column, Integer, String, TIMESTAMP + +from oss.src.dbs.postgres.shared.dbas import ( + DataDBA, + FlagsDBA, + IdentifierDBA, + LifecycleDBA, + MetaDBA, + ProjectScopeDBA, + TagsDBA, +) + + +class SessionCommandDBA( + ProjectScopeDBA, + LifecycleDBA, + IdentifierDBA, + DataDBA, + FlagsDBA, + TagsDBA, + MetaDBA, +): + """One durable request to change an execution. + + The delivery columns (`state`, `claimed_by`, `claim_expires_at`, `claim_count`) are flat + rather than nested in `data` because a claim query filters and orders on them and a JSON + blob cannot be indexed for that. Their names carry the grouping. + + `state` and `outcome` are never merged. `state` says where the COMMAND is; `outcome` says + what happened to the EXECUTION. + """ + + __abstract__ = True + + # Bare correlator, not a foreign key — the same rule every other sessions table follows. + session_id = Column(String, nullable=False) + kind = Column(String, nullable=False) + + # The execution the API resolved ONCE at admission and pinned. A turn that starts later has + # a different id, so a pinned command can never reach it. Null when nothing was running. + target_turn_id = Column(String, nullable=True) + # What the caller asserted, stored as sent, so a 409 stays explainable after the fact. + expected_turn_id = Column(String, nullable=True) + + state = Column(String, nullable=False) + claimed_by = Column(String, nullable=True) + claim_expires_at = Column(TIMESTAMP(timezone=True), nullable=True) + claim_count = Column(Integer, nullable=False, default=0, server_default="0") + + outcome = Column(String, nullable=True) + idempotency_key = Column(String, nullable=True) + settled_at = Column(TIMESTAMP(timezone=True), nullable=True) diff --git a/api/oss/src/dbs/postgres/sessions/commands/dbes.py b/api/oss/src/dbs/postgres/sessions/commands/dbes.py new file mode 100644 index 00000000000..f4a755aba9a --- /dev/null +++ b/api/oss/src/dbs/postgres/sessions/commands/dbes.py @@ -0,0 +1,80 @@ +from sqlalchemy import ( + CheckConstraint, + ForeignKeyConstraint, + Index, + PrimaryKeyConstraint, + UniqueConstraint, + text, +) + +from oss.src.dbs.postgres.shared.base import Base +from oss.src.dbs.postgres.sessions.commands.dbas import SessionCommandDBA + + +class SessionCommandDBE(Base, SessionCommandDBA): + __tablename__ = "session_commands" + + __table_args__ = ( + ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"), + PrimaryKeyConstraint("project_id", "id"), + # The caller's retry identity. Postgres treats nulls as distinct in a unique index, so a + # command with no client key never collides with another. + UniqueConstraint( + "project_id", + "session_id", + "idempotency_key", + name="uq_session_commands_idempotency", + ), + CheckConstraint("kind IN ('cancel')", name="ck_session_commands_kind"), + CheckConstraint( + "state IN ('pending', 'claimed', 'applied', 'obsolete')", + name="ck_session_commands_state", + ), + # ONE open command per target execution. Two Stops are one intent, and admission's + # read-then-insert cannot enforce that on its own: two requests that arrive in the same + # instant both find no open command and both insert. The database decides instead, and + # the DAO turns the losing insert into a read of the winner. + # + # `target_turn_id` is NULL only on a command that is inserted already settled, which the + # predicate excludes, so the fact that Postgres treats NULLs as distinct costs nothing. + Index( + "uq_session_commands_open_target", + "project_id", + "session_id", + "kind", + "target_turn_id", + unique=True, + postgresql_where=text( + "state IN ('pending', 'claimed') AND deleted_at IS NULL" + ), + ), + # The claim query's index, and the open-command collapse read at admission. Partial on + # the open states because a settled command is never claimed again. + Index( + "ix_session_commands_open", + "project_id", + "session_id", + "created_at", + postgresql_where=text( + "state IN ('pending', 'claimed') AND deleted_at IS NULL" + ), + ), + # The settlement sweep's index: expired leases, nothing else. + Index( + "ix_session_commands_claims", + "claim_expires_at", + postgresql_where=text("state = 'claimed' AND deleted_at IS NULL"), + ), + Index( + "ix_session_commands_project_session", + "project_id", + "session_id", + "created_at", + ), + # The runner reports an outcome with the command id ALONE (it holds no project + # credential), so that read needs an index that does not lead with the project. + Index( + "ix_session_commands_id", + "id", + ), + ) diff --git a/api/oss/src/dbs/postgres/sessions/commands/mappings.py b/api/oss/src/dbs/postgres/sessions/commands/mappings.py new file mode 100644 index 00000000000..65a36df1eca --- /dev/null +++ b/api/oss/src/dbs/postgres/sessions/commands/mappings.py @@ -0,0 +1,72 @@ +from typing import Optional +from uuid import UUID + +from oss.src.core.sessions.commands.dtos import ( + SessionCommand, + SessionCommandCreate, + SessionCommandKind, + SessionCommandOutcome, + SessionCommandState, +) +from oss.src.dbs.postgres.sessions.commands.dbes import SessionCommandDBE + + +def map_command_dto_to_dbe_create( + *, + user_id: Optional[UUID], + command: SessionCommandCreate, +) -> SessionCommandDBE: + return SessionCommandDBE( + project_id=command.project_id, + # + created_by_id=user_id, + # Stamped, not defaulted: the stale-Stop guard compares this value, so the row must + # carry exactly the instant that was compared. + **({"created_at": command.created_at} if command.created_at else {}), + # + session_id=command.session_id, + kind=command.kind.value, + target_turn_id=command.target_turn_id, + expected_turn_id=command.expected_turn_id, + # + state=command.state.value, + claim_count=0, + outcome=command.outcome.value if command.outcome else None, + settled_at=command.settled_at, + idempotency_key=command.idempotency_key, + # + data=command.data, + ) + + +def map_command_dbe_to_dto(dbe: SessionCommandDBE) -> SessionCommand: + return SessionCommand( + id=dbe.id, + # + created_at=dbe.created_at, + updated_at=dbe.updated_at, + deleted_at=dbe.deleted_at, + created_by_id=dbe.created_by_id, + updated_by_id=dbe.updated_by_id, + deleted_by_id=dbe.deleted_by_id, + # + project_id=dbe.project_id, + session_id=dbe.session_id, + kind=SessionCommandKind(dbe.kind), + # + target_turn_id=dbe.target_turn_id, + expected_turn_id=dbe.expected_turn_id, + data=dbe.data, + # + state=SessionCommandState(dbe.state), + claimed_by=dbe.claimed_by, + claim_expires_at=dbe.claim_expires_at, + claim_count=dbe.claim_count or 0, + # + outcome=SessionCommandOutcome(dbe.outcome) if dbe.outcome else None, + idempotency_key=dbe.idempotency_key, + settled_at=dbe.settled_at, + # + tags=dbe.tags, + meta=dbe.meta, + ) diff --git a/api/oss/src/dbs/postgres/sessions/executions/__init__.py b/api/oss/src/dbs/postgres/sessions/executions/__init__.py new file mode 100644 index 00000000000..d30a53d8fee --- /dev/null +++ b/api/oss/src/dbs/postgres/sessions/executions/__init__.py @@ -0,0 +1 @@ +"""Postgres execution terminal-state storage.""" diff --git a/api/oss/src/dbs/postgres/sessions/executions/dao.py b/api/oss/src/dbs/postgres/sessions/executions/dao.py new file mode 100644 index 00000000000..69c646835a5 --- /dev/null +++ b/api/oss/src/dbs/postgres/sessions/executions/dao.py @@ -0,0 +1,166 @@ +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Sequence, Tuple +from uuid import UUID + +from sqlalchemy import and_, literal_column, or_, select, tuple_, update as sa_update +from sqlalchemy.dialects.postgresql import insert + +from oss.src.core.sessions.executions.dtos import ( + SessionExecutionSettlement, + SessionExecutionSettlementResult, +) +from oss.src.core.sessions.executions.interfaces import SessionExecutionsDAOInterface +from oss.src.dbs.postgres.sessions.executions.dbes import SessionExecutionDBE +from oss.src.dbs.postgres.shared.engine import ( + TransactionsEngine, + get_transactions_engine, +) + + +def _to_dto(row: SessionExecutionDBE) -> SessionExecutionSettlement: + return SessionExecutionSettlement( + project_id=row.project_id, + session_id=row.session_id, + execution_id=row.execution_id, + terminal_outcome=row.terminal_outcome, + settled_by=row.settled_by, + settled_at=row.settled_at, + ending_written_at=row.ending_written_at, + redis_reconciled_at=row.redis_reconciled_at, + ) + + +class SessionExecutionsDAO(SessionExecutionsDAOInterface): + def __init__(self, engine: Optional[TransactionsEngine] = None): + self.engine = engine or get_transactions_engine() + + async def settle( + self, + *, + project_id: UUID, + session_id: str, + execution_id: str, + terminal_outcome: str, + settled_by: str, + settled_at: Optional[datetime] = None, + transaction: Optional[Any] = None, + ) -> SessionExecutionSettlementResult: + settled_at = settled_at or datetime.now(timezone.utc) + stmt = ( + insert(SessionExecutionDBE) + .values( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + terminal_outcome=terminal_outcome, + settled_by=settled_by, + settled_at=settled_at, + ) + .on_conflict_do_update( + index_elements=["project_id", "session_id", "execution_id"], + set_={"terminal_outcome": SessionExecutionDBE.terminal_outcome}, + ) + .returning( + SessionExecutionDBE, + literal_column("xmax = 0").label("won"), + ) + ) + + async def execute(session: Any) -> SessionExecutionSettlementResult: + stored, won = (await session.execute(stmt)).one() + return SessionExecutionSettlementResult(settlement=_to_dto(stored), won=won) + + if transaction is not None: + return await execute(transaction) + async with self.engine.session() as session: + return await execute(session) + + async def query_settled( + self, + *, + project_id: UUID, + keys: Sequence[Tuple[str, str]], + ) -> Dict[Tuple[str, str], SessionExecutionSettlement]: + if not keys: + return {} + key_filter = or_( + *[ + and_( + SessionExecutionDBE.session_id == session_id, + SessionExecutionDBE.execution_id == execution_id, + ) + for session_id, execution_id in keys + ] + ) + async with self.engine.session() as session: + rows = ( + await session.execute( + select(SessionExecutionDBE).where( + SessionExecutionDBE.project_id == project_id, + key_filter, + ) + ) + ).scalars() + return {(row.session_id, row.execution_id): _to_dto(row) for row in rows} + + async def mark_endings_written( + self, + *, + project_id: UUID, + keys: Sequence[Tuple[str, str]], + written_at: Optional[datetime] = None, + ) -> None: + if not keys: + return + async with self.engine.session() as session: + await session.execute( + sa_update(SessionExecutionDBE) + .where( + SessionExecutionDBE.project_id == project_id, + tuple_( + SessionExecutionDBE.session_id, + SessionExecutionDBE.execution_id, + ).in_(keys), + SessionExecutionDBE.ending_written_at.is_(None), + ) + .values(ending_written_at=written_at or datetime.now(timezone.utc)) + ) + + async def list_redis_unreconciled( + self, + *, + limit: int, + ) -> List[SessionExecutionSettlement]: + async with self.engine.session() as session: + rows = ( + await session.execute( + select(SessionExecutionDBE) + .where( + SessionExecutionDBE.settled_by == "runner", + SessionExecutionDBE.terminal_outcome == "stopped", + SessionExecutionDBE.redis_reconciled_at.is_(None), + ) + .order_by(SessionExecutionDBE.settled_at) + .limit(limit) + ) + ).scalars() + return [_to_dto(row) for row in rows] + + async def mark_redis_reconciled( + self, + *, + project_id: UUID, + session_id: str, + execution_id: str, + ) -> None: + async with self.engine.session() as session: + await session.execute( + sa_update(SessionExecutionDBE) + .where( + SessionExecutionDBE.project_id == project_id, + SessionExecutionDBE.session_id == session_id, + SessionExecutionDBE.execution_id == execution_id, + SessionExecutionDBE.redis_reconciled_at.is_(None), + ) + .values(redis_reconciled_at=datetime.now(timezone.utc)) + ) diff --git a/api/oss/src/dbs/postgres/sessions/executions/dbes.py b/api/oss/src/dbs/postgres/sessions/executions/dbes.py new file mode 100644 index 00000000000..2a13846bb91 --- /dev/null +++ b/api/oss/src/dbs/postgres/sessions/executions/dbes.py @@ -0,0 +1,48 @@ +from sqlalchemy import ( + Column, + ForeignKeyConstraint, + Index, + PrimaryKeyConstraint, + String, + text, +) +from sqlalchemy import TIMESTAMP +from sqlalchemy.dialects.postgresql import UUID + +from oss.src.dbs.postgres.shared.base import Base + + +class SessionExecutionDBE(Base): + __tablename__ = "session_executions" + + project_id = Column(UUID(as_uuid=True), nullable=False) + session_id = Column(String, nullable=False) + execution_id = Column(String, nullable=False) + terminal_outcome = Column(String, nullable=False) + settled_by = Column(String, nullable=False) + settled_at = Column(TIMESTAMP(timezone=True), nullable=False) + ending_written_at = Column(TIMESTAMP(timezone=True), nullable=True) + redis_reconciled_at = Column(TIMESTAMP(timezone=True), nullable=True) + + __table_args__ = ( + ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"), + PrimaryKeyConstraint("project_id", "session_id", "execution_id"), + Index( + "ix_session_executions_project_session", + "project_id", + "session_id", + ), + Index( + "ix_session_executions_ending_unwritten", + "settled_at", + postgresql_where=text("ending_written_at IS NULL"), + ), + Index( + "ix_session_executions_redis_unreconciled", + "settled_at", + postgresql_where=text( + "settled_by = 'runner' AND terminal_outcome = 'stopped' " + "AND redis_reconciled_at IS NULL" + ), + ), + ) diff --git a/api/oss/src/dbs/postgres/sessions/interactions/dao.py b/api/oss/src/dbs/postgres/sessions/interactions/dao.py index 97043a77b46..ef46fcbd0a8 100644 --- a/api/oss/src/dbs/postgres/sessions/interactions/dao.py +++ b/api/oss/src/dbs/postgres/sessions/interactions/dao.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta, timezone -from typing import List, Optional +from typing import Any, List, Optional from uuid import UUID from sqlalchemy import cast, delete as sa_delete, func, select, update as sa_update @@ -142,13 +142,15 @@ async def cancel_session_pending( except_turn_id: Optional[str] = None, except_tokens: Optional[List[str]] = None, only_turn_id: Optional[str] = None, - ) -> int: + transaction: Optional[Any] = None, + ) -> List[SessionInteraction]: """Cancel still-pending interactions for a session. With `except_turn_id`, spare the current turn's own gates (used at turn start to cancel prior turns' unanswered gates; without it, cancel all of them, e.g. on kill). `except_tokens` spares prior-turn gates the current turn answers in-band, so the resume can resolve them instead. With - `only_turn_id`, touch nothing but that one turn's gates. Returns the count cancelled.""" - async with self.engine.session() as session: + `only_turn_id`, touch nothing but that one turn's gates. Returns the rows cancelled.""" + + async def execute(session: Any) -> List[SessionInteraction]: stmt = ( sa_update(SessionInteractionDBE) .where( @@ -160,6 +162,7 @@ async def cancel_session_pending( status="cancelled", updated_at=datetime.now(timezone.utc), ) + .returning(SessionInteractionDBE) ) if only_turn_id is not None: stmt = stmt.where(SessionInteractionDBE.turn_id == only_turn_id) @@ -168,8 +171,14 @@ async def cancel_session_pending( if except_tokens: stmt = stmt.where(SessionInteractionDBE.token.notin_(except_tokens)) result = await session.execute(stmt) + return [map_interaction_dbe_to_dto(dbe) for dbe in result.scalars().all()] + + if transaction is not None: + return await execute(transaction) + async with self.engine.session() as session: + cancelled = await execute(session) await session.commit() - return result.rowcount or 0 + return cancelled async def query_interactions( self, diff --git a/api/oss/src/dbs/postgres/sessions/records/dao.py b/api/oss/src/dbs/postgres/sessions/records/dao.py index 3f7a08c9491..019deb5ceac 100644 --- a/api/oss/src/dbs/postgres/sessions/records/dao.py +++ b/api/oss/src/dbs/postgres/sessions/records/dao.py @@ -1,23 +1,32 @@ -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Sequence, Set, Tuple from uuid import UUID -from sqlalchemy import func, select +from sqlalchemy import case, func, or_, select, tuple_, update from sqlalchemy.dialects.postgresql import insert from sqlalchemy.ext.asyncio import AsyncSession from oss.src.core.sessions.records.dtos import ( + RECORD_SETTLED_BY_ATTRIBUTE, SESSION_MESSAGE_PREVIEW_TEXT_LIMIT, + TERMINAL_RECORD_TYPE, SessionMessagePreview, SessionRecord, SessionRecordEvent, + SessionRecordsPage, + SessionRecordsReplay, + SessionRecordsReadState, ) from oss.src.core.sessions.records.interfaces import RecordsDAOInterface -from oss.src.dbs.postgres.sessions.records.dbes import RecordDBE +from oss.src.dbs.postgres.sessions.records.dbes import ( + RecordDBE, + SessionSequenceCursorDBE, +) from oss.src.dbs.postgres.sessions.records.mappings import ( map_record_event_to_dbe, map_record_dbe_to_dto, ) from oss.src.dbs.postgres.shared.engine import AnalyticsEngine, get_analytics_engine +from oss.src.utils.env import env class RecordsDAO(RecordsDAOInterface): @@ -46,7 +55,11 @@ async def _append( event: SessionRecordEvent, session: AsyncSession, ) -> Optional[SessionRecord]: - stmt = RecordsDAO._upsert_stmt(values_list=[RecordsDAO._values(event=event)]) + values = RecordsDAO._values(event=event) + if env.sessions.sequence_writes: + return await RecordsDAO._append_sequenced(values=values, session=session) + + stmt = RecordsDAO._upsert_stmt(values_list=[values]) result = await session.execute(stmt) await session.flush() @@ -55,6 +68,53 @@ async def _append( return None return map_record_dbe_to_dto(dbe=row) + @staticmethod + async def _append_sequenced( + *, + values: dict, + session: AsyncSession, + ) -> Optional[SessionRecord]: + insert_stmt = ( + insert(RecordDBE) + .values(values) + .on_conflict_do_nothing(index_elements=["project_id", "record_id"]) + .returning(RecordDBE.record_id) + ) + inserted_id = (await session.execute(insert_stmt)).scalar_one_or_none() + if inserted_id is None: + result = await session.execute( + RecordsDAO._upsert_stmt(values_list=[values]) + ) + await session.flush() + row = result.scalars().first() + return map_record_dbe_to_dto(dbe=row) if row is not None else None + + cursor_insert = insert(SessionSequenceCursorDBE).values( + project_id=values["project_id"], + session_id=values["session_id"], + latest_sequence=1, + ) + cursor_stmt = cursor_insert.on_conflict_do_update( + index_elements=["project_id", "session_id"], + set_={ + "latest_sequence": SessionSequenceCursorDBE.latest_sequence + 1, + "updated_at": func.now(), + }, + ).returning(SessionSequenceCursorDBE.latest_sequence) + sequence = (await session.execute(cursor_stmt)).scalar_one() + record_stmt = ( + update(RecordDBE) + .where( + RecordDBE.project_id == values["project_id"], + RecordDBE.record_id == values["record_id"], + ) + .values(sequence=sequence) + .returning(RecordDBE) + ) + row = (await session.execute(record_stmt)).scalars().one() + await session.flush() + return map_record_dbe_to_dto(dbe=row) + async def append_many( self, *, @@ -70,6 +130,25 @@ async def append_many( ) async with self.engine.session() as session: + if env.sessions.sequence_writes: + records = [] + # Stable session order prevents mixed-session transactions from deadlocking. + ordered_values = sorted( + values_list, + key=lambda values: ( + str(values["project_id"]), + values["session_id"], + ), + ) + for values in ordered_values: + record = await self._append_sequenced( + values=values, session=session + ) + if record is not None: + records.append(record) + await session.commit() + return records + stmt = self._upsert_stmt(values_list=values_list) result = await session.execute(stmt) await session.commit() @@ -94,6 +173,7 @@ def _values(*, event: SessionRecordEvent) -> dict: "attributes", "turn_id", "span_id", + "quarantined_at", ) @staticmethod @@ -132,6 +212,14 @@ def _upsert_stmt(*, values_list: List[dict]): "attributes": stmt.excluded.attributes, "turn_id": stmt.excluded.turn_id, "span_id": stmt.excluded.span_id, + # coalesce, not a plain overwrite: quarantine is one-way. A redelivery of a + # late record keeps the instant it was FIRST quarantined, so the column is + # stable however many times the stream replays the message, and a delivery + # that somehow arrives unmarked can never resurrect the row into the + # transcript. + "quarantined_at": func.coalesce( + RecordDBE.quarantined_at, stmt.excluded.quarantined_at + ), }, ).returning(RecordDBE) @@ -147,6 +235,11 @@ async def get_records( .where( RecordDBE.project_id == project_id, RecordDBE.session_id == session_id, + # A quarantined record is history the platform refused: it reached ingest + # for a turn the watchdog had already ended. Excluding it HERE is what + # makes one execution render one ending, because this is the read every + # transcript reconstruction goes through. + RecordDBE.quarantined_at.is_(None), ) # Producer event time first: it is the only key that is monotonic across # turns. `record_index` restarts at 0 every turn, and the worker can batch @@ -164,6 +257,156 @@ async def get_records( dbes = (await session.execute(stmt)).scalars().all() return [map_record_dbe_to_dto(dbe=dbe) for dbe in dbes] + @staticmethod + def _transcript_order(): + return ( + case((RecordDBE.sequence.is_(None), 0), else_=1), + case((RecordDBE.sequence.is_(None), RecordDBE.timestamp), else_=None) + .asc() + .nullslast(), + case((RecordDBE.sequence.is_(None), RecordDBE.created_at), else_=None) + .asc() + .nullslast(), + case((RecordDBE.sequence.is_(None), RecordDBE.record_index), else_=None) + .asc() + .nullslast(), + RecordDBE.sequence.asc().nullslast(), + ) + + async def get_records_page( + self, + *, + project_id: UUID, + session_id: str, + offset: int, + limit: int, + through_sequence: int, + ) -> SessionRecordsPage: + async with self.engine.session() as session: + stmt = ( + select(RecordDBE) + .where( + RecordDBE.project_id == project_id, + RecordDBE.session_id == session_id, + RecordDBE.deleted_at.is_(None), + RecordDBE.quarantined_at.is_(None), + or_( + RecordDBE.sequence.is_(None), + RecordDBE.sequence <= through_sequence, + ), + ) + .order_by(*self._transcript_order()) + .offset(offset) + .limit(limit + 1) + ) + rows = list((await session.execute(stmt)).scalars().all()) + + has_more = len(rows) > limit + records = [map_record_dbe_to_dto(dbe=row) for row in rows[:limit]] + return SessionRecordsPage( + records=records, + offset=offset, + limit=limit, + next_offset=offset + limit if has_more else None, + through_sequence=through_sequence, + ) + + async def get_read_state( + self, + *, + project_id: UUID, + session_id: str, + ) -> SessionRecordsReadState: + async with self.engine.session() as session: + latest_sequence = await session.scalar( + select(SessionSequenceCursorDBE.latest_sequence).where( + SessionSequenceCursorDBE.project_id == project_id, + SessionSequenceCursorDBE.session_id == session_id, + ) + ) + record_count, first_sequenced_at = ( + await session.execute( + select( + func.count(RecordDBE.record_id), + func.min(RecordDBE.created_at).filter( + RecordDBE.sequence.is_not(None) + ), + ).where( + RecordDBE.project_id == project_id, + RecordDBE.session_id == session_id, + RecordDBE.deleted_at.is_(None), + ) + ) + ).one() + null_after_cutover = False + if first_sequenced_at is not None: + null_after_cutover = bool( + await session.scalar( + select(func.count(RecordDBE.record_id)).where( + RecordDBE.project_id == project_id, + RecordDBE.session_id == session_id, + RecordDBE.deleted_at.is_(None), + RecordDBE.sequence.is_(None), + RecordDBE.created_at >= first_sequenced_at, + ) + ) + ) + + history_complete = record_count == 0 or ( + latest_sequence is not None and not null_after_cutover + ) + return SessionRecordsReadState( + latest_sequence=latest_sequence or 0, + history_complete=history_complete, + ) + + async def get_records_after( + self, + *, + project_id: UUID, + session_id: str, + after: int, + ) -> SessionRecordsReplay: + async with self.engine.session() as session: + watermark = ( + await session.scalar( + select(SessionSequenceCursorDBE.latest_sequence).where( + SessionSequenceCursorDBE.project_id == project_id, + SessionSequenceCursorDBE.session_id == session_id, + ) + ) + or 0 + ) + sequence_filter = ( + or_( + RecordDBE.sequence.is_(None), + RecordDBE.sequence.between(1, watermark), + ) + if after == 0 + else RecordDBE.sequence.between(after + 1, watermark) + ) + rows = list( + ( + await session.execute( + select(RecordDBE) + .where( + RecordDBE.project_id == project_id, + RecordDBE.session_id == session_id, + RecordDBE.deleted_at.is_(None), + RecordDBE.quarantined_at.is_(None), + sequence_filter, + ) + .order_by(*self._transcript_order()) + ) + ) + .scalars() + .all() + ) + return SessionRecordsReplay( + records=[map_record_dbe_to_dto(dbe=row) for row in rows], + watermark=watermark, + ) + async def latest_message_per_session( self, *, @@ -200,6 +443,7 @@ async def latest_message_per_session( RecordDBE.session_id.in_(session_ids), RecordDBE.record_type == "message", RecordDBE.deleted_at.is_(None), + RecordDBE.quarantined_at.is_(None), ) .distinct(RecordDBE.session_id) .order_by( @@ -225,6 +469,59 @@ async def latest_message_per_session( ) return previews + async def settled_turns( + self, + *, + project_id: UUID, + keys: Sequence[Tuple[str, str]], + settled_by: Optional[str] = None, + ) -> Set[Tuple[str, str]]: + """Which of these `(session_id, turn_id)` pairs already carry a terminal record. + + Two callers ask nearly the same question and mean different things by it, which is + why `settled_by` exists rather than a second query. + + * The watchdog asks with no writer, before it writes an ending of its own: ANY + terminal record means this turn already ended and must not be given a second, + contradictory one. + * The ingest guard asks with `settled_by="watchdog"`, and only the watchdog's own + ending counts. A runner that wrote its honest ending has not lost the turn to the + platform, so nothing arriving afterwards is late in the sense that matters. + + A QUARANTINED terminal record never answers yes to either. It is precisely the + second, refused ending both callers exist to keep out of the transcript, so counting + it would let one late `done` suppress the real one. + + One query for the whole batch, served by + `ix_records_project_id_session_id_turn_id`. + """ + if not keys: + return set() + + conditions = [ + RecordDBE.project_id == project_id, + RecordDBE.record_type == TERMINAL_RECORD_TYPE, + RecordDBE.deleted_at.is_(None), + RecordDBE.quarantined_at.is_(None), + tuple_(RecordDBE.session_id, RecordDBE.turn_id).in_( + [(session_id, turn_id) for session_id, turn_id in keys] + ), + ] + if settled_by is not None: + conditions.append( + RecordDBE.attributes[RECORD_SETTLED_BY_ATTRIBUTE].astext == settled_by + ) + + async with self.engine.session() as session: + stmt = ( + select(RecordDBE.session_id, RecordDBE.turn_id) + .where(*conditions) + .distinct() + ) + rows = (await session.execute(stmt)).all() + + return {(row.session_id, row.turn_id) for row in rows} + async def get_event( self, *, diff --git a/api/oss/src/dbs/postgres/sessions/records/dbas.py b/api/oss/src/dbs/postgres/sessions/records/dbas.py index 200eae44bbd..cd60fcc5aa6 100644 --- a/api/oss/src/dbs/postgres/sessions/records/dbas.py +++ b/api/oss/src/dbs/postgres/sessions/records/dbas.py @@ -1,6 +1,6 @@ import uuid_utils.compat as uuid -from sqlalchemy import Column, UUID, TIMESTAMP, String, Integer +from sqlalchemy import BigInteger, Column, UUID, TIMESTAMP, String, Integer from sqlalchemy.dialects.postgresql import JSONB @@ -37,6 +37,11 @@ class RecordDBA: nullable=False, ) + sequence = Column( + BigInteger, + nullable=True, + ) + # Producer-stamped per-turn ordinal and the in-session ordering key (record_id is # no longer time-ordered). Restarts at 0 each cold turn, so reads tiebreak with # created_at (ingest time) ahead of it — see get_records. @@ -65,3 +70,13 @@ class RecordDBA: JSONB(none_as_null=True), nullable=True, ) + + # Non-null when this record reached ingest for a turn the watchdog had ALREADY ended. + # The row is kept — the agent really did that work, and the token accounting on a late + # `usage` is real money — but every read that rebuilds a transcript excludes it, so one + # execution still shows exactly one ending. Written only by the ingest guard in + # `RecordsService.append_many`; forward-fill only, like every other column here. + quarantined_at = Column( + TIMESTAMP(timezone=True), + nullable=True, + ) diff --git a/api/oss/src/dbs/postgres/sessions/records/dbes.py b/api/oss/src/dbs/postgres/sessions/records/dbes.py index 78de94b5df3..2064b4705f3 100644 --- a/api/oss/src/dbs/postgres/sessions/records/dbes.py +++ b/api/oss/src/dbs/postgres/sessions/records/dbes.py @@ -1,8 +1,22 @@ -from sqlalchemy import PrimaryKeyConstraint, Index +from sqlalchemy import ( + BigInteger, + Column, + Index, + PrimaryKeyConstraint, + String, +) from oss.src.dbs.postgres.shared.base import Base from oss.src.dbs.postgres.sessions.records.dbas import RecordDBA, RecordTurnSpanDBA -from oss.src.dbs.postgres.shared.dbas import ProjectScopeDBA, LifecycleDBA +from oss.src.dbs.postgres.shared.dbas import LifecycleDBA, ProjectScopeDBA + + +class SessionSequenceCursorDBE(Base, ProjectScopeDBA, LifecycleDBA): + __tablename__ = "session_sequence_cursors" + __table_args__ = (PrimaryKeyConstraint("project_id", "session_id"),) + + session_id = Column(String, nullable=False) + latest_sequence = Column(BigInteger, nullable=False) class RecordDBE( @@ -33,4 +47,11 @@ class RecordDBE( "session_id", "turn_id", ), + Index( + "ux_records_session_id_sequence", + "project_id", + "session_id", + "sequence", + unique=True, + ), ) diff --git a/api/oss/src/dbs/postgres/sessions/records/mappings.py b/api/oss/src/dbs/postgres/sessions/records/mappings.py index 62420cc97e4..a1e4eac5d47 100644 --- a/api/oss/src/dbs/postgres/sessions/records/mappings.py +++ b/api/oss/src/dbs/postgres/sessions/records/mappings.py @@ -18,6 +18,7 @@ def map_record_event_to_dbe( project_id=event.project_id, session_id=event.session_id, record_id=event.record_id or uuid.uuid4(), + sequence=None, record_index=event.record_index, timestamp=event.timestamp, record_type=event.record_type, @@ -25,6 +26,7 @@ def map_record_event_to_dbe( attributes=event.attributes, turn_id=event.turn_id, span_id=event.span_id, + quarantined_at=event.quarantined_at, ) @@ -33,6 +35,7 @@ def map_record_dbe_to_dto(*, dbe: RecordDBE) -> SessionRecord: record_id=dbe.record_id, session_id=dbe.session_id, project_id=dbe.project_id, + sequence=dbe.sequence, record_index=dbe.record_index, timestamp=dbe.timestamp, record_type=dbe.record_type, @@ -40,5 +43,6 @@ def map_record_dbe_to_dto(*, dbe: RecordDBE) -> SessionRecord: attributes=dbe.attributes, turn_id=dbe.turn_id, span_id=dbe.span_id, + quarantined_at=dbe.quarantined_at, created_at=dbe.created_at, ) diff --git a/api/oss/src/dbs/postgres/sessions/streams/dao.py b/api/oss/src/dbs/postgres/sessions/streams/dao.py index a01dd8d58af..399db927ad3 100644 --- a/api/oss/src/dbs/postgres/sessions/streams/dao.py +++ b/api/oss/src/dbs/postgres/sessions/streams/dao.py @@ -1,5 +1,5 @@ from datetime import datetime, timezone -from typing import List, Optional +from typing import Any, List, Optional from uuid import UUID import uuid_utils.compat as uuid @@ -48,6 +48,7 @@ references_containment_json, references_to_json, ) +from oss.src.dbs.postgres.sessions.executions.dbes import SessionExecutionDBE from oss.src.dbs.postgres.sessions.streams.dbes import SessionStreamDBE from oss.src.dbs.postgres.sessions.streams.mappings import ( SESSION_ORIGIN_TAG_KEY, @@ -95,6 +96,42 @@ def __init__(self, engine: TransactionsEngine = None): engine = get_transactions_engine() self.engine = engine + async def settle_command( + self, + *, + project_id: UUID, + session_id: str, + turn_id: Optional[str], + mirror_stopped: bool, + transaction: Optional[Any] = None, + ) -> None: + now = datetime.now(timezone.utc) + values = {"stopping_turn_id": None, "updated_at": now} + if mirror_stopped: + values["flags"] = func.coalesce(SessionStreamDBE.flags, cast({}, JSONB)).op( + "||" + )(cast({"is_running": False}, JSONB)) + stmt = sa_update(SessionStreamDBE).where( + SessionStreamDBE.project_id == project_id, + SessionStreamDBE.session_id == session_id, + ) + if turn_id is not None: + stmt = stmt.where( + or_( + SessionStreamDBE.stopping_turn_id == turn_id, + SessionStreamDBE.stopping_turn_id.is_(None), + ) + ) + + async def execute(session: Any) -> None: + await session.execute(stmt.values(**values)) + + if transaction is not None: + await execute(transaction) + return + async with self.engine.session() as session: + await execute(session) + async def create( self, *, @@ -494,6 +531,48 @@ async def update( session_id: str, stream: SessionStreamEdit, ) -> Optional[SessionStream]: + if stream.expected_turn_id is not None: + terminal_execution_exists = ( + select(SessionExecutionDBE.execution_id) + .where( + SessionExecutionDBE.project_id == project_id, + SessionExecutionDBE.session_id == session_id, + SessionExecutionDBE.execution_id == stream.expected_turn_id, + ) + .exists() + ) + values = { + "updated_by_id": user_id, + "updated_at": datetime.now(timezone.utc), + } + if stream.flags is not None: + values["flags"] = stream.flags.model_dump(mode="json") + if stream.turn_id is not None: + values["turn_id"] = stream.turn_id + + async with self.engine.session() as session: + result = await session.execute( + sa_update(SessionStreamDBE) + .where( + SessionStreamDBE.project_id == project_id, + SessionStreamDBE.session_id == session_id, + SessionStreamDBE.deleted_at.is_(None), + SessionStreamDBE.turn_id == stream.expected_turn_id, + SessionStreamDBE.flags.contains( + {"is_alive": True, "is_running": True} + ), + ~terminal_execution_exists, + ) + .values(**values) + .returning(SessionStreamDBE) + .execution_options(synchronize_session=False) + ) + dbe = result.scalar_one_or_none() + await session.commit() + if dbe is None: + return None + return map_stream_dbe_to_dto(stream_dbe=dbe) + async with self.engine.session() as session: stmt = select(SessionStreamDBE).where( SessionStreamDBE.project_id == project_id, diff --git a/api/oss/src/dbs/postgres/sessions/streams/dbes.py b/api/oss/src/dbs/postgres/sessions/streams/dbes.py index 7dd82b6cb7d..47b7afb8e4e 100644 --- a/api/oss/src/dbs/postgres/sessions/streams/dbes.py +++ b/api/oss/src/dbs/postgres/sessions/streams/dbes.py @@ -63,6 +63,24 @@ class SessionStreamDBE( # (resumable, still listed); `archived_at` marks a deliberately-hidden one (restorable). archived_at = Column(TIMESTAMP(timezone=True), nullable=True) + # The execution an accepted Stop is waiting on. Written in the same transaction as the + # command insert, cleared at settlement. Null means nothing is stopping. + # + # A column and not a bit inside `flags`, because `flags` is the Redis mirror and every + # heartbeat rewrites it whole (`streams/service.py`, the unconditional mirror write), so a + # value stored there would be erased on the next beat. `SessionStreamEdit` carries only + # flags/tags/meta/turn_id, so the heartbeat path cannot touch this column by accident. + stopping_turn_id = Column(String, nullable=True) + + # When the row's CURRENT `turn_id` started. It exists for the stale-Stop guard, which has to + # compare a Stop's arrival time with the running execution's start time, and there was + # nowhere to read that: `updated_at` is the heartbeat timestamp and moves every 30 seconds, + # runner-minted turn ids are uuid4 and carry no time, the Redis lock value is a bare turn id + # that a Lua compare reads whole, and the `session_turns` append is fire-and-forget so a + # running turn may have no row. Stamped only when the id actually changes, so the repeated + # heartbeats that restamp the same id never move it. + turn_started_at = Column(TIMESTAMP(timezone=True), nullable=True) + __table_args__ = ( ForeignKeyConstraint( ["project_id"], diff --git a/api/oss/src/dbs/postgres/sessions/streams/mappings.py b/api/oss/src/dbs/postgres/sessions/streams/mappings.py index 2442b3e433f..33d43c872d4 100644 --- a/api/oss/src/dbs/postgres/sessions/streams/mappings.py +++ b/api/oss/src/dbs/postgres/sessions/streams/mappings.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone from typing import Any, Dict, Optional from uuid import UUID @@ -135,6 +136,10 @@ def map_stream_dto_to_dbe_create( tags=stream.tags, meta=stream.meta, turn_id=stream.turn_id, + # A create that already names a turn IS that turn's start. Without this, the first row a + # `_start_turn` writes carries no start time and the stale-Stop guard cannot fire on the + # very first turn of a session. + turn_started_at=datetime.now(timezone.utc) if stream.turn_id else None, references=references_to_json(stream.references), ) @@ -157,6 +162,8 @@ def map_stream_dbe_to_dto( name=stream_dbe.name, description=stream_dbe.description, turn_id=stream_dbe.turn_id, + turn_started_at=stream_dbe.turn_started_at, + stopping_turn_id=stream_dbe.stopping_turn_id, references=references_from_json(stream_dbe.references), archived_at=stream_dbe.archived_at, flags=SessionStreamFlags.model_validate(stream_dbe.flags) @@ -199,6 +206,11 @@ def map_stream_dto_to_dbe_edit( if stream.meta is not None: stream_dbe.meta = stream.meta if stream.turn_id is not None: + # Stamp the start time only when the id actually CHANGES. A heartbeat restamps the same + # id every 30 seconds, and a start time that moved with each beat would make every Stop + # look like it arrived before its own turn began. + if stream_dbe.turn_id != stream.turn_id: + stream_dbe.turn_started_at = datetime.now(timezone.utc) stream_dbe.turn_id = stream.turn_id diff --git a/api/oss/src/dbs/redis/sessions/contract.py b/api/oss/src/dbs/redis/sessions/contract.py index f3c85dc15be..ce308a292ca 100644 --- a/api/oss/src/dbs/redis/sessions/contract.py +++ b/api/oss/src/dbs/redis/sessions/contract.py @@ -8,13 +8,16 @@ alive::session: — session claimed; runner owns it running::session: — a turn is actively executing right now attached::session: — attach lock (client watching live view) - owner::session: — which replica currently owns this session + owner::session: — replica + turn generation owning this session displaced::session: — pub/sub for attach-steal notifications watch::session: — pub/sub for the live relay (SSE watch) superseded::session::turn: — tombstone: this turn lost the nest and is dead forever (API-side only; the runner learns it through `is_current_turn`) + started::session::turn: + — when this turn first took `alive`, in epoch + milliseconds (API-side only; see below) `session_id` is caller-supplied and Postgres uniqueness is (project_id, session_id), so two projects may legitimately hold the same one. The `project_id` segment is the tenant boundary: @@ -45,6 +48,25 @@ # deliberately absent from the shared golden fixture (like `watch_heartbeat_seconds`). SUPERSEDED_TTL_SECONDS: int = env.sessions.superseded_ttl_seconds +# API-side owner payload. The runner reaches affinity through the heartbeat response and never +# reads this Redis value directly. Unit Separator cannot occur in either UUID-like component and +# keeps legacy bare-replica values unambiguous. +OWNER_VALUE_SEPARATOR = "\x1f" + + +def make_owner_value(*, replica_id: str, turn_id: str | None) -> str: + return f"{replica_id}{OWNER_VALUE_SEPARATOR}{turn_id or ''}" + + +def owner_replica_id(owner_value: str) -> str: + return owner_value.split(OWNER_VALUE_SEPARATOR, 1)[0] + + +# The turn-start key lives exactly as long as `alive` can: it answers "did this turn start +# before that cancel arrived?", and a turn with no `alive` cannot be cancelled. Reusing +# ALIVE_TTL keeps the two in step without a new setting. +TURN_STARTED_TTL_SECONDS: int = ALIVE_TTL_SECONDS + # --------------------------------------------------------------------------- # Key builders # --------------------------------------------------------------------------- @@ -70,6 +92,18 @@ def superseded_key(project_id: str, session_id: str, turn_id: str) -> str: return f"superseded:{project_id}:session:{session_id}:turn:{turn_id}" +def turn_started_key(project_id: str, session_id: str, turn_id: str) -> str: + """When this turn first took the alive lock, in epoch milliseconds. + + API-side only, like the tombstone above: the runner never reads it, so it stays out of + the shared golden fixture. It exists because nothing else records a turn's start early + enough to be useful. `session_turns.start_time` is written by the runner some time after + the turn begins, and a browser turn's id is a runner-minted uuid4 + (`services/runner/src/server.ts:188`), so no timestamp can be read out of the id either. + """ + return f"started:{project_id}:session:{session_id}:turn:{turn_id}" + + def displaced_channel(project_id: str, session_id: str) -> str: return f"displaced:{project_id}:session:{session_id}" @@ -123,6 +157,10 @@ def project_watch_channel(project_id: str) -> str: return f"watch:{project_id}:project" +def live_events_channel(project_id: str, session_id: str) -> str: + return f"events:{project_id}:session:{session_id}" + + def make_watch_records_changed_payload(*, session_id: str) -> dict: return {"type": WATCH_EVENT_RECORDS_CHANGED, "session_id": session_id} @@ -140,7 +178,7 @@ def make_watch_entity_changed_payload(*, entity: str, id: str) -> dict: # --------------------------------------------------------------------------- -# Release-if-owner Lua scripts +# Coordination Lua scripts # These are the canonical scripts; both Python and TS implementations must # use the same logic (same key/argv layout; different runtime bindings). # @@ -159,12 +197,142 @@ def make_watch_entity_changed_payload(*, entity: str, id: str) -> dict: end """.strip() -# Atomic claim-or-read: take ownership iff the key is absent or already ours (refreshing the -# TTL), never steal it from another replica. Returns the actual owner after the operation, so -# the caller learns who won without a second racy read. +# Atomically release only the generation the watchdog swept. A new Send or Steer may install +# another turn after the database commit, so every destructive Redis action must compare the +# value captured before the guarded stream update. The swept turn is tombstoned regardless of +# whether its old lock keys still exist. +WATCHDOG_RELEASE_TURN_LUA = """ +-- AGENTA_WATCHDOG_RELEASE_TURN +local expected_turn = ARGV[1] +local expected_owner = ARGV[2] +local superseded_ttl = tonumber(ARGV[3]) +local alive = redis.call('GET', KEYS[1]) or '' +local running = redis.call('GET', KEYS[2]) or '' +local owner = redis.call('GET', KEYS[3]) or '' +local released_alive = 0 +local released_running = 0 +local released_owner = 0 + +if expected_turn ~= '' and alive == expected_turn then + released_alive = redis.call('DEL', KEYS[1]) +end +if expected_turn ~= '' and running == expected_turn then + released_running = redis.call('DEL', KEYS[2]) +end + +local foreign_turn = (alive ~= '' and alive ~= expected_turn) + or (running ~= '' and running ~= expected_turn) +if expected_owner ~= '' and owner == expected_owner and not foreign_turn then + released_owner = redis.call('DEL', KEYS[3]) +end + +if expected_turn ~= '' then + redis.call('SET', KEYS[4], '1', 'EX', superseded_ttl) +end + +return {released_alive, released_running, released_owner} +""".strip() + +ACQUIRE_ALIVE_WITH_START_LUA = """ +-- AGENTA_ACQUIRE_ALIVE_WITH_START +if redis.call('GET', KEYS[1]) then + return 0 +end +local now = redis.call('TIME') +local now_ms = (tonumber(now[1]) * 1000) + math.floor(tonumber(now[2]) / 1000) +redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2]) +if redis.call('SET', KEYS[2], tostring(now_ms), 'NX', 'EX', ARGV[3]) == false then + redis.call('EXPIRE', KEYS[2], ARGV[3]) +end +return 1 +""".strip() + +DISPLACE_TURNS_LUA = """ +-- AGENTA_DISPLACE_TURNS +local alive = redis.call('GET', KEYS[1]) or '' +local running = redis.call('GET', KEYS[2]) or '' +local expected = ARGV[1] +local arrived_at_ms = tonumber(ARGV[2]) +local superseded_prefix = ARGV[3] +local started_prefix = ARGV[4] +local superseded_ttl = tonumber(ARGV[5]) +local running_only = ARGV[6] == '1' + +local function is_mismatch(owner) + if owner == '' then + return false + end + if expected ~= '' then + return owner ~= expected + end + if arrived_at_ms then + local started_at_ms = tonumber(redis.call('GET', started_prefix .. owner)) + return started_at_ms and started_at_ms > arrived_at_ms + end + return false +end + +if not running_only and is_mismatch(alive) then + return {0, alive} +end +if (running_only or running ~= alive) and is_mismatch(running) then + return {0, running} +end + +local seen = {} +local function supersede(turn_id) + if turn_id ~= '' and not seen[turn_id] then + redis.call('SET', superseded_prefix .. turn_id, '1', 'EX', superseded_ttl) + seen[turn_id] = true + end +end + +if not running_only then + supersede(alive) +end +supersede(running) +supersede(expected) +if running_only then + if alive == running and running ~= '' then + redis.call('DEL', KEYS[1]) + end + redis.call('DEL', KEYS[2]) +else + redis.call('DEL', KEYS[1], KEYS[2]) +end +local returned_alive = alive +if running_only then + returned_alive = '' +end +return {1, returned_alive, running, expected} +""".strip() + +# Atomically tombstone a durably stopped execution and release `running` only if that exact +# generation still owns it. `alive` deliberately survives so the native harness stays warm. +RECONCILE_STOPPED_TURN_LUA = """ +-- AGENTA_RECONCILE_STOPPED_TURN +local expected = ARGV[1] +redis.call('SET', KEYS[2], '1', 'EX', tonumber(ARGV[2])) +if redis.call('GET', KEYS[1]) == expected then + return redis.call('DEL', KEYS[1]) +end +return 0 +""".strip() + +# Atomic claim-or-read: take ownership iff the key is absent or already belongs to this replica, +# refreshing both its TTL and turn generation. Returns the full actual value without a second +# racy read. Bare legacy values compare as their own replica id and are upgraded on refresh. CLAIM_OWNER_LUA = """ local current = redis.call('GET', KEYS[1]) -if current == false or current == ARGV[1] then +local separator = string.char(31) +local function replica(value) + local boundary = string.find(value, separator, 1, true) + if boundary then + return string.sub(value, 1, boundary - 1) + end + return value +end +if current == false or replica(current) == replica(ARGV[1]) then redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2]) return ARGV[1] end diff --git a/api/oss/src/dbs/redis/sessions/locks.py b/api/oss/src/dbs/redis/sessions/locks.py index 8da9dcf9914..a3bc900d26b 100644 --- a/api/oss/src/dbs/redis/sessions/locks.py +++ b/api/oss/src/dbs/redis/sessions/locks.py @@ -6,24 +6,32 @@ """ import json -from typing import Optional +from typing import List, Optional, Tuple from oss.src.dbs.redis.shared.engine import LockEngine from oss.src.dbs.redis.sessions.contract import ( ALIVE_TTL_SECONDS, + ACQUIRE_ALIVE_WITH_START_LUA, ATTACHED_TTL_SECONDS, CLAIM_OWNER_LUA, + DISPLACE_TURNS_LUA, OWNER_TTL_SECONDS, + RECONCILE_STOPPED_TURN_LUA, RELEASE_IF_OWNER_LUA, RUNNING_TTL_SECONDS, SUPERSEDED_TTL_SECONDS, + TURN_STARTED_TTL_SECONDS, + WATCHDOG_RELEASE_TURN_LUA, alive_key, attached_key, displaced_channel, make_displacement_payload, + make_owner_value, + owner_replica_id, owner_key, running_key, superseded_key, + turn_started_key, validate_session_id, # noqa: F401 — re-exported for callers that import from locks ) @@ -54,6 +62,26 @@ async def acquire_alive( return result is not None +async def acquire_alive_with_start( + engine: LockEngine, + *, + project_id: str, + session_id: str, + turn_id: str, +) -> bool: + """Atomically acquire `alive` and record its first start on the Redis clock.""" + result = await engine.eval( + ACQUIRE_ALIVE_WITH_START_LUA, + 2, + alive_key(project_id, session_id).encode(), + turn_started_key(project_id, session_id, turn_id).encode(), + turn_id.encode(), + ALIVE_TTL_SECONDS, + TURN_STARTED_TTL_SECONDS, + ) + return result == 1 + + async def refresh_alive( engine: LockEngine, *, @@ -154,6 +182,155 @@ async def is_turn_superseded( return True +async def release_watchdog_turn( + engine: LockEngine, + *, + project_id: str, + session_id: str, + turn_id: Optional[str], + owner_value: Optional[str], +) -> Tuple[bool, bool, bool]: + """Atomically release only the swept turn and its observed replica owner.""" + result = await engine.eval( + WATCHDOG_RELEASE_TURN_LUA, + 4, + alive_key(project_id, session_id).encode(), + running_key(project_id, session_id).encode(), + owner_key(project_id, session_id).encode(), + superseded_key(project_id, session_id, turn_id or "").encode(), + (turn_id or "").encode(), + (owner_value or "").encode(), + SUPERSEDED_TTL_SECONDS, + ) + return bool(int(result[0])), bool(int(result[1])), bool(int(result[2])) + + +# --------------------------------------------------------------------------- +# Turn start times — "when did this turn first take the session?" +# +# A cancel that is applied after the turn it meant has ended tombstones whichever turn holds +# the nest, which can be the NEXT turn (the stop-then-send race behind #6417). Refusing that +# needs one thing the coordination plane never recorded: when the holding turn started. It +# cannot be derived. `session_turns.start_time` is written by the runner after the fact, and a +# browser turn's id is a runner-minted uuid4, so it carries no time. +# --------------------------------------------------------------------------- + + +async def record_turn_start( + engine: LockEngine, + *, + project_id: str, + session_id: str, + turn_id: str, + started_at_ms: Optional[int] = None, +) -> int: + """Record this turn's start once, then keep the record alive for as long as `alive` is. + + Write-once (nx): a turn that re-takes its own lock after a raced beat keeps its FIRST + start time, which is the one the guard must compare against. Returns the recorded start, + which is the stored one when a record already exists. + """ + key = turn_started_key(project_id, session_id, turn_id) + now_ms = await redis_time_ms(engine) if started_at_ms is None else started_at_ms + written = await engine.set( + key, + str(now_ms).encode(), + nx=True, + ex=TURN_STARTED_TTL_SECONDS, + ) + if written is not None: + return now_ms + current = await engine.get(key) + await engine.expire(key, TURN_STARTED_TTL_SECONDS) + try: + return int(current.decode()) if current else now_ms + except ValueError: + return now_ms + + +async def redis_time_ms(engine: LockEngine) -> int: + """Read the shared Redis clock in epoch milliseconds.""" + seconds, microseconds = await engine.time() + return int(seconds) * 1000 + int(microseconds) // 1000 + + +async def displace_turns( + engine: LockEngine, + *, + project_id: str, + session_id: str, + expected_turn_id: Optional[str] = None, + arrived_at_ms: Optional[int] = None, + running_only: bool = False, +) -> Tuple[bool, Optional[str], List[str]]: + """Atomically validate, tombstone, and clear the alive/running owners.""" + result = await engine.eval( + DISPLACE_TURNS_LUA, + 2, + alive_key(project_id, session_id).encode(), + running_key(project_id, session_id).encode(), + (expected_turn_id or "").encode(), + "" if arrived_at_ms is None else str(arrived_at_ms), + superseded_key(project_id, session_id, "").encode(), + turn_started_key(project_id, session_id, "").encode(), + SUPERSEDED_TTL_SECONDS, + "1" if running_only else "0", + ) + + def _decode(value) -> str: + return value.decode() if isinstance(value, (bytes, bytearray)) else str(value) + + accepted = bool(result) and int(result[0]) == 1 + if not accepted: + return False, _decode(result[1]) if len(result) > 1 else None, [] + turn_ids = list( + dict.fromkeys(_decode(value) for value in result[1:] if _decode(value)) + ) + return True, None, turn_ids + + +async def reconcile_stopped_turn( + engine: LockEngine, + *, + project_id: str, + session_id: str, + turn_id: str, +) -> bool: + """Atomically tombstone a stopped turn and release only its `running` generation.""" + result = await engine.eval( + RECONCILE_STOPPED_TURN_LUA, + 2, + running_key(project_id, session_id).encode(), + superseded_key(project_id, session_id, turn_id).encode(), + turn_id.encode(), + SUPERSEDED_TTL_SECONDS, + ) + return result == 1 + + +async def get_turn_start( + engine: LockEngine, + *, + project_id: str, + session_id: str, + turn_id: str, +) -> Optional[int]: + """This turn's start in epoch milliseconds, or None when nothing recorded one. + + None means "unknown", never "old". Every caller must treat it as unknown and fall back to + the behavior it had before this key existed: a turn from before this code shipped, or one + whose record outlived its TTL, must not become uncancellable. + """ + key = turn_started_key(project_id, session_id, turn_id) + current = await engine.get(key) + if current is None: + return None + try: + return int(current.decode()) + except ValueError: + return None + + # --------------------------------------------------------------------------- # Running lock — "a turn is actively executing right now" # Nested under alive: a session can be alive-but-idle (running absent) between turns. @@ -321,52 +498,108 @@ async def get_owner( session_id: str, ) -> Optional[str]: """Return the replica id currently owning this session, or None.""" + current = await get_owner_value( + engine, project_id=project_id, session_id=session_id + ) + return owner_replica_id(current) if current else None + + +async def get_owner_value( + engine: LockEngine, + *, + project_id: str, + session_id: str, +) -> Optional[str]: + """Return the full replica + turn-generation owner value, or None.""" key = owner_key(project_id, session_id) current = await engine.get(key) return current.decode() if current else None -async def claim_owner( +async def claim_owner_value( engine: LockEngine, *, project_id: str, session_id: str, replica_id: str, + turn_id: Optional[str] = None, ) -> str: - """Atomically claim ownership iff unowned or already ours, and return the actual owner. + """Atomically claim ownership and return the full observed owner generation. Never steals from a live different owner: if another replica holds it, its id is - returned so the caller can refuse to serve a local session on the wrong host. + returned with its turn generation so a later compare-and-delete cannot clear a refresh. """ key = owner_key(project_id, session_id) + owner_value = make_owner_value(replica_id=replica_id, turn_id=turn_id) result = await engine.eval( CLAIM_OWNER_LUA, 1, key.encode(), - replica_id.encode(), + owner_value.encode(), str(OWNER_TTL_SECONDS).encode(), ) - return result.decode() if isinstance(result, (bytes, bytearray)) else str(result) + actual = result.decode() if isinstance(result, (bytes, bytearray)) else str(result) + return actual -async def clear_owner( +async def claim_owner( engine: LockEngine, *, project_id: str, session_id: str, replica_id: str, + turn_id: Optional[str] = None, +) -> str: + """Claim ownership and return the actual owner's replica id.""" + actual = await claim_owner_value( + engine, + project_id=project_id, + session_id=session_id, + replica_id=replica_id, + turn_id=turn_id, + ) + return owner_replica_id(actual) + + +async def release_owner_value( + engine: LockEngine, + *, + project_id: str, + session_id: str, + owner_value: str, ) -> bool: - """Remove the owner key if replica_id is still the owner.""" + """Remove the owner key only if its full replica + turn generation still matches.""" key = owner_key(project_id, session_id) result = await engine.eval( RELEASE_IF_OWNER_LUA, 1, key.encode(), - replica_id.encode(), + owner_value.encode(), ) return result == 1 +async def clear_owner( + engine: LockEngine, + *, + project_id: str, + session_id: str, + replica_id: str, +) -> bool: + """Remove the owner key if replica_id is still the owner.""" + owner_value = await get_owner_value( + engine, project_id=project_id, session_id=session_id + ) + if owner_value is None or owner_replica_id(owner_value) != replica_id: + return False + return await release_owner_value( + engine, + project_id=project_id, + session_id=session_id, + owner_value=owner_value, + ) + + async def force_clear_owner( engine: LockEngine, *, @@ -382,7 +615,7 @@ async def force_clear_owner( key = owner_key(project_id, session_id) current = await engine.get(key) await engine.delete(key) - return current.decode() if current else None + return owner_replica_id(current.decode()) if current else None # --------------------------------------------------------------------------- diff --git a/api/oss/src/middlewares/auth.py b/api/oss/src/middlewares/auth.py index 302bd481790..f9f3cc930ab 100644 --- a/api/oss/src/middlewares/auth.py +++ b/api/oss/src/middlewares/auth.py @@ -71,6 +71,12 @@ "/api/tools/connections/callback", "/preview/tools/connections/callback", "/api/preview/tools/connections/callback", + # SESSIONS CONTROL — the runner reports a command's outcome with the shared runner token, + # not a project credential: it holds none for a command it was handed. The route checks the + # token itself and resolves the project from the command id, so this exemption widens no + # tenant boundary. + "/sessions/control/commands/", + "/api/sessions/control/commands/", # TRIGGERS — inbound provider events arrive from Composio with no auth token "/triggers/composio/events/", "/api/triggers/composio/events/", diff --git a/api/oss/src/routers/user_profile.py b/api/oss/src/routers/user_profile.py index e8701acfc8c..ce382dc7702 100644 --- a/api/oss/src/routers/user_profile.py +++ b/api/oss/src/routers/user_profile.py @@ -44,9 +44,11 @@ async def user_profile(request: Request): user = await db_manager.get_user_with_id(user_id=request.state.user_id) - assert user is not None, ( - "User not found. Please ensure that the user_id is specified correctly." - ) + if user is None: + raise HTTPException( + status_code=400, + detail="User not found. Please ensure that the user_id is specified correctly.", + ) # Fall back to created_at if no update has occurred updated_at = user.updated_at or user.created_at diff --git a/api/oss/src/services/db_manager.py b/api/oss/src/services/db_manager.py index c1f04ac5a9c..c9c336e8a1b 100644 --- a/api/oss/src/services/db_manager.py +++ b/api/oss/src/services/db_manager.py @@ -122,7 +122,8 @@ async def get_project_by_workspace( ) -> ProjectDB: """Get the (default) project for a workspace.""" - assert workspace_id is not None, "Workspace ID is required to retrieve project" + if workspace_id is None: + raise ValueError("Workspace ID is required to retrieve project") engine = get_transactions_engine() async with engine.session() as session: diff --git a/api/oss/src/tasks/asyncio/sessions/live_relay_worker.py b/api/oss/src/tasks/asyncio/sessions/live_relay_worker.py new file mode 100644 index 00000000000..ba9b55f1f7c --- /dev/null +++ b/api/oss/src/tasks/asyncio/sessions/live_relay_worker.py @@ -0,0 +1,70 @@ +from datetime import datetime, timezone +from typing import Dict, List, Tuple + +from orjson import dumps + +from oss.src.core.sessions.records.streaming import ( + LiveFrameMessage, + deserialize_live_relay_message, +) +from oss.src.dbs.redis.sessions.contract import live_events_channel +from oss.src.tasks.asyncio.shared.consumer import StreamConsumer +from oss.src.utils.env import env +from oss.src.utils.logging import get_module_logger + +log = get_module_logger(__name__) + + +class LiveRelayWorker(StreamConsumer): + log_prefix = "[SESSION-LIVE-RELAY]" + + async def create_consumer_group(self): + try: + await self.redis.xgroup_create( + name=self.stream_name, + groupname=self.consumer_group, + id="$", + mkstream=True, + ) + except Exception as exc: + if "BUSYGROUP" not in str(exc): + raise + + async def process_batch( + self, + batch: List[Tuple[bytes, Dict[bytes, bytes]]], + ) -> Tuple[int, List[bytes]]: + processed_ids: List[bytes] = [] + published = 0 + cutoff = ( + datetime.now(timezone.utc).timestamp() + - env.sessions.live_frame_max_age_seconds + ) + + for msg_id, data in batch: + processed_ids.append(msg_id) + try: + message = deserialize_live_relay_message(payload=data[b"data"]) + envelope = ( + message.frame + if isinstance(message, LiveFrameMessage) + else message.event + ) + created_at = envelope.created_at + if created_at.tzinfo is None: + created_at = created_at.replace(tzinfo=timezone.utc) + if created_at.timestamp() < cutoff: + continue + await self.redis.publish( + live_events_channel(str(message.project_id), envelope.session_id), + dumps(envelope.model_dump(mode="json")), + ) + published += 1 + except Exception: + log.warning( + "[SESSION-LIVE-RELAY] Frame relay failed", + msg_id=repr(msg_id), + exc_info=True, + ) + + return published, processed_ids diff --git a/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py b/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py index 40f97730558..72d2e531516 100644 --- a/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py +++ b/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py @@ -1,52 +1,359 @@ -"""Orphan sweep — SCA-6. +"""Execution watchdog (formerly the orphan sweep) — SCA-6. -Periodically scans session_streams for rows whose mirror says is_alive but whose -heartbeat (updated_at) is stale — the owning runner died mid-turn and its Redis -alive lock has expired. Marks each orphan ended + collapses its flags so the -sandbox can be reaped. +Every accepted execution must reach exactly one durable terminal outcome. The runner writes +that outcome on every path it controls, but it cannot write one when it is gone: its container +restarts, its process dies, or its `run()` never returns. The session then keeps the Redis +`alive`/`running` nest of a turn nobody is running, the transcript stops mid-turn, and the +session refuses a new message until a threshold far away expires. + +This pass closes that hole. It scans `session_streams` for rows whose mirror still says +`is_alive` but whose heartbeat (`updated_at`) is stale, and for each one it: + +1. compare-and-sets the stale stream generation so a renewed turn cannot be settled; +2. settles the execution and writes the terminal records the dead runner owed; +3. clears the Redis nest and tombstones the turn, so a late beat cannot re-nest it; +4. publishes the watch notification, so an open browser refreshes without a reload. + +Steps 1 and 2 share one Postgres transaction. Terminal records publish before its commit with +stable ids, so a crash rolls the stream and execution changes back and the next pass safely +re-publishes the same records. + +Two thresholds, not one. A RUNNING row beats every 30 seconds, so a short silence means the +runner died. An ALIVE-but-idle row is a different animal: between turns, and while a turn is +parked awaiting a human, the runner sends a final beat with `is_running: false` and then stops +beating on purpose. That state is resumable, so it is never given a terminal record here. +Both thresholds are settings; see `SessionWatchdogConfig` in `oss/src/utils/env.py`. + +WHAT THIS PASS CANNOT SEE, and why the runner needs its own detector. This scan keys off +heartbeat age, and a turn whose SANDBOX died keeps beating perfectly well: the runner is +healthy, only the machine under it is gone. Such a row never becomes stale and is invisible +here for ever. That case is issue #6418 and it is closed on the runner side, by the sandbox +liveness probe in `services/runner/src/engines/sandbox_agent/sandbox-liveness.ts`. This pass +covers the complementary case, where the RUNNER is what disappeared and nothing on that side +can write anything at all. Called from the FastAPI lifespan; runs as a background asyncio task. """ import asyncio from datetime import datetime, timezone, timedelta +from typing import Any, Dict, List, Optional, Sequence, Set, Tuple +from uuid import UUID, uuid5, NAMESPACE_URL +from oss.src.utils.env import env from oss.src.utils.logging import get_module_logger +from oss.src.dbs.postgres.sessions.executions.dbes import SessionExecutionDBE from oss.src.dbs.postgres.shared.engine import TransactionsEngine from oss.src.dbs.postgres.sessions.streams.dbes import SessionStreamDBE +from oss.src.core.sessions.records.dtos import ( + RECORD_SETTLED_BY_ATTRIBUTE, + SETTLED_BY_WATCHDOG, + TERMINAL_RECORD_TYPE, + SessionRecordEvent, +) +from oss.src.core.sessions.records.service import RecordsService +from oss.src.core.sessions.records.streaming import publish_record from oss.src.core.sessions.streams.dtos import ( SessionStreamFlags, ) +from oss.src.core.sessions.watch.interfaces import SessionsWatchPublisherInterface +from oss.src.dbs.redis.sessions.contract import WATCH_LIFECYCLE_ENDED from oss.src.dbs.redis.shared.engine import LockEngine from oss.src.dbs.redis.sessions.locks import ( - force_cancel_alive, clear_running, + force_cancel_alive, force_clear_owner, + get_owner_value, mark_turn_superseded, + release_watchdog_turn, ) -from sqlalchemy import and_, func, not_, or_, select +from sqlalchemy import and_, func, not_, or_, select, tuple_, update as sa_update log = get_module_logger(__name__) -# A RUNNING stream whose heartbeat (updated_at) is older than this is orphaned: a live turn -# beats every 30s, so this much silence means the owning runner died. -ORPHAN_THRESHOLD_SECONDS: int = 300 # 5 minutes +# A RUNNING stream whose heartbeat is older than this is lost. +# +# The rule is HEARTBEAT AGE, deliberately, and not the Redis lease. The `alive` and `running` +# keys carry a one-hour TTL (`env.sessions.alive_ttl_seconds`), so waiting for a lease to +# expire would mean waiting an hour. The runner beats every 30 seconds and the beat is +# mirrored onto `session_streams.updated_at`, so the age of that column is what actually says +# whether anyone is still running the turn. Durable Stop uses three missed beats (90 seconds); +# flag-off deployments retain the pre-milestone ten-beat threshold (300 seconds). +# +# Raise AGENTA_SESSIONS_WATCHDOG_STALE_HEARTBEAT_SECONDS if healthy turns are being settled. +ORPHAN_THRESHOLD_SECONDS: int = env.agenta.sessions.watchdog.stale_heartbeat_seconds -# Alive-but-idle rows (between turns, or parked awaiting approval) get a longer grace: the -# runner stops beating while a turn is parked, and it keeps that sandbox warm for the -# approval TTL (30 min). Sweeping those at 5 min would declare a resumable session dead. -IDLE_THRESHOLD_SECONDS: int = 1800 # 30 minutes +# Alive-but-NOT-running rows are RECLAIMED on a different, much longer clock. Between turns, and +# while a turn is parked awaiting a human, the runner sends one final beat with `is_running: +# false` and then stops beating on purpose; that state is resumable, so collapsing it is keyed +# to the 30-minute approval TTL rather than to three missed beats. +# +# It does NOT decide whether such a row owes its turn an ending. It used to, on the premise that +# a not-running row's last turn had already reached a terminal record — a premise a durable Stop +# broke, because settlement clears `is_running` before the runner has written that record. The +# ending is now decided by asking the records plane on the configured stale-heartbeat clock. See +# the second selection in `run_orphan_sweep`. +IDLE_THRESHOLD_SECONDS: int = env.agenta.sessions.watchdog.idle_grace_seconds -# How often the sweep runs. -SWEEP_INTERVAL_SECONDS: int = 60 +# How often the watchdog runs. +SWEEP_INTERVAL_SECONDS: int = env.agenta.sessions.watchdog.interval_seconds # Rows swept per pass. A backlog drains over successive passes instead of one huge commit. -SWEEP_BATCH_SIZE: int = 500 +SWEEP_BATCH_SIZE: int = env.agenta.sessions.watchdog.batch_size + +# The error class the watchdog stamps on the turn it settles. One of the `RunErrorCode` +# values in services/runner/src/engines/sandbox_agent/errors.ts; the client reads it to offer +# a retry rather than parsing the message. +LOST_ERROR_CODE = "execution_lost" + +# The line the user reads in place of the answer the dead runner never gave. Identical to +# `EXECUTION_LOST_MESSAGE` in services/runner/src/engines/sandbox_agent/errors.ts, which the +# runner writes for the same class when a turn will not unwind: one outcome must not reach +# the user in two different wordings depending on which side noticed it. +LOST_ERROR_MESSAGE = "The agent stopped responding and the run was closed. Send the message again to retry." + +# Records are attributed to the agent, matching every record the runner writes for a turn. +RECORD_SOURCE_AGENT = "agent" + +# Both records carry this marker, and it is the ONLY thing that distinguishes the watchdog's +# ending from a runner's. That matters twice at ingest: a record arriving for a turn this +# marker has already closed is quarantined rather than appended, and the watchdog's own two +# records are exempt from that rule so a redelivery cannot quarantine the ending itself. See +# `RecordsService.append_many`. +SETTLED_BY = {RECORD_SETTLED_BY_ATTRIBUTE: SETTLED_BY_WATCHDOG} + + +def _watchdog_record_id( + *, + project_id: str, + session_id: str, + turn_id: str, + suffix: str, +) -> UUID: + """A stable id per (turn, record), so re-running the watchdog upserts instead of appending. + + The ingest path is `INSERT ... ON CONFLICT (project_id, record_id) DO UPDATE`, so two + passes — or two API replicas sweeping at once — write the same two rows, never four. + """ + return uuid5( + NAMESPACE_URL, + f"agenta:sessions:watchdog:{project_id}:{session_id}:{turn_id}:{suffix}", + ) + + +def _lost_turn_records( + *, + project_id: UUID, + session_id: str, + turn_id: str, + now: datetime, +) -> List[SessionRecordEvent]: + """The two records a runner writes when a turn ends badly, written on its behalf. + + Shape and order mirror `run-turn.ts`'s error path exactly: an `error` event carrying the + class a client can act on, then the terminal `done`. A lone `done` would render as a + clean finish, which is the opposite of what happened. + + The two are ordered explicitly. The transcript sorts on (`timestamp`, `created_at`, + `record_index`), and one write batch shares a single `created_at`, so two records stamped + at the same instant with no index would come back in whatever order Postgres chose. A + `done` read before its `error` closes the turn early, and the failure then renders as a + stray bubble beside a turn that claims it got no response. + """ + project = str(project_id) + + return [ + SessionRecordEvent( + project_id=project_id, + session_id=session_id, + record_id=_watchdog_record_id( + project_id=project, + session_id=session_id, + turn_id=turn_id, + suffix="error", + ), + timestamp=now, + record_index=0, + record_type="error", + record_source=RECORD_SOURCE_AGENT, + attributes={ + "type": "error", + "message": LOST_ERROR_MESSAGE, + "code": LOST_ERROR_CODE, + **SETTLED_BY, + }, + turn_id=turn_id, + ), + SessionRecordEvent( + project_id=project_id, + session_id=session_id, + record_id=_watchdog_record_id( + project_id=project, + session_id=session_id, + turn_id=turn_id, + suffix="done", + ), + timestamp=now + timedelta(milliseconds=1), + record_index=1, + record_type="done", + record_source=RECORD_SOURCE_AGENT, + attributes={"type": "done", **SETTLED_BY}, + turn_id=turn_id, + ), + ] + + +def _stopped_turn_records( + *, + project_id: UUID, + session_id: str, + turn_id: str, + now: datetime, +) -> List[SessionRecordEvent]: + return [ + SessionRecordEvent( + project_id=project_id, + session_id=session_id, + record_id=_watchdog_record_id( + project_id=str(project_id), + session_id=session_id, + turn_id=turn_id, + suffix="done", + ), + timestamp=now, + record_index=0, + record_type=TERMINAL_RECORD_TYPE, + record_source=RECORD_SOURCE_AGENT, + attributes={"type": "done", "stopReason": "cancelled", **SETTLED_BY}, + turn_id=turn_id, + ) + ] + + +async def _unsettled_turns( + *, + records_service: Optional[RecordsService], + candidates: Sequence[Tuple[UUID, str, str]], +) -> Tuple[ + Set[Tuple[UUID, str, str]], + Set[Tuple[UUID, str, str]], + Set[Tuple[UUID, str, str]], +]: + """Partition candidates into turns without and with a terminal record. + + A runner can die AFTER writing its outcome but BEFORE its final `is_running=false` + heartbeat lands — the last beat is best-effort and untimed. Such a turn is already + settled; the row still needs collapsing, but writing a second, contradictory ending + would corrupt the transcript. One query per project, never one per candidate. + """ + if not candidates: + return set(), set(), set() + + if records_service is None: + # No records plane wired (minimal test compositions): settle the row, write nothing. + return set(), set(), set() + + by_project: Dict[UUID, List[Tuple[str, str]]] = {} + for project_id, session_id, turn_id in candidates: + by_project.setdefault(project_id, []).append((session_id, turn_id)) + + unsettled: Set[Tuple[UUID, str, str]] = set() + ended: Set[Tuple[UUID, str, str]] = set() + deferred: Set[Tuple[UUID, str, str]] = set() + for project_id, keys in by_project.items(): + try: + settled = await records_service.settled_turns( + project_id=project_id, keys=keys + ) + except Exception: + log.warning( + "watchdog: terminal-record lookup failed; deferring project candidates", + project_id=str(project_id), + exc_info=True, + ) + deferred.update( + (project_id, session_id, turn_id) for session_id, turn_id in keys + ) + continue + + for session_id, turn_id in keys: + key = (project_id, session_id, turn_id) + if (session_id, turn_id) in settled: + ended.add(key) + else: + unsettled.add(key) + + return unsettled, ended, deferred + + +async def _mark_endings_written( + *, + session: Any, + keys: Set[Tuple[UUID, str, str]], + written_at: datetime, +) -> None: + if not keys: + return + await session.execute( + sa_update(SessionExecutionDBE) + .where( + tuple_( + SessionExecutionDBE.project_id, + SessionExecutionDBE.session_id, + SessionExecutionDBE.execution_id, + ).in_(keys), + SessionExecutionDBE.ending_written_at.is_(None), + ) + .values(ending_written_at=written_at) + ) + + +async def _settle_abandoned_commands( + commands_service: Optional[Any], + now: datetime, +) -> int: + """Settle every Stop command whose runner accepted it and never reported. + + Delegates the decision to the commands plane, which owns the command state machine, so + this sweep and a runner report can never write two different terminal outcomes for the + same command. Never raises: an abandoned command must not stop the pass that settles + executions. + """ + if commands_service is None: + return 0 + try: + return await commands_service.settle_abandoned_commands(now=now) + except Exception: + log.warning("watchdog: failed to settle abandoned commands", exc_info=True) + return 0 + +async def _repair_terminal_redis(commands_service: Optional[Any]) -> int: + if commands_service is None: + return 0 + try: + return await commands_service.repair_terminal_redis() + except Exception: + log.warning("watchdog: failed to repair terminal Redis state", exc_info=True) + return 0 -async def run_orphan_sweep(engine: TransactionsEngine, lock_engine: LockEngine) -> None: - """Single sweep pass: mark stale is_alive rows as ended.""" + +async def run_orphan_sweep( + engine: TransactionsEngine, + lock_engine: LockEngine, + *, + records_service: Optional[RecordsService] = None, + watch_publisher: Optional[SessionsWatchPublisherInterface] = None, + commands_service: Optional[Any] = None, + publish: Any = publish_record, +) -> None: + """Single watchdog pass: settle every stale is_alive row, then every abandoned command. + + `commands_service` is a `SessionCommandsService`. It is optional and typed loosely so this + module keeps no import edge on the commands plane, which would be a cycle. When it is + given, this pass is also the one writer that settles a Stop the runner never reported. + """ now_utc = datetime.now(timezone.utc) threshold = now_utc - timedelta(seconds=ORPHAN_THRESHOLD_SECONDS) idle_threshold = now_utc - timedelta(seconds=IDLE_THRESHOLD_SECONDS) @@ -72,55 +379,541 @@ async def run_orphan_sweep(engine: TransactionsEngine, lock_engine: LockEngine) result = await session.execute(stmt) orphans = result.scalars().all() - if not orphans: + # Capture what the collapse and its Redis/watch follow-up need as plain values NOW, + # before any nested `engine.session()` in this pass runs. The records lookup and the + # command settlement below each open `engine.session()`, which returns the SAME + # current-task-scoped session and, in its `finally`, calls `session.close()` (see + # `TransactionsEngine.session`). That close detaches every ORM row loaded here, so a + # later `row.flags = ...` mutation is tracked by no session and is silently dropped at + # commit -- the flags UPDATE is never emitted, while a Core UPDATE (the command + # settle's `stopping_turn_id`) still lands. That is the finding-7 bug: the row kept + # `is_running: true` after the sweep. The collapse below writes through a Core UPDATE + # keyed by these ids, and the Redis/watch steps read these tuples, never the rows. + orphan_rows: List[Tuple[UUID, UUID, str, Optional[str], Optional[datetime]]] = [ + ( + row.id, + row.project_id, + row.session_id, + str(row.turn_id) if row.turn_id else None, + row.updated_at, + ) + for row in orphans + ] + + # Current stopped turns get their missing ending on the short clock without collapsing + # a parked session, whose reclamation stays on the longer idle grace. + ending_stmt = ( + select(SessionStreamDBE) + .where( + SessionStreamDBE.deleted_at.is_(None), + SessionStreamDBE.flags.contains({"is_alive": True}), + not_(is_running), + SessionStreamDBE.turn_id.is_not(None), + last_beat < threshold, + ) + .limit(SWEEP_BATCH_SIZE) + ) + ending_only = (await session.execute(ending_stmt)).scalars().all() + + # A stream row names only its current turn. Older terminal executions must remain + # visible after that row advances, or their missing transcript ending is permanent. + terminal_executions = [] + if records_service is not None: + terminal_stmt = ( + select(SessionExecutionDBE) + .where( + SessionExecutionDBE.terminal_outcome.in_(("stopped", "lost")), + SessionExecutionDBE.ending_written_at.is_(None), + SessionExecutionDBE.settled_at < threshold, + ) + .order_by(SessionExecutionDBE.settled_at.desc()) + .limit(SWEEP_BATCH_SIZE) + ) + terminal_executions = (await session.execute(terminal_stmt)).scalars().all() + + # A row that claimed a RUNNING turn owes that turn an ending. So does a stopped row + # whose runner never wrote one; see the note above. + seen: Set[Tuple[UUID, str, str]] = set() + claimed: List[Tuple[UUID, str, str]] = [] + for row in [*orphans, *ending_only]: + if not row.turn_id: + continue + key = (row.project_id, row.session_id, str(row.turn_id)) + if key in seen: + continue + seen.add(key) + claimed.append(key) + terminal_turns: Set[Tuple[UUID, str, str]] = set() + terminal_outcomes: Dict[Tuple[UUID, str, str], str] = {} + for execution in terminal_executions: + key = ( + execution.project_id, + execution.session_id, + execution.execution_id, + ) + terminal_turns.add(key) + terminal_outcomes[key] = execution.terminal_outcome + if key in seen: + continue + seen.add(key) + claimed.append(key) + unsettled, ended, deferred = await _unsettled_turns( + records_service=records_service, candidates=claimed + ) + if deferred: + orphan_rows = [ + row + for row in orphan_rows + if row[3] is None or (row[1], row[2], row[3]) not in deferred + ] + await _mark_endings_written( + session=session, + keys=ended & terminal_turns, + written_at=now_utc, + ) + + if not orphan_rows and not unsettled: + # No stale row and nothing owed an ending, but a command can still be abandoned: + # its execution may have ended normally between the claim and the report. + if not deferred: + await _settle_abandoned_commands(commands_service, now_utc) + await _repair_terminal_redis(commands_service) return now = datetime.now(timezone.utc) - for row in orphans: - row.flags = SessionStreamFlags( - is_alive=False, is_running=False, is_attached=False - ).model_dump(mode="json") - row.updated_at = now + + # Capture the affinity generation before the guarded database update. Redis cleanup + # compares this replica and the swept turn atomically after commit, so a new Send or + # Steer generation cannot be deleted. + observed_owners: Dict[Tuple[UUID, str, str], Optional[str]] = {} + owner_keys = { + (project_id, session_id, turn_id) + for project_id, session_id, turn_id in unsettled + } + owner_keys.update( + (project_id, session_id, turn_id) + for _row_id, project_id, session_id, turn_id, _updated_at in orphan_rows + if turn_id is not None + ) + for project_id, session_id, turn_id in sorted( + owner_keys, key=lambda key: key[1] + ): + observed_owners[(project_id, session_id, turn_id)] = await get_owner_value( + lock_engine, + project_id=str(project_id), + session_id=session_id, + ) + + # Win the stale stream generation before settling its execution or publishing records. + # The update and execution settlement share this transaction; an exception rolls both + # back, while record ids make a publish-before-commit retry idempotent. + collapsed_flags = SessionStreamFlags( + is_alive=False, is_running=False, is_attached=False + ).model_dump(mode="json") + collapsed_rows: List[ + Tuple[UUID, UUID, str, Optional[str], Optional[datetime]] + ] = [] + skipped_orphan_turns: Set[Tuple[UUID, str, str]] = set() + for ( + row_id, + project_uuid, + session_id, + turn_id, + observed_updated_at, + ) in orphan_rows: + conditions = [ + SessionStreamDBE.id == row_id, + SessionStreamDBE.project_id == project_uuid, + SessionStreamDBE.session_id == session_id, + SessionStreamDBE.deleted_at.is_(None), + ( + SessionStreamDBE.turn_id == turn_id + if turn_id is not None + else SessionStreamDBE.turn_id.is_(None) + ), + ( + SessionStreamDBE.updated_at == observed_updated_at + if observed_updated_at is not None + else SessionStreamDBE.updated_at.is_(None) + ), + ] + result = await session.execute( + sa_update(SessionStreamDBE) + .where(*conditions) + .values(flags=collapsed_flags, updated_at=now) + .execution_options(synchronize_session=False) + ) + if result.rowcount != 1: + if turn_id is not None: + skipped_orphan_turns.add((project_uuid, session_id, turn_id)) + log.info( + "watchdog: orphan stream advanced during sweep; leaving it untouched", + session_id=session_id, + turn_id=turn_id, + ) + continue + collapsed_rows.append( + (row_id, project_uuid, session_id, turn_id, observed_updated_at) + ) log.warning( - "orphan_sweep: marking session_stream ended", - extra={"session_id": row.session_id, "stream_id": str(row.id)}, + "watchdog: settled a session_stream whose runner went silent", + extra={ + "session_id": session_id, + "stream_id": str(row_id), + "turn_id": turn_id, + "lost": (project_uuid, session_id, turn_id) in unsettled, + }, ) - await session.commit() + terminal_winners: Set[Tuple[UUID, str, str]] = set() + endings_written: Set[Tuple[UUID, str, str]] = set() + for project_id, session_id, turn_id in sorted(unsettled, key=lambda t: t[1]): + key = (project_id, session_id, turn_id) + if key in skipped_orphan_turns: + continue + if ( + key not in terminal_turns + and env.agenta.sessions.durable_stop + and commands_service is not None + and not await commands_service.settle_execution_lost( + project_id=project_id, + session_id=session_id, + execution_id=turn_id, + settled_at=now, + transaction=session, + ) + ): + continue + terminal_winners.add(key) + record_events = ( + _stopped_turn_records( + project_id=project_id, + session_id=session_id, + turn_id=turn_id, + now=now, + ) + if terminal_outcomes.get(key) == "stopped" + else _lost_turn_records( + project_id=project_id, + session_id=session_id, + turn_id=turn_id, + now=now, + ) + ) + for record_event in record_events: + published = False + try: + published = await publish( + project_id=project_id, record_event=record_event + ) + except Exception: + log.warning( + "watchdog: failed to publish a terminal record", + project_id=str(project_id), + session_id=session_id, + turn_id=turn_id, + exc_info=True, + ) + if published and record_event.record_type == TERMINAL_RECORD_TYPE: + endings_written.add(key) + + await _mark_endings_written( + session=session, + keys=endings_written, + written_at=now, + ) + + unsettled = terminal_winners + + # A lost turn whose stream row the went-silent collapse did NOT touch must still be + # brought to rest here, in this same pass, or the SEND gate refuses the next message + # until the runner returns -- which, for a lost turn, may be never. The RFC's rule is + # that the settlement writes the ending, clears `is_running`, releases `alive`, and + # updates the mirror together. The collapse above owns the rows the orphan query + # matched; this owns every other lost turn (a row the query did not return, or an + # older execution whose row has since advanced). Everything here is guarded on + # `turn_id`, so a row that now names a NEWER running turn is never disturbed. + collapsing = {(p, s, t) for (_id, p, s, t, _u) in collapsed_rows} + newly_lost = sorted(unsettled - collapsing, key=lambda t: t[1]) - # Bring the Redis locks the SEND gate reads in sync with the rows just written. - for row in orphans: - project_id = str(row.project_id) - displaced_alive = await force_cancel_alive( - lock_engine, project_id=project_id, session_id=row.session_id + # Clear `is_running` on the DB row that STILL names a lost turn, keeping `is_alive` so + # the session stays resumable. Guarded on turn_id: a row that advanced to a newer turn + # is left alone. Observed live on the integration stack: the execution was settled lost + # but the stream row kept `is_running: true`, and the next Send was refused. + # Captured as plain values, and written by a Core UPDATE further down, for the same + # reason the collapse is: the Redis calls that follow this block, and anything a future + # edit puts between the load and the write, can open a nested `engine.session()`, whose + # `finally` closes the shared task-scoped session and detaches these rows. A mutation on + # a detached row is tracked by no session and is dropped at commit with no error. + running_rows_to_clear: List[ + Tuple[UUID, UUID, str, str, Optional[datetime], Dict[str, Any]] + ] = [] + if newly_lost: + rows_to_clear = ( + ( + await session.execute( + select(SessionStreamDBE).where( + SessionStreamDBE.deleted_at.is_(None), + SessionStreamDBE.flags.contains({"is_running": True}), + tuple_( + SessionStreamDBE.project_id, + SessionStreamDBE.session_id, + SessionStreamDBE.turn_id, + ).in_(list(newly_lost)), + ) + ) + ) + .scalars() + .all() ) - displaced_running = await clear_running( - lock_engine, project_id=project_id, session_id=row.session_id + for row in rows_to_clear: + flags = dict(row.flags or {}) + flags["is_running"] = False + running_rows_to_clear.append( + ( + row.id, + row.project_id, + row.session_id, + str(row.turn_id), + row.updated_at, + flags, + ) + ) + + # Every write to `session_streams` uses a Core UPDATE. No ORM attribute write on this + # table survives anywhere in this pass: nested scoped sessions can detach loaded rows. + running_rows_cleared: List[ + Tuple[UUID, UUID, str, str, Optional[datetime], Dict[str, Any]] + ] = [] + failed_running_clears: Set[Tuple[UUID, str, str]] = set() + for ( + row_id, + project_uuid, + session_id, + turn_id, + observed_updated_at, + cleared_flags, + ) in running_rows_to_clear: + conditions = [ + SessionStreamDBE.id == row_id, + SessionStreamDBE.project_id == project_uuid, + SessionStreamDBE.session_id == session_id, + SessionStreamDBE.turn_id == turn_id, + SessionStreamDBE.deleted_at.is_(None), + ( + SessionStreamDBE.updated_at == observed_updated_at + if observed_updated_at is not None + else SessionStreamDBE.updated_at.is_(None) + ), + ] + result = await session.execute( + sa_update(SessionStreamDBE) + .where(*conditions) + .values(flags=cleared_flags, updated_at=now) + .execution_options(synchronize_session=False) ) - # A swept turn is declared dead; tombstone it so a late beat from it cannot - # re-nest the session it was just evicted from. - for turn_id in {t for t in (displaced_alive, displaced_running) if t}: - await mark_turn_superseded( - lock_engine, - project_id=project_id, - session_id=row.session_id, + if result.rowcount != 1: + failed_running_clears.add((project_uuid, session_id, turn_id)) + log.info( + "watchdog: lost-turn stream advanced during sweep; leaving it untouched", + session_id=session_id, turn_id=turn_id, ) - # A swept session is dead; free its affinity like kill does. - await force_clear_owner( - lock_engine, project_id=project_id, session_id=row.session_id + continue + running_rows_cleared.append( + ( + row_id, + project_uuid, + session_id, + turn_id, + observed_updated_at, + cleared_flags, + ) ) - log.info("orphan_sweep: marked %d orphans ended", len(orphans)) + await session.commit() + + # Redis cleanup is one compare-and-delete operation per session. A new Send or Steer may + # install another generation after this commit; the script leaves its keys and affinity + # untouched and tombstones only the swept turn. + for project_uuid, session_id, turn_id in newly_lost: + if (project_uuid, session_id, turn_id) in failed_running_clears: + continue + ( + released_alive, + _released_running, + _released_owner, + ) = await release_watchdog_turn( + lock_engine, + project_id=str(project_uuid), + session_id=session_id, + turn_id=turn_id, + owner_value=observed_owners.get((project_uuid, session_id, turn_id)), + ) + log.warning( + "watchdog: wrote the ending a stopped turn's runner never reported", + extra={ + "session_id": session_id, + "turn_id": turn_id, + "released_alive": released_alive, + }, + ) + + for ( + _row_id, + project_uuid, + session_id, + row_turn_id, + _observed_updated_at, + ) in collapsed_rows: + if row_turn_id is None: + project_id = str(project_uuid) + displaced_alive = await force_cancel_alive( + lock_engine, project_id=project_id, session_id=session_id + ) + displaced_running = await clear_running( + lock_engine, project_id=project_id, session_id=session_id + ) + for displaced_turn_id in { + turn_id + for turn_id in (displaced_alive, displaced_running) + if turn_id + }: + await mark_turn_superseded( + lock_engine, + project_id=project_id, + session_id=session_id, + turn_id=displaced_turn_id, + ) + await force_clear_owner( + lock_engine, project_id=project_id, session_id=session_id + ) + continue + await release_watchdog_turn( + lock_engine, + project_id=str(project_uuid), + session_id=session_id, + turn_id=row_turn_id, + owner_value=observed_owners.get( + (project_uuid, session_id, row_turn_id) + ), + ) + + # Tell every open reader the session ended. Without this a browser sitting on the + # settled turn keeps showing it as running until the user reloads. Best effort: the + # publisher never raises and never re-drives the settle above. + if watch_publisher is not None: + for ( + _row_id, + project_uuid, + session_id, + _turn_id, + _observed_updated_at, + ) in collapsed_rows: + try: + await watch_publisher.lifecycle( + project_id=str(project_uuid), + session_id=session_id, + state=WATCH_LIFECYCLE_ENDED, + ) + # The session channel reaches a tab that has this session open. A list + # row lives on the project channel, so publish there too, or every other + # tab keeps the session marked running until its own poll comes round. + await watch_publisher.changed( + project_id=str(project_uuid), + entity="session", + id=session_id, + ) + except Exception: + log.warning( + "watchdog: watch publish failed", + session_id=session_id, + exc_info=True, + ) + + # A row whose `is_running` was cleared (but not collapsed) also needs the mirror + # update, or a browser sitting on it keeps the turn drawn as running until a reload. + for ( + _row_id, + project_uuid, + session_id, + _turn_id, + _observed_updated_at, + _flags, + ) in running_rows_cleared: + try: + await watch_publisher.changed( + project_id=str(project_uuid), + entity="session", + id=session_id, + ) + except Exception: + log.warning( + "watchdog: watch publish failed", + session_id=session_id, + exc_info=True, + ) + + # AFTER the rows above are collapsed, on purpose. A command is only abandoned when its + # session has stopped beating, and the collapse just made that true for every row in + # this batch. Running it first would leave the runner-gone case waiting a second pass. + commands_settled = 0 + if not deferred: + commands_settled = await _settle_abandoned_commands( + commands_service, datetime.now(timezone.utc) + ) + await _repair_terminal_redis(commands_service) + + log.info( + "watchdog: settled %d sessions (%d turns marked lost, %d commands lost)", + len(collapsed_rows), + len(unsettled), + commands_settled, + ) async def orphan_sweep_loop( - engine: TransactionsEngine, lock_engine: LockEngine + engine: TransactionsEngine, + lock_engine: LockEngine, + *, + records_service: Optional[RecordsService] = None, + watch_publisher: Optional[SessionsWatchPublisherInterface] = None, + commands_service: Optional[Any] = None, ) -> None: """Infinite loop; runs as a background asyncio task during app lifespan.""" + # A pass that never returns would end the watchdog for the life of the process with + # nothing in the log; observed on the integration stack on 2026-09-03, when the sweep + # went silent after one pass and never ran again. Bound every pass, log the timeout, + # and go round again. + pass_timeout = float(max(SWEEP_INTERVAL_SECONDS * 2, 120)) while True: + started = datetime.now(timezone.utc) try: - await run_orphan_sweep(engine, lock_engine) + await asyncio.wait_for( + run_orphan_sweep( + engine, + lock_engine, + records_service=records_service, + watch_publisher=watch_publisher, + commands_service=commands_service, + ), + timeout=pass_timeout, + ) + except asyncio.CancelledError: + raise + except asyncio.TimeoutError: + log.error( + "watchdog: sweep pass timed out after %.0fs; skipping to the next pass", + pass_timeout, + ) except Exception: - log.exception("orphan_sweep: error during sweep pass") - await asyncio.sleep(SWEEP_INTERVAL_SECONDS) + # `log` is a MultiLogger, which has no `exception` method; calling one would + # raise AttributeError from inside this handler and kill the loop for the life + # of the process. Use `error(..., exc_info=True)`, the same shape the helpers + # above use, so the first sweep error is logged and the loop goes round again. + log.error("watchdog: error during sweep pass", exc_info=True) + elapsed = (datetime.now(timezone.utc) - started).total_seconds() + if elapsed > SWEEP_INTERVAL_SECONDS: + log.warning("watchdog: sweep pass took %.1fs", elapsed) + # Floored: a zero or negative interval would turn the loop into a hot spin. + await asyncio.sleep(max(SWEEP_INTERVAL_SECONDS, 1)) diff --git a/api/oss/src/tasks/asyncio/sessions/records_worker.py b/api/oss/src/tasks/asyncio/sessions/records_worker.py index b107935a44e..a6c2a36c195 100644 --- a/api/oss/src/tasks/asyncio/sessions/records_worker.py +++ b/api/oss/src/tasks/asyncio/sessions/records_worker.py @@ -2,10 +2,16 @@ from uuid import UUID from redis.asyncio import Redis +from sqlalchemy.exc import DataError, IntegrityError from oss.src.core.sessions.interactions.service import SessionInteractionsService +from oss.src.core.sessions.records.dtos import SessionRecord, TERMINAL_RECORD_TYPE from oss.src.core.sessions.records.service import RecordsService -from oss.src.core.sessions.records.streaming import deserialize_record +from oss.src.core.sessions.records.events import durable_events_from_records +from oss.src.core.sessions.records.streaming import ( + deserialize_record, + publish_durable_event, +) from oss.src.core.sessions.watch.interfaces import SessionsWatchPublisherInterface from oss.src.utils.common import is_ee from oss.src.utils.logging import get_module_logger @@ -18,11 +24,11 @@ from ee.src.core.access.entitlements.types import Counter -# The runner's terminal per-turn record, and the marker it stamps on that record when the turn -# stopped to wait for a human instead of finishing (services/runner/src/tracing/otel.ts: the -# field is written ONLY for a pause and omitted on every other stop reason). -TERMINAL_RECORD_TYPE = "done" +# The marker the runner stamps on its terminal record when the turn stopped to wait for a +# human instead of finishing (services/runner/src/tracing/otel.ts: the field is written ONLY +# for a pause and omitted on every other stop reason). PAUSED_STOP_REASON = "paused" +ROW_REJECTION_ERRORS = (DataError, IntegrityError) def finished_turns_in_batch(events: List[Any]) -> Dict[str, str]: @@ -52,13 +58,18 @@ class RecordsWorker(StreamConsumer): Consumer group: worker-records Flow: - 1. Read batch from stream (XREADGROUP) — StreamConsumer + 1. Read batch from stream (XREADGROUP), or reclaim unacknowledged entries — StreamConsumer 2. Deserialize messages 3. Group by project_id 4. EE: L2 quota check per org (Counter.RECORDS_INGESTED) 5. Append record events to DB 6. Reconcile HITL gates orphaned by a finished turn - 7. ACK + DEL messages — StreamConsumer + 7. ACK + DEL only the messages whose Postgres write committed — StreamConsumer + + A message id leaves this worker in the acknowledged list for exactly three reasons: its + write committed, it could not be decoded, or its org is over quota. Everything else stays + pending so the reclaim pass writes it later. Acknowledging before the write, which is what + this worker used to do, turned every Postgres failure into permanent silent record loss. """ log_prefix = "[RECORDS]" @@ -76,6 +87,8 @@ def __init__( max_batch_mb: int = 50, watch_publisher: Optional[SessionsWatchPublisherInterface] = None, interactions_service: Optional[SessionInteractionsService] = None, + reclaim_min_idle_ms: int = 30_000, + max_deliveries: int = 5, ): super().__init__( redis_client=redis_client, @@ -86,12 +99,18 @@ def __init__( max_block_ms=max_block_ms, max_delay_ms=max_delay_ms, max_batch_mb=max_batch_mb, + # Records are the durable transcript. A pending entry that is never redelivered is + # a lost turn, so this worker always runs the reclaim pass. + reclaim_pending=True, + reclaim_min_idle_ms=reclaim_min_idle_ms, + max_deliveries=max_deliveries, ) self.service = service self.watch_publisher = watch_publisher # Absent disables gate reconciliation (minimal test compositions), which only loses the # safety net — never the append. self.interactions_service = interactions_service + self._permanent_failure_ids: set[bytes] = set() async def reconcile_orphaned_gates( self, @@ -147,13 +166,122 @@ async def reconcile_orphaned_gates( exc_info=True, ) + def describe_message(self, data: Dict[bytes, bytes]) -> Optional[str]: + """`session:record:type` for the dropped-message log, so a loss is traceable.""" + try: + record = deserialize_record(payload=data[b"data"]).record_event + return f"{record.session_id}:{record.record_id}:{record.record_type}" + except Exception: + return None + + def is_permanent_failure( + self, + msg_id: bytes, + data: Dict[bytes, bytes], + ) -> bool: + return msg_id in self._permanent_failure_ids + + async def _append( + self, + *, + project_id: UUID, + entries: List[Tuple[bytes, Any]], + ) -> Tuple[List[SessionRecord], Optional[Exception]]: + """One `append_many` call. Returns committed rows and any failure.""" + try: + results = await self.service.append_many( + events=[msg.record_event for _, msg in entries], + ) + quarantined = [ + row + for row in results + if getattr(row, "quarantined_at", None) is not None + ] + if quarantined: + log.warning( + "[RECORDS] Quarantined late records for settled turns", + project_id=str(project_id), + quarantined=len(quarantined), + appended=len(results), + turns=sorted( + {f"{row.session_id}:{row.turn_id}" for row in quarantined} + ), + ) + return results, None + except Exception as exc: + log.error( + "[RECORDS] Failed to append event batch", + project_id=str(project_id), + size=len(entries), + exc_info=True, + ) + return [], exc + + async def _append_committed( + self, + *, + project_id: UUID, + entries: List[Tuple[bytes, Any]], + ) -> Tuple[List[SessionRecord], List[bytes]]: + """Write a project group and report the rows and message ids that are durable. + + `append_many` is one statement in one transaction, so a row-specific database rejection + takes the whole group down with it. Only that failure class triggers one-record writes to + isolate the rejected row. Connection, timeout, and unknown failures leave the entire + group pending for Redis reclaim instead of multiplying calls during an outage. + """ + results, failure = await self._append(project_id=project_id, entries=entries) + if failure is None: + self._permanent_failure_ids.difference_update( + msg_id for msg_id, _ in entries + ) + return results, [msg_id for msg_id, _ in entries] + + if not isinstance(failure, ROW_REJECTION_ERRORS): + return [], [] + + if len(entries) == 1: + self._permanent_failure_ids.add(entries[0][0]) + return [], [] + + log.warning( + "[RECORDS] Batch append failed, retrying one record at a time", + project_id=str(project_id), + size=len(entries), + ) + + committed_records: List[SessionRecord] = [] + committed_ids: List[bytes] = [] + for entry in entries: + results, failure = await self._append( + project_id=project_id, entries=[entry] + ) + if failure is None: + committed_records.extend(results) + committed_ids.append(entry[0]) + self._permanent_failure_ids.discard(entry[0]) + elif isinstance(failure, ROW_REJECTION_ERRORS): + self._permanent_failure_ids.add(entry[0]) + + log.warning( + "[RECORDS] Retry finished", + project_id=str(project_id), + committed=len(committed_ids), + pending=len(entries) - len(committed_ids), + ) + return committed_records, committed_ids + async def process_batch( self, batch: List[Tuple[bytes, Dict[bytes, bytes]]], ) -> Tuple[int, List[bytes]]: - """Process batch — deserialize, group by org for EE quota, append to DB.""" + """Process batch — deserialize, group by org for EE quota, append to DB. + + The returned ids are acknowledged and deleted by the consumer loop, so an id only goes + in once its rows are committed, or once this worker has decided to drop it on purpose. + """ groups: Dict[UUID, Dict[str, Any]] = {} - processed_ids: List[bytes] = [] + acked_ids: List[bytes] = [] batch_bytes = 0 for msg_id, data in batch: @@ -162,6 +290,8 @@ async def process_batch( batch_bytes += len(payload) if batch_bytes > self.max_batch_mb * 1024 * 1024: + # The rest of the batch stays unacknowledged and comes back through the + # reclaim pass, rather than being silently skipped. break msg = deserialize_record(payload=payload) @@ -170,23 +300,28 @@ async def process_batch( group = { "organization_id": msg.organization_id, "project_id": msg.project_id, - "events": [], + "entries": [], } groups[msg.project_id] = group - group["events"].append(msg) - processed_ids.append(msg_id) + group["entries"].append((msg_id, msg)) except Exception: log.error( "[RECORDS] Failed to deserialize message", msg_id=repr(msg_id), exc_info=True, ) - processed_ids.append(msg_id) + # A message that does not decode will not decode on redelivery either, so + # acknowledge it instead of letting it hold the pending list. Counted as a loss. + self.dropped_messages += 1 + acked_ids.append(msg_id) batches = list(groups.values()) total_appended = 0 org_allowed: Dict[UUID, bool] = {} + # Orgs whose quota question could not be answered. Their records are not over quota, + # they are unmetered, so they wait for the next delivery instead of being dropped. + org_deferred: set = set() events_per_org: Dict[UUID, int] = {} if is_ee(): @@ -195,7 +330,7 @@ async def process_batch( if org_id is None: continue events_per_org[org_id] = events_per_org.get(org_id, 0) + len( - project_batch["events"] + project_batch["entries"] ) for org_id, delta in events_per_org.items(): @@ -216,6 +351,7 @@ async def process_batch( exc_info=True, ) org_allowed[org_id] = False + org_deferred.add(org_id) continue if not quota_allowed: @@ -231,27 +367,61 @@ async def process_batch( for project_batch in batches: org_id = project_batch["organization_id"] + entries: List[Tuple[bytes, Any]] = project_batch["entries"] + if is_ee() and org_id and not org_allowed.get(org_id, True): + if org_id in org_deferred: + # The meter was unreachable, not exceeded. Leave the entries pending so a + # transient entitlements outage does not delete a conversation. + continue + # An over-quota org is a deliberate product drop, so acknowledging is correct. + # Count it, because it is still a record the transcript will never have. + self.dropped_messages += len(entries) + acked_ids.extend(msg_id for msg_id, _ in entries) continue - try: - results = await self.service.append_many( - events=[msg.record_event for msg in project_batch["events"]], - ) - total_appended += len(results) - except Exception: - log.error( - "[RECORDS] Failed to append event batch", - project_id=str(project_batch["project_id"]), - exc_info=True, - ) + results, committed_ids = await self._append_committed( + project_id=project_batch["project_id"], + entries=entries, + ) + total_appended += len(results) + acked_ids.extend(committed_ids) + + if not committed_ids: continue + committed = set(committed_ids) + committed_events = [msg for msg_id, msg in entries if msg_id in committed] + results_by_session: Dict[str, List[SessionRecord]] = {} + for result in results: + if isinstance(result, SessionRecord): + results_by_session.setdefault(result.session_id, []).append(result) + + for session_results in results_by_session.values(): + # Live events carry this batch's committed maximum; replay/ready uses the + # authoritative session cursor and may therefore be higher. + watermark = max( + (result.sequence or 0 for result in session_results), default=0 + ) + visible_results = [ + result + for result in session_results + if result.quarantined_at is None + ] + for event in durable_events_from_records( + visible_results, watermark=watermark + ): + await publish_durable_event( + organization_id=project_batch["organization_id"], + project_id=project_batch["project_id"], + event=event, + ) + # Strictly post-append, and BEFORE the relay tee: a client woken by the records # notification below must already see the cancelled gate, not re-render it. await self.reconcile_orphaned_gates( project_id=project_batch["project_id"], - events=project_batch["events"], + events=committed_events, ) # Relay tee (M3): strictly post-append so a notified client that @@ -259,9 +429,7 @@ async def process_batch( # session in the project batch; failures never re-drive the append. if self.watch_publisher is not None: project_id = str(project_batch["project_id"]) - session_ids = { - msg.record_event.session_id for msg in project_batch["events"] - } + session_ids = {msg.record_event.session_id for msg in committed_events} for session_id in sorted(session_ids): try: await self.watch_publisher.records_changed( @@ -275,4 +443,4 @@ async def process_batch( session_id=session_id, ) - return total_appended, processed_ids + return total_appended, acked_ids diff --git a/api/oss/src/tasks/asyncio/shared/consumer.py b/api/oss/src/tasks/asyncio/shared/consumer.py index 66303ff5edf..79846cb4a20 100644 --- a/api/oss/src/tasks/asyncio/shared/consumer.py +++ b/api/oss/src/tasks/asyncio/shared/consumer.py @@ -12,6 +12,10 @@ - max_block_ms: 5000ms (XREADGROUP BLOCK) - max wait time when queue is empty - max_batch_mb: 50 - max batch size in megabytes - max_delay_ms: 250ms - max wait time for batch accumulation when small batches arrive + +Redelivery (opt-in, `reclaim_pending`): +- reclaim_min_idle_ms: 30000 - how long an unacknowledged entry sits before it is retried +- max_deliveries: 5 - deliveries after which an entry is dropped loudly instead of retried """ import time @@ -31,9 +35,10 @@ class StreamConsumer: Base class for a Redis Streams consumer-group loop. Flow: - 1. Read batch from Redis Streams (XREADGROUP) + 1. Read batch from Redis Streams (XREADGROUP), or reclaim entries an earlier + pass left unacknowledged (opt-in, see `reclaim_batch`) 2. `process_batch` (subclass): deserialize, group, meter, write - 3. ACK + DEL processed messages + 3. ACK + DEL the message ids `process_batch` reports as durable """ #: Short tag prepended to log messages by subclasses (e.g. "[INGEST]"). @@ -49,6 +54,9 @@ def __init__( max_block_ms: int = 5000, # 5 seconds max_delay_ms: int = 250, # 250 milliseconds max_batch_mb: int = 50, # 50 MB + reclaim_pending: bool = False, + reclaim_min_idle_ms: int = 30_000, # 30 seconds + max_deliveries: int = 5, ): self.redis = redis_client self.stream_name = stream_name @@ -62,6 +70,12 @@ def __init__( self.max_block_ms = max_block_ms self.max_batch_mb = max_batch_mb self.max_delay_ms = max_delay_ms + self.reclaim_pending = reclaim_pending + self.reclaim_min_idle_ms = reclaim_min_idle_ms + self.max_deliveries = max_deliveries + #: Messages this process gave up on. Only ever grows; read by tests and logs. + self.dropped_messages = 0 + self._last_reclaim_at = 0.0 async def create_consumer_group(self): """Create consumer group if it doesn't exist. Safe to call multiple times (idempotent).""" @@ -141,6 +155,127 @@ async def read_batch(self) -> List[Tuple[bytes, Dict[bytes, bytes]]]: log.error(f"{self.log_prefix} Failed to read batch: {e}") return [] + def describe_message(self, data: Dict[bytes, bytes]) -> Optional[str]: + """Subclass hook: a short identity for a dropped message, for the loss log.""" + return None + + def is_permanent_failure( + self, + msg_id: bytes, + data: Dict[bytes, bytes], + ) -> bool: + """Subclass hook: whether this exact message is known not to succeed on retry.""" + return False + + async def reclaim_batch(self) -> List[Tuple[bytes, Dict[bytes, bytes]]]: + """Re-deliver entries an earlier pass left unacknowledged. + + `read_batch` only ever asks Redis for `>`, so an entry that is never acknowledged is + invisible to every later read of this group. Without this pass, "skip the ACK so Redis + retries it" means "lose it quietly with a growing pending list". Redis' delivery count + bounds retries only for a message the subclass has identified as permanently invalid; + it cannot distinguish a poison message from a transient write-path outage. + """ + if not self.reclaim_pending: + return [] + + # One XPENDING per idle window, not one per loop turn: a busy stream spins this loop + # as fast as Postgres answers, and the pending list cannot change faster than the + # window anyway. + now = time.monotonic() + if (now - self._last_reclaim_at) * 1000 < self.reclaim_min_idle_ms: + return [] + self._last_reclaim_at = now + + try: + pending = await self.redis.xpending_range( + name=self.stream_name, + groupname=self.consumer_group, + min="-", + max="+", + count=self.max_batch_size, + # A zero window means "no idle filter", not "idle exactly zero". + idle=self.reclaim_min_idle_ms or None, + ) + except Exception as e: + log.error(f"{self.log_prefix} Failed to read pending entries: {e}") + return [] + + if not pending: + return [] + + deliveries = { + entry["message_id"]: int(entry["times_delivered"]) for entry in pending + } + + try: + claimed = await self.redis.xclaim( + name=self.stream_name, + groupname=self.consumer_group, + consumername=self.consumer_name, + min_idle_time=self.reclaim_min_idle_ms, + message_ids=list(deliveries.keys()), + ) + except Exception as e: + log.error(f"{self.log_prefix} Failed to claim pending entries: {e}") + return [] + + # XCLAIM returns nothing for an entry whose stream payload is already gone (MAXLEN + # trim), and removes it from the pending list itself. + retry: List[Tuple[bytes, Dict[bytes, bytes]]] = [] + expired: List[Tuple[bytes, Dict[bytes, bytes]]] = [] + over_budget = 0 + for msg_id, data in claimed: + if not data: + continue + if deliveries.get(msg_id, 1) >= self.max_deliveries: + over_budget += 1 + if self.is_permanent_failure(msg_id, data): + expired.append((msg_id, data)) + continue + retry.append((msg_id, data)) + + if expired: + await self.drop_expired(expired) + elif over_budget: + log.warning( + f"{self.log_prefix} Keeping over-budget messages: failure is not known to be permanent", + stream=self.stream_name, + group=self.consumer_group, + count=over_budget, + ) + + if retry: + log.warning( + f"{self.log_prefix} Redelivering unacknowledged messages", + stream=self.stream_name, + group=self.consumer_group, + count=len(retry), + ) + + return retry + + async def drop_expired(self, entries: List[Tuple[bytes, Dict[bytes, bytes]]]): + """Give up on entries that failed `max_deliveries` times, loudly. + + This is data loss. It is preferred over an unbounded retry because a single entry the + write path can never accept would otherwise stall every later entry in the group. The + log line names each lost message so the loss is countable after the fact. + """ + self.dropped_messages += len(entries) + log.error( + f"{self.log_prefix} Dropping messages after repeated delivery failures", + stream=self.stream_name, + group=self.consumer_group, + max_deliveries=self.max_deliveries, + count=len(entries), + messages=[ + self.describe_message(data) or repr(msg_id) for msg_id, data in entries + ], + dropped_total=self.dropped_messages, + ) + await self.ack_and_delete([msg_id for msg_id, _ in entries]) + async def ack_and_delete(self, message_ids: List[bytes]): """ACK and DELETE messages after successful processing.""" if not message_ids: @@ -168,10 +303,10 @@ async def run(self): Main worker loop. Flow: - 1. Read batch via XREADGROUP + 1. Reclaim entries an earlier pass left unacknowledged, else read via XREADGROUP 2. Process batch - 3. ACK + DEL on success - 4. On error, messages remain pending for retry + 3. ACK + DEL only the message ids `process_batch` reports as durable + 4. Everything else stays pending and comes back through step 1 """ log.info( f"{self.log_prefix} Starting worker", @@ -183,7 +318,9 @@ async def run(self): while True: try: - batch = await self.read_batch() + batch = await self.reclaim_batch() + if not batch: + batch = await self.read_batch() if not batch: continue diff --git a/api/oss/src/tasks/taskiq/triggers/worker.py b/api/oss/src/tasks/taskiq/triggers/worker.py index 057aed66b23..3f5d16d458e 100644 --- a/api/oss/src/tasks/taskiq/triggers/worker.py +++ b/api/oss/src/tasks/taskiq/triggers/worker.py @@ -105,10 +105,21 @@ async def dispatch_schedule( ) return - resolved_project_id = UUID(project_id) + try: + resolved_project_id = UUID(project_id) + resolved_schedule_id = UUID(str(queued_schedule_id)) + except ValueError: + log.warning( + "[TASK] triggers.dispatch_schedule Malformed UUID " + "project_id=%s schedule_id=%s — skipping", + project_id, + queued_schedule_id, + ) + return + entity = await self.triggers_dao.fetch_schedule( project_id=resolved_project_id, - schedule_id=UUID(str(queued_schedule_id)), + schedule_id=resolved_schedule_id, ) if entity is None or not entity.flags.is_active: log.info( diff --git a/api/oss/src/utils/caching.py b/api/oss/src/utils/caching.py index b2c6f97b627..5a21373f462 100644 --- a/api/oss/src/utils/caching.py +++ b/api/oss/src/utils/caching.py @@ -55,9 +55,9 @@ def _pack( if user_id: user_id = user_id[-12:] if len(user_id) > 12 else user_id + user_id = user_id + "-" * (12 - len(user_id)) else: - user_id = "" - user_id = user_id + "-" * (12 - len(user_id)) + user_id = "*" if pattern else "-" * 12 namespace = namespace or ("" if not pattern else "*") diff --git a/api/oss/src/utils/crypting.py b/api/oss/src/utils/crypting.py index e9bf4b87241..6a3a2d89276 100644 --- a/api/oss/src/utils/crypting.py +++ b/api/oss/src/utils/crypting.py @@ -7,12 +7,14 @@ import base64 import hashlib +from functools import lru_cache from cryptography.fernet import Fernet, InvalidToken from oss.src.utils.env import env +@lru_cache(maxsize=1) def _get_fernet() -> Fernet: crypt_key = env.agenta.crypt_key if not crypt_key: diff --git a/api/oss/src/utils/env.py b/api/oss/src/utils/env.py index 74edaafcca0..7006b9ca525 100644 --- a/api/oss/src/utils/env.py +++ b/api/oss/src/utils/env.py @@ -1,7 +1,7 @@ import os import hashlib import warnings -from typing import List, Optional +from typing import List, Literal, Optional from uuid import getnode from json import loads from urllib.parse import urlparse, quote_plus @@ -512,6 +512,31 @@ def _validate_mode(self) -> "RedactionConfig": # --------------------------------------------------------------------------- +def _parse_sessions_late_output() -> Literal["quarantine", "reject"]: + value = (os.getenv("AGENTA_SESSIONS_LATE_OUTPUT") or "quarantine").strip().lower() + if value in ("quarantine", "reject"): + return value + warnings.warn( + f"AGENTA_SESSIONS_LATE_OUTPUT={value!r} is not recognized; " + "behaving as 'quarantine'.", + stacklevel=2, + ) + return "quarantine" + + +def _sessions_durable_stop_enabled() -> bool: + return (os.getenv("AGENTA_SESSIONS_DURABLE_STOP") or "true").lower() in _TRUTHY + + +def _parse_sessions_watchdog_stale_heartbeat_seconds() -> int: + configured = _parse_optional_positive_int_env( + "AGENTA_SESSIONS_WATCHDOG_STALE_HEARTBEAT_SECONDS" + ) + if configured is not None: + return configured + return 90 if _sessions_durable_stop_enabled() else 300 + + class SessionsRecordsConfig(BaseModel): """Durable session-record ingest tuning (server-side history reconstruction).""" @@ -523,6 +548,24 @@ class SessionsRecordsConfig(BaseModel): os.getenv("AGENTA_RECORDS_SMART_TRUNCATION") or "true" ).lower() in _TRUTHY + # How long a record message the worker failed to write sits unacknowledged before the + # worker claims it back and tries again. + reclaim_idle_ms: int = Field( + default_factory=lambda: int( + os.getenv("AGENTA_RECORDS_RECLAIM_IDLE_MS") or 30_000 + ), + ge=0, + validate_default=True, + ) + + # Deliveries after which a record message is dropped instead of retried forever. A message + # Postgres never accepts would otherwise hold every later message in the group. + max_deliveries: int = Field( + default_factory=lambda: int(os.getenv("AGENTA_RECORDS_MAX_DELIVERIES") or 5), + ge=1, + validate_default=True, + ) + model_config = ConfigDict(extra="ignore") @@ -561,11 +604,110 @@ class SessionAttachmentsConfig(BaseModel): model_config = ConfigDict(extra="ignore") +class SessionWatchdogConfig(BaseModel): + """The execution watchdog: how long a running turn may go silent before it is settled. + + The rule is HEARTBEAT AGE, not lease expiry. The Redis `alive` and `running` keys carry a + one-hour TTL, so "shortly after the lease expires" would mean an hour after the runner + died. The runner beats every `heartbeat_interval_seconds` (30) and the beat is mirrored + onto `session_streams.updated_at`, so the age of that column is the real liveness signal. + + A turn is declared lost when its stream row still claims `is_running` and its last + heartbeat is older than `stale_heartbeat_seconds`. Durable Stop uses 90 seconds (three + missed beats); flag-off deployments retain the pre-milestone 300-second default. + + Only a turn that still claims `is_running` is eligible. A turn parked for a human sends a + final beat with `is_running: false` and then stops beating on purpose; that state is + resumable, not lost, and the watchdog must never end it. + + Raise `stale_heartbeat_seconds` if a healthy deployment settles live turns. Lower it to + settle a dead turn sooner. It is a plain restart-time setting; nothing else changes. + """ + + # Maximum age of the last heartbeat before a RUNNING turn is declared lost. + stale_heartbeat_seconds: int = _parse_sessions_watchdog_stale_heartbeat_seconds() + + # How long an ALIVE-but-not-running row (between turns, or parked awaiting a human) is left + # alone before it is RECLAIMED. That state is resumable, so it is keyed to the 30-minute + # approval TTL rather than to three missed beats. It does not govern whether such a row owes + # its turn a terminal record: that question is asked of the records plane on the + # `stale_heartbeat_seconds` clock, because a durable Stop clears `is_running` before the + # runner has written its own ending. + idle_grace_seconds: int = ( + _parse_optional_positive_int_env("AGENTA_SESSIONS_WATCHDOG_IDLE_GRACE_SECONDS") + or 1_800 + ) + + # How often the watchdog runs. + interval_seconds: int = ( + _parse_optional_positive_int_env("AGENTA_SESSIONS_WATCHDOG_INTERVAL_SECONDS") + or 60 + ) + + # Rows settled per pass. A backlog drains over successive passes, not one huge commit. + batch_size: int = ( + _parse_optional_positive_int_env("AGENTA_SESSIONS_WATCHDOG_BATCH_SIZE") or 500 + ) + + model_config = ConfigDict(extra="ignore") + + +class SessionsCommandsConfig(BaseModel): + """Durable session commands: how a Stop reaches the runner, and how long it may wait. + + `adapter` picks the control-delivery transport behind `ControlDeliveryPort`: + + * `direct` — the API posts the command to the runner's own `/cancel`, over the + authenticated hop that already carries hard kill. One runner process, no held + connection, no poll loop. This is the default. + * `long_poll` — the runner holds a claim request open and the API answers it. Correct for + two or more runner replicas and for a runner the API cannot reach inbound. Not built in + this slice; naming it here fails loudly rather than silently falling back. + + `direct` calls one service address, so with two runner replicas behind a load balancer the + call lands on the right process only by luck. Nothing here guards that, on purpose: the + detector is exact and lives in the service, where a `not_held` for a session that is alive + and beating is the wrong-replica failure and nothing else produces it. + """ + + adapter: str = os.getenv("AGENTA_SESSIONS_CONTROL_ADAPTER") or "direct" + + # How long a claimed command may go unreported before the settlement sweep acts. Three + # heartbeat intervals. + lease_seconds: int = ( + _parse_optional_positive_int_env("AGENTA_SESSIONS_COMMAND_LEASE_SECONDS") or 90 + ) + # Bounds a delivery loop where a runner accepts a command and never reports. + max_deliveries: int = ( + _parse_optional_positive_int_env("AGENTA_SESSIONS_COMMAND_MAX_DELIVERIES") or 3 + ) + sweep_seconds: int = ( + _parse_optional_positive_int_env("AGENTA_SESSIONS_COMMAND_SWEEP_SECONDS") or 10 + ) + # A command nobody ever claimed is a runner that is not there. + admission_timeout_seconds: int = ( + _parse_optional_positive_int_env( + "AGENTA_SESSIONS_COMMAND_ADMISSION_TIMEOUT_SECONDS" + ) + or 90 + ) + # How long the direct call waits for the runner to acknowledge. The runner answers before + # it cancels anything, so this covers a network hop, not a harness cancel. + delivery_timeout_seconds: float = float( + os.getenv("AGENTA_SESSIONS_COMMAND_DELIVERY_TIMEOUT_SECONDS") or 5.0 + ) + model_config = ConfigDict(extra="ignore") + + class SessionsConfig(BaseModel): """Agenta sessions sub-namespace.""" + durable_stop: bool = _sessions_durable_stop_enabled() + late_output: Literal["quarantine", "reject"] = _parse_sessions_late_output() attachments: SessionAttachmentsConfig = SessionAttachmentsConfig() + commands: SessionsCommandsConfig = SessionsCommandsConfig() records: SessionsRecordsConfig = SessionsRecordsConfig() + watchdog: SessionWatchdogConfig = SessionWatchdogConfig() model_config = ConfigDict(extra="ignore") @@ -1411,8 +1553,13 @@ class SessionsRedisConfig(BaseModel): Defaults mirror the golden fixture (services/runner/tests/fixtures/sessions/ redis_contract.json) shared with the TypeScript runner. Do not change a default without updating that fixture and the TS side in lockstep. + + AGENTA_SESSIONS_SEQUENCE_WRITES independently gates atomic record sequencing. """ + sequence_writes: bool = ( + os.getenv("AGENTA_SESSIONS_SEQUENCE_WRITES") or "false" + ).lower() in _TRUTHY alive_ttl_seconds: int = ( _parse_optional_positive_int_env("AGENTA_SESSIONS_REDIS_ALIVE_TTL_SECONDS") or 3600 @@ -1445,6 +1592,25 @@ class SessionsRedisConfig(BaseModel): _parse_optional_positive_int_env("AGENTA_SESSIONS_REDIS_CONCURRENCY_LIMIT") or 1000 ) + live_stream_maxlen: int = ( + _parse_optional_positive_int_env("AGENTA_SESSIONS_LIVE_STREAM_MAXLEN") + or 100_000 + ) + live_frame_max_age_seconds: int = ( + _parse_optional_positive_int_env("AGENTA_SESSIONS_LIVE_FRAME_MAX_AGE_SECONDS") + or 900 + ) + shared_reader: bool = ( + os.getenv("AGENTA_SESSIONS_SHARED_READER") or "false" + ).lower() in _TRUTHY + live_auth_recheck_seconds: int = ( + _parse_optional_positive_int_env("AGENTA_SESSIONS_LIVE_AUTH_RECHECK_SECONDS") + or 60 + ) + live_reader_buffer_limit: int = ( + _parse_optional_positive_int_env("AGENTA_SESSIONS_LIVE_READER_BUFFER_LIMIT") + or 256 + ) # API-side only (SSE watch endpoint keep-alive cadence) — NOT part of the # runner golden fixture; safe to tune without touching the TS side. watch_heartbeat_seconds: int = ( diff --git a/api/oss/src/utils/exceptions.py b/api/oss/src/utils/exceptions.py index a5fcaa9458a..204f631005c 100644 --- a/api/oss/src/utils/exceptions.py +++ b/api/oss/src/utils/exceptions.py @@ -157,7 +157,7 @@ async def wrapper(*args, **kwargs): # request_body = None with suppress(verbose=False): - request = kwargs.pop("request", None) + request = kwargs.get("request", None) request = request if isinstance(request, Request) else None state = request.state if request else None user_id = state.user_id if state else None diff --git a/api/oss/src/utils/helpers.py b/api/oss/src/utils/helpers.py index f04685aa5a0..7fd712b3795 100644 --- a/api/oss/src/utils/helpers.py +++ b/api/oss/src/utils/helpers.py @@ -1,5 +1,6 @@ from typing import List, Dict from uuid import UUID +import os import sys import unicodedata import re @@ -141,7 +142,7 @@ def warn_deprecated_env_vars(): messages = [] for old_var, new_var in deprecated_env_map.items(): - if getattr(env, old_var, None) is not None: + if os.getenv(old_var) is not None: if new_var is not None: messages.append( f"Environment variable '{old_var}' is deprecated and will be removed in the next release. " diff --git a/api/oss/src/utils/logging.py b/api/oss/src/utils/logging.py index 0d3ba469809..16d3779c8e4 100644 --- a/api/oss/src/utils/logging.py +++ b/api/oss/src/utils/logging.py @@ -208,6 +208,14 @@ def warn(self, *a, **k): def error(self, *a, **k): self._log("error", *a, **k) + def exception(self, *a, **k): + # Mirror stdlib `Logger.exception`: log at error level with the active + # traceback. Without this method a caller reaching for `log.exception(...)` + # -- the natural thing to write inside an `except` block -- would raise + # AttributeError from inside the handler and take the caller down with it. + k.setdefault("exc_info", True) + self._log("error", *a, **k) + def critical(self, *a, **k): self._log("critical", *a, **k) diff --git a/api/oss/tests/pytest/acceptance/evaluations/test_evaluation_scenarios_basics.py b/api/oss/tests/pytest/acceptance/evaluations/test_evaluation_scenarios_basics.py index d8a928aacf7..6d4e69c2db7 100644 --- a/api/oss/tests/pytest/acceptance/evaluations/test_evaluation_scenarios_basics.py +++ b/api/oss/tests/pytest/acceptance/evaluations/test_evaluation_scenarios_basics.py @@ -179,7 +179,7 @@ def test_delete_evaluation_scenarios(self, authed_api, mock_data): assert response.status_code == 200 response = response.json() assert response["count"] == 2 - assert response["scenario_ids"] == scenario_ids + assert sorted(response["scenario_ids"]) == sorted(scenario_ids) # ---------------------------------------------------------------------- # ACT ------------------------------------------------------------------ diff --git a/api/oss/tests/pytest/integration/sessions/test_records_replay_postgres.py b/api/oss/tests/pytest/integration/sessions/test_records_replay_postgres.py new file mode 100644 index 00000000000..f06adb89959 --- /dev/null +++ b/api/oss/tests/pytest/integration/sessions/test_records_replay_postgres.py @@ -0,0 +1,223 @@ +import uuid +from datetime import datetime, timezone + +import pytest +import pytest_asyncio +from sqlalchemy import delete + +import oss.src.dbs.postgres.shared.engine as engine_module +from oss.src.core.sessions.records.dtos import SessionRecordEvent +from oss.src.core.sessions.records.service import RecordsService +from oss.src.dbs.postgres.sessions.records.dao import RecordsDAO +from oss.src.dbs.postgres.sessions.records.dbes import ( + RecordDBE, + SessionSequenceCursorDBE, +) +from oss.src.dbs.postgres.shared.engine import get_analytics_engine +from oss.src.utils.env import env + + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration] + + +@pytest_asyncio.fixture(autouse=True) +async def _fresh_analytics_engine(monkeypatch): + monkeypatch.setattr(env.sessions, "sequence_writes", True) + engine_module._analytics_engine = None + yield + if engine_module._analytics_engine is not None: + await engine_module._analytics_engine.close() + engine_module._analytics_engine = None + + +def _event( + project_id, + session_id, + text="", + *, + record_type="message", + attributes=None, +): + return SessionRecordEvent( + project_id=project_id, + session_id=session_id, + record_id=uuid.uuid4(), + turn_id="execution-1", + record_type=record_type, + record_source="agent", + attributes=attributes or {"type": "message", "text": text}, + ) + + +async def test_snapshot_n_followed_by_events_after_n_loses_no_commit(): + project_id = uuid.uuid4() + session_id = f"replay-{uuid.uuid4()}" + dao = RecordsDAO(engine=get_analytics_engine()) + service = RecordsService(records_dao=dao) + + try: + await dao.append(event=_event(project_id, session_id, "one")) + snapshot = await dao.get_read_state( + project_id=project_id, session_id=session_id + ) + await dao.append_many( + events=[ + _event( + project_id, session_id, record_type="done", attributes={"ok": True} + ), + _event(project_id, session_id, "three"), + _event( + project_id, + session_id, + record_type="usage", + attributes={"tokens": 1}, + ), + _event( + project_id, + session_id, + record_type="tool_call", + attributes={"id": "tool-1", "name": "read", "input": {}}, + ), + _event( + project_id, + session_id, + record_type="tool_result", + attributes={"id": "tool-1", "output": "ok"}, + ), + _event( + project_id, + session_id, + record_type="execution.stopped", + attributes={ + "stopped_at": datetime.now(timezone.utc).isoformat(), + "reason": "completed", + }, + ), + _event( + project_id, + session_id, + record_type="thought", + attributes={"text": "x"}, + ), + _event(project_id, session_id, "nine"), + ] + ) + + replay = await service.get_events_after( + project_id=project_id, + session_id=session_id, + after=snapshot.latest_sequence, + ) + + assert snapshot.latest_sequence == 1 + assert [event.sequence for event in replay.events] == [3, 6, 7, 9] + assert replay.events[0].payload.content == "three" + assert replay.events[-1].payload.content == "nine" + assert replay.events[-1].watermark == 9 + assert replay.watermark == 9 + finally: + async with get_analytics_engine().session() as session: + await session.execute( + delete(RecordDBE).where(RecordDBE.project_id == project_id) + ) + await session.execute( + delete(SessionSequenceCursorDBE).where( + SessionSequenceCursorDBE.project_id == project_id, + SessionSequenceCursorDBE.session_id == session_id, + ) + ) + + +async def test_quarantined_records_are_absent_from_snapshot_pages_and_replay(): + project_id = uuid.uuid4() + session_id = f"quarantine-{uuid.uuid4()}" + dao = RecordsDAO(engine=get_analytics_engine()) + service = RecordsService(records_dao=dao) + + try: + await dao.append(event=_event(project_id, session_id, "visible-before")) + await dao.append( + event=_event(project_id, session_id, "refused-tail").model_copy( + update={"quarantined_at": datetime.now(timezone.utc)} + ) + ) + await dao.append(event=_event(project_id, session_id, "visible-after")) + + read = await dao.get_read_state(project_id=project_id, session_id=session_id) + page = await dao.get_records_page( + project_id=project_id, + session_id=session_id, + offset=0, + limit=10, + through_sequence=read.latest_sequence, + ) + replay = await service.get_events_after( + project_id=project_id, + session_id=session_id, + after=0, + ) + + assert read.latest_sequence == 3 + assert [record.sequence for record in page.records] == [1, 3] + assert [event.sequence for event in replay.events] == [1, 3] + assert [event.payload.content for event in replay.events] == [ + "visible-before", + "visible-after", + ] + assert replay.watermark == 3 + finally: + async with get_analytics_engine().session() as session: + await session.execute( + delete(RecordDBE).where(RecordDBE.project_id == project_id) + ) + await session.execute( + delete(SessionSequenceCursorDBE).where( + SessionSequenceCursorDBE.project_id == project_id, + SessionSequenceCursorDBE.session_id == session_id, + ) + ) + + +async def test_legacy_session_replays_ordered_history_and_is_incomplete(): + project_id = uuid.uuid4() + session_id = f"legacy-{uuid.uuid4()}" + dao = RecordsDAO(engine=get_analytics_engine()) + service = RecordsService(records_dao=dao) + now = datetime.now(timezone.utc) + + try: + async with get_analytics_engine().session() as session: + session.add_all( + [ + RecordDBE( + project_id=project_id, + record_id=uuid.uuid4(), + session_id=session_id, + turn_id="execution-legacy", + record_index=index, + timestamp=now, + record_type="message", + record_source="agent", + attributes={"type": "message", "text": text}, + ) + for index, text in enumerate(("first", "second")) + ] + ) + + read = await dao.get_read_state(project_id=project_id, session_id=session_id) + replay = await service.get_events_after( + project_id=project_id, + session_id=session_id, + after=0, + ) + + assert read.latest_sequence == 0 + assert read.history_complete is False + assert replay.watermark == 0 + assert [event.sequence for event in replay.events] == [None, None] + assert [event.payload.content for event in replay.events] == ["first", "second"] + finally: + async with get_analytics_engine().session() as session: + await session.execute( + delete(RecordDBE).where(RecordDBE.project_id == project_id) + ) diff --git a/api/oss/tests/pytest/integration/sessions/test_records_sequence_postgres.py b/api/oss/tests/pytest/integration/sessions/test_records_sequence_postgres.py new file mode 100644 index 00000000000..69719a3f479 --- /dev/null +++ b/api/oss/tests/pytest/integration/sessions/test_records_sequence_postgres.py @@ -0,0 +1,213 @@ +import asyncio +import uuid + +import pytest +from sqlalchemy import delete, select + +import oss.src.dbs.postgres.shared.engine as engine_module +from oss.src.core.sessions.records.dtos import SessionRecordEvent +from oss.src.dbs.postgres.sessions.records.dao import RecordsDAO +from oss.src.dbs.postgres.sessions.records.dbes import ( + RecordDBE, + SessionSequenceCursorDBE, +) +from oss.src.dbs.postgres.shared.engine import get_analytics_engine +from oss.src.utils.env import env + + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration] + + +@pytest.fixture(autouse=True) +async def _fresh_analytics_engine(monkeypatch): + monkeypatch.setattr(env.sessions, "sequence_writes", True) + # Close whatever an earlier module left behind; teardown only knows this fixture's engine. + previous = engine_module._analytics_engine + if previous is not None: + await previous.close() + engine_module._analytics_engine = None + yield + if engine_module._analytics_engine is not None: + await engine_module._analytics_engine.close() + engine_module._analytics_engine = None + + +async def test_concurrent_inserts_allocate_distinct_sequences_and_retry_keeps_cursor(): + project_id = uuid.uuid4() + session_id = f"sequence-{uuid.uuid4()}" + record_ids = [uuid.uuid4(), uuid.uuid4()] + dao = RecordsDAO(engine=get_analytics_engine()) + + def event(record_id: uuid.UUID, text: str) -> SessionRecordEvent: + return SessionRecordEvent( + project_id=project_id, + session_id=session_id, + record_id=record_id, + record_type="message", + record_source="agent", + attributes={"type": "message", "text": text}, + ) + + try: + first, second = await asyncio.gather( + dao.append(event=event(record_ids[0], "first")), + dao.append(event=event(record_ids[1], "second")), + ) + assert sorted([first.sequence, second.sequence]) == [1, 2] + + retried = await dao.append(event=event(record_ids[0], "first")) + assert retried.sequence == first.sequence + + async with get_analytics_engine().session() as session: + cursor = await session.scalar( + select(SessionSequenceCursorDBE.latest_sequence).where( + SessionSequenceCursorDBE.project_id == project_id, + SessionSequenceCursorDBE.session_id == session_id, + ) + ) + sequences = list( + ( + await session.scalars( + select(RecordDBE.sequence) + .where(RecordDBE.session_id == session_id) + .order_by(RecordDBE.sequence) + ) + ).all() + ) + assert cursor == 2 + assert sequences == [1, 2] + finally: + async with get_analytics_engine().session() as session: + await session.execute( + delete(RecordDBE).where(RecordDBE.project_id == project_id) + ) + await session.execute( + delete(SessionSequenceCursorDBE).where( + SessionSequenceCursorDBE.project_id == project_id, + SessionSequenceCursorDBE.session_id == session_id, + ) + ) + + +async def test_sequence_allocation_is_scoped_by_project(): + project_ids = [uuid.uuid4(), uuid.uuid4()] + session_id = f"shared-session-{uuid.uuid4()}" + dao = RecordsDAO(engine=get_analytics_engine()) + + try: + records = [] + for project_id in project_ids: + records.append( + await dao.append( + event=SessionRecordEvent( + project_id=project_id, + session_id=session_id, + record_id=uuid.uuid4(), + record_type="message", + record_source="agent", + attributes={"type": "message", "text": "hello"}, + ) + ) + ) + + assert [record.sequence for record in records] == [1, 1] + + async with get_analytics_engine().session() as session: + cursors = list( + ( + await session.scalars( + select(SessionSequenceCursorDBE.latest_sequence) + .where( + SessionSequenceCursorDBE.project_id.in_(project_ids), + SessionSequenceCursorDBE.session_id == session_id, + ) + .order_by(SessionSequenceCursorDBE.project_id) + ) + ).all() + ) + + assert cursors == [1, 1] + finally: + async with get_analytics_engine().session() as session: + await session.execute( + delete(RecordDBE).where(RecordDBE.project_id.in_(project_ids)) + ) + await session.execute( + delete(SessionSequenceCursorDBE).where( + SessionSequenceCursorDBE.project_id.in_(project_ids), + SessionSequenceCursorDBE.session_id == session_id, + ) + ) + + +async def test_reverse_order_batches_lock_sessions_without_deadlock(monkeypatch): + project_id = uuid.uuid4() + session_ids = [f"lock-a-{uuid.uuid4()}", f"lock-b-{uuid.uuid4()}"] + dao = RecordsDAO(engine=get_analytics_engine()) + original_append = RecordsDAO._append_sequenced + append_counts = {} + + async def append_with_first_lock_pause(*, values, session): + record = await original_append(values=values, session=session) + task = asyncio.current_task() + append_counts[task] = append_counts.get(task, 0) + 1 + if append_counts[task] == 1: + await asyncio.sleep(0.1) + return record + + monkeypatch.setattr( + RecordsDAO, + "_append_sequenced", + staticmethod(append_with_first_lock_pause), + ) + + def event(session_id: str, record_index: int) -> SessionRecordEvent: + return SessionRecordEvent( + project_id=project_id, + session_id=session_id, + record_id=uuid.uuid4(), + record_index=record_index, + record_type="message", + record_source="agent", + attributes={"type": "message", "text": session_id}, + ) + + first_batch = [event(session_ids[0], 0), event(session_ids[1], 0)] + second_batch = [event(session_ids[1], 1), event(session_ids[0], 1)] + + try: + first, second = await asyncio.wait_for( + asyncio.gather( + dao.append_many(events=first_batch), + dao.append_many(events=second_batch), + ), + timeout=5, + ) + + assert len(first) == 2 + assert len(second) == 2 + async with get_analytics_engine().session() as session: + rows = ( + await session.execute( + select(RecordDBE.session_id, RecordDBE.sequence) + .where(RecordDBE.project_id == project_id) + .order_by(RecordDBE.session_id, RecordDBE.sequence) + ) + ).all() + assert rows == [ + (session_ids[0], 1), + (session_ids[0], 2), + (session_ids[1], 1), + (session_ids[1], 2), + ] + finally: + async with get_analytics_engine().session() as session: + await session.execute( + delete(RecordDBE).where(RecordDBE.project_id == project_id) + ) + await session.execute( + delete(SessionSequenceCursorDBE).where( + SessionSequenceCursorDBE.project_id == project_id, + SessionSequenceCursorDBE.session_id.in_(session_ids), + ) + ) diff --git a/api/oss/tests/pytest/integration/sessions/test_records_snapshot_postgres.py b/api/oss/tests/pytest/integration/sessions/test_records_snapshot_postgres.py new file mode 100644 index 00000000000..71b9a383021 --- /dev/null +++ b/api/oss/tests/pytest/integration/sessions/test_records_snapshot_postgres.py @@ -0,0 +1,81 @@ +import uuid + +import pytest +import pytest_asyncio +from sqlalchemy import delete + +import oss.src.dbs.postgres.shared.engine as engine_module +from oss.src.core.sessions.records.dtos import SessionRecordEvent +from oss.src.dbs.postgres.sessions.records.dao import RecordsDAO +from oss.src.dbs.postgres.sessions.records.dbes import ( + RecordDBE, + SessionSequenceCursorDBE, +) +from oss.src.dbs.postgres.shared.engine import get_analytics_engine +from oss.src.utils.env import env + + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration] + + +@pytest_asyncio.fixture(autouse=True) +async def _fresh_analytics_engine(monkeypatch): + monkeypatch.setattr(env.sessions, "sequence_writes", True) + engine_module._analytics_engine = None + yield + if engine_module._analytics_engine is not None: + await engine_module._analytics_engine.close() + engine_module._analytics_engine = None + + +async def test_snapshot_watermark_pages_transcript_without_admitting_later_rows(): + project_id = uuid.uuid4() + session_id = f"snapshot-{uuid.uuid4()}" + dao = RecordsDAO(engine=get_analytics_engine()) + + def event(text: str) -> SessionRecordEvent: + return SessionRecordEvent( + project_id=project_id, + session_id=session_id, + record_id=uuid.uuid4(), + record_type="message", + record_source="agent", + attributes={"type": "message", "text": text}, + ) + + try: + await dao.append_many(events=[event("one"), event("two")]) + read = await dao.get_read_state(project_id=project_id, session_id=session_id) + assert read.latest_sequence == 2 + assert read.history_complete is True + + first = await dao.get_records_page( + project_id=project_id, + session_id=session_id, + offset=0, + limit=1, + through_sequence=read.latest_sequence, + ) + await dao.append(event=event("later")) + second = await dao.get_records_page( + project_id=project_id, + session_id=session_id, + offset=first.next_offset, + limit=1, + through_sequence=read.latest_sequence, + ) + + assert [record.sequence for record in first.records] == [1] + assert [record.sequence for record in second.records] == [2] + assert second.next_offset is None + finally: + async with get_analytics_engine().session() as session: + await session.execute( + delete(RecordDBE).where(RecordDBE.project_id == project_id) + ) + await session.execute( + delete(SessionSequenceCursorDBE).where( + SessionSequenceCursorDBE.project_id == project_id, + SessionSequenceCursorDBE.session_id == session_id, + ) + ) diff --git a/api/oss/tests/pytest/unit/middlewares/test_auth_public_endpoints.py b/api/oss/tests/pytest/unit/middlewares/test_auth_public_endpoints.py new file mode 100644 index 00000000000..5108924ead3 --- /dev/null +++ b/api/oss/tests/pytest/unit/middlewares/test_auth_public_endpoints.py @@ -0,0 +1,35 @@ +import pytest +from starlette.requests import Request + +from oss.src.middlewares.auth import _check_authentication_token +from oss.src.utils.exceptions import UnauthorizedException + + +def _request(path: str) -> Request: + return Request( + { + "type": "http", + "method": "POST", + "path": path, + "headers": [], + "query_string": b"", + "scheme": "http", + "server": ("testserver", 80), + "root_path": "", + } + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("prefix", ["", "/api"]) +async def test_session_named_control_still_requires_project_auth(prefix): + with pytest.raises(UnauthorizedException): + await _check_authentication_token(_request(f"{prefix}/sessions/control/cancel")) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("prefix", ["", "/api"]) +async def test_runner_command_outcome_route_remains_auth_exempt(prefix): + await _check_authentication_token( + _request(f"{prefix}/sessions/control/commands/command-id/outcome") + ) diff --git a/api/oss/tests/pytest/unit/migrations/test_tracing_session_sequence_chain.py b/api/oss/tests/pytest/unit/migrations/test_tracing_session_sequence_chain.py new file mode 100644 index 00000000000..99b47f0d0d7 --- /dev/null +++ b/api/oss/tests/pytest/unit/migrations/test_tracing_session_sequence_chain.py @@ -0,0 +1,31 @@ +import ast +from pathlib import Path + + +VERSIONS_DIR = ( + Path(__file__).resolve().parents[4] + / "databases/postgres/migrations/tracing_oss/versions" +) + + +def _revision_link(path: Path) -> tuple[str, str]: + assignments = {} + for node in ast.parse(path.read_text()).body: + if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): + if node.target.id in {"revision", "down_revision"}: + assignments[node.target.id] = ast.literal_eval(node.value) + return assignments["revision"], assignments["down_revision"] + + +def test_tracing_chain_has_one_head_with_watchdog_migration(): + watchdog_migration = VERSIONS_DIR / "oss000000005_add_records_quarantined_at.py" + session_migration = VERSIONS_DIR / "oss000000006_add_session_sequence_cursors.py" + links = dict(map(_revision_link, (watchdog_migration, session_migration))) + + heads = set(links) - set(links.values()) + + assert links == { + "oss000000005": "oss000000004", + "oss000000006": "oss000000005", + } + assert heads == {"oss000000006"} diff --git a/api/oss/tests/pytest/unit/migrations/test_tracing_session_sequence_migration.py b/api/oss/tests/pytest/unit/migrations/test_tracing_session_sequence_migration.py new file mode 100644 index 00000000000..4788f4482c0 --- /dev/null +++ b/api/oss/tests/pytest/unit/migrations/test_tracing_session_sequence_migration.py @@ -0,0 +1,191 @@ +import asyncio +from pathlib import Path + +from alembic import command +from alembic.config import Config +import asyncpg +import pytest + +from oss.src.dbs.postgres.shared import config as postgres_config + + +DATABASE_NAME = "agenta_m2idx_tracing" +ADMIN_DSN = "postgresql://username:password@localhost:5444/postgres" +DATABASE_DSN = f"postgresql://username:password@localhost:5444/{DATABASE_NAME}" +SQLALCHEMY_URL = ( + f"postgresql+asyncpg://username:password@localhost:5444/{DATABASE_NAME}" +) +VERSIONS_ROOT = ( + Path(__file__).resolve().parents[4] / "databases/postgres/migrations/tracing_oss" +) + + +async def _recreate_database() -> None: + admin = await asyncpg.connect(ADMIN_DSN) + try: + await admin.execute( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity " + "WHERE datname=$1 AND pid<>pg_backend_pid()", + DATABASE_NAME, + ) + await admin.execute(f'DROP DATABASE IF EXISTS "{DATABASE_NAME}"') + await admin.execute(f'CREATE DATABASE "{DATABASE_NAME}"') + finally: + await admin.close() + + +async def _drop_database() -> None: + admin = await asyncpg.connect(ADMIN_DSN) + try: + await admin.execute( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity " + "WHERE datname=$1 AND pid<>pg_backend_pid()", + DATABASE_NAME, + ) + await admin.execute(f'DROP DATABASE IF EXISTS "{DATABASE_NAME}"') + finally: + await admin.close() + + +async def _prepare_records_table(row_count: int) -> None: + connection = await asyncpg.connect(DATABASE_DSN) + try: + await connection.execute( + """ + CREATE TABLE records ( + project_id UUID NOT NULL, + record_id UUID NOT NULL, + session_id VARCHAR NOT NULL, + payload INTEGER NOT NULL, + PRIMARY KEY (project_id, record_id) + ); + CREATE TABLE alembic_version_oss ( + version_num VARCHAR(32) NOT NULL + ); + INSERT INTO alembic_version_oss (version_num) + VALUES ('oss000000005'); + """ + ) + if row_count: + await connection.execute( + """ + INSERT INTO records (project_id, record_id, session_id, payload) + SELECT + '00000000-0000-0000-0000-000000000001'::uuid, + md5(value::text)::uuid, + 'session-' || (value % 32), + value + FROM generate_series(1, $1) AS value + """, + row_count, + ) + finally: + await connection.close() + + +async def _migration_result() -> tuple[int, int | None, int, str]: + connection = await asyncpg.connect(DATABASE_DSN) + try: + row = await connection.fetchrow( + """ + SELECT + count(*) AS row_count, + sum(payload) AS payload_sum, + count(*) FILTER (WHERE sequence IS NULL) AS null_sequences + FROM records + """ + ) + index_definition = await connection.fetchval( + """ + SELECT indexdef + FROM pg_indexes + WHERE schemaname = 'public' + AND tablename = 'records' + AND indexname = 'ux_records_session_id_sequence' + """ + ) + return ( + row["row_count"], + row["payload_sum"], + row["null_sequences"], + index_definition, + ) + finally: + await connection.close() + + +async def _downgrade_result() -> tuple[int, int | None, bool, bool, bool]: + connection = await asyncpg.connect(DATABASE_DSN) + try: + row = await connection.fetchrow( + "SELECT count(*) AS row_count, sum(payload) AS payload_sum FROM records" + ) + index_exists = await connection.fetchval( + "SELECT to_regclass('public.ux_records_session_id_sequence') IS NOT NULL" + ) + sequence_exists = await connection.fetchval( + """ + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'records' + AND column_name = 'sequence' + ) + """ + ) + cursor_table_exists = await connection.fetchval( + "SELECT to_regclass('public.session_sequence_cursors') IS NOT NULL" + ) + return ( + row["row_count"], + row["payload_sum"], + index_exists, + sequence_exists, + cursor_table_exists, + ) + finally: + await connection.close() + + +@pytest.fixture +def scratch_tracing_database(): + try: + asyncio.run(_recreate_database()) + except (OSError, asyncpg.PostgresConnectionError) as exc: + pytest.skip(f"scratch Postgres is unavailable: {exc}") + + try: + yield + finally: + asyncio.run(_drop_database()) + + +def test_session_sequence_migration_preserves_records_and_creates_index( + monkeypatch, + scratch_tracing_database, +): + monkeypatch.setattr(postgres_config, "POSTGRES_URI_TRACING", SQLALCHEMY_URL) + + for row_count in (0, 4096): + asyncio.run(_recreate_database()) + asyncio.run(_prepare_records_table(row_count)) + + alembic_config = Config() + alembic_config.set_main_option("script_location", str(VERSIONS_ROOT)) + command.upgrade(alembic_config, "oss000000006") + + actual_count, payload_sum, null_sequences, index_definition = asyncio.run( + _migration_result() + ) + expected_sum = row_count * (row_count + 1) // 2 if row_count else None + assert actual_count == row_count + assert payload_sum == expected_sum + assert null_sequences == row_count + assert index_definition is not None + assert "UNIQUE INDEX" in index_definition + assert "(project_id, session_id, sequence)" in index_definition + + command.downgrade(alembic_config, "oss000000005") + downgrade_result = asyncio.run(_downgrade_result()) + assert downgrade_result == (row_count, expected_sum, False, False, False) diff --git a/api/oss/tests/pytest/unit/sessions/test_cancel_cancels_pending_interactions.py b/api/oss/tests/pytest/unit/sessions/test_cancel_cancels_pending_interactions.py new file mode 100644 index 00000000000..05dae17f5a4 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_cancel_cancels_pending_interactions.py @@ -0,0 +1,224 @@ +"""Stop cancels the stopped turn's pending interactions. + +`requirements.md:149` asks for it and only KILL did it (`delete_session_stream` calls +`cancel_session_pending`). The CANCEL branch did not, so a stopped session kept an approval card +whose buttons answered a turn that no longer existed (#6315). + +These pin the router wiring: CANCEL cancels pending gates for the turns it ended, SEND / STEER / +ATTACH do not, and a cancel that ended no turn falls back to the whole session (nothing holds +it, so nothing can ever answer those gates — the same reasoning as kill). +""" + +from unittest.mock import AsyncMock, patch +from uuid import uuid4 + +import pytest +from fastapi import FastAPI, Request + +from oss.src.apis.fastapi.sessions.router import SessionStreamsRouter +from oss.src.core.sessions.streams.dtos import ( + CommandMode, + SessionStreamCommandRequest, + SessionStreamCommandResponse, +) + + +_SESSION = "session_stop-gates" + + +def _make_authed_request(app: FastAPI, project_id, user_id) -> Request: + scope = { + "type": "http", + "method": "POST", + "path": "/sessions/streams/", + "headers": [], + "app": app, + } + request = Request(scope) + request.state.project_id = str(project_id) + request.state.user_id = str(user_id) + return request + + +def _patched_access(allowed: bool): + return patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=allowed, + ) + + +# The route derives the mode from the PAYLOAD, not from the service's answer, because it must know +# whether this is a cancel before it runs the concurrency check. So each payload below is the real +# inputs x force combination for its mode, not a stub of one. +_CANCEL = SessionStreamCommandRequest(session_id=_SESSION) +_ATTACH = SessionStreamCommandRequest(session_id=_SESSION, force=True) +_SEND = SessionStreamCommandRequest( + session_id=_SESSION, + data={"inputs": {"messages": [{"role": "user", "content": "go"}]}}, +) +_STEER = SessionStreamCommandRequest( + session_id=_SESSION, + force=True, + data={"inputs": {"messages": [{"role": "user", "content": "go"}]}}, +) + + +async def _post( + response: SessionStreamCommandResponse, + payload, + *, + cleanup_error: Exception | None = None, +): + """Drive the route with a stubbed service that returns `response`.""" + service = AsyncMock() + service.clock_ms.return_value = 1_000 + service.command.return_value = response + interactions = AsyncMock() + interactions.cancel_session_pending.side_effect = cleanup_error + interactions.cancel_session_pending.return_value = 1 + router = SessionStreamsRouter(service=service, interactions_service=interactions) + + project_id = uuid4() + user_id = uuid4() + app = FastAPI() + request = _make_authed_request(app, project_id, user_id) + + with _patched_access(True): + result = await router.set_session_stream(request=request, payload=payload) + return result, interactions, project_id, service + + +@pytest.mark.asyncio +async def test_cancel_cancels_pending_gates_of_the_cancelled_turn(): + result, interactions, project_id, _ = await _post( + SessionStreamCommandResponse( + mode=CommandMode.cancel, + session_id=_SESSION, + turn_id="turn-1", + cancelled_turn_ids=["turn-1"], + detached=True, + ), + _CANCEL, + ) + + assert result.mode == CommandMode.cancel + interactions.cancel_session_pending.assert_awaited_once() + kwargs = interactions.cancel_session_pending.await_args.kwargs + assert kwargs["project_id"] == project_id + assert kwargs["session_id"] == _SESSION + assert kwargs["only_turn_id"] == "turn-1" + + +@pytest.mark.asyncio +async def test_cancel_that_ended_no_turn_cancels_every_pending_gate(): + _, interactions, _, _ = await _post( + SessionStreamCommandResponse( + mode=CommandMode.cancel, + session_id=_SESSION, + cancelled_turn_ids=[], + detached=True, + ), + _CANCEL, + ) + + interactions.cancel_session_pending.assert_awaited_once() + assert "only_turn_id" not in interactions.cancel_session_pending.await_args.kwargs + + +@pytest.mark.asyncio +async def test_cancel_returns_the_accepted_response_when_gate_cleanup_fails(): + response = SessionStreamCommandResponse( + mode=CommandMode.cancel, + session_id=_SESSION, + turn_id="turn-1", + cancelled_turn_ids=["turn-1"], + detached=True, + ) + + result, interactions, _, _ = await _post( + response, + _CANCEL, + cleanup_error=RuntimeError("cleanup unavailable"), + ) + + assert result == response + interactions.cancel_session_pending.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_cancel_scopes_each_call_to_one_turn(): + """`alive` and `running` can be held by different turns during a handover; both die.""" + _, interactions, _, _ = await _post( + SessionStreamCommandResponse( + mode=CommandMode.cancel, + session_id=_SESSION, + turn_id="turn-1", + cancelled_turn_ids=["turn-1", "turn-2"], + detached=True, + ), + _CANCEL, + ) + + assert interactions.cancel_session_pending.await_count == 2 + targeted = [ + call.kwargs["only_turn_id"] + for call in interactions.cancel_session_pending.await_args_list + ] + assert targeted == ["turn-1", "turn-2"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "mode,payload", + [ + (CommandMode.send, _SEND), + (CommandMode.steer, _STEER), + (CommandMode.attach, _ATTACH), + ], +) +async def test_non_cancel_modes_leave_pending_gates_alone(mode, payload): + """A steer's own turn-start sweep owns the prior turn's gates. Stop must not duplicate it.""" + _, interactions, _, _ = await _post( + SessionStreamCommandResponse( + mode=mode, session_id=_SESSION, turn_id="turn-9", detached=False + ), + payload, + ) + + interactions.cancel_session_pending.assert_not_awaited() + + +# --------------------------------------------------------------------------- # +# The concurrency limit must not refuse a Stop +# --------------------------------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_cancel_skips_the_concurrency_limit(): + """A project at its limit must still be able to stop the runs that hold the limit.""" + _, _, _, service = await _post( + SessionStreamCommandResponse( + mode=CommandMode.cancel, + session_id=_SESSION, + turn_id="turn-1", + cancelled_turn_ids=["turn-1"], + detached=True, + ), + _CANCEL, + ) + + service.check_runner_concurrency_limit.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("payload", [_SEND, _STEER, _ATTACH]) +async def test_every_other_mode_still_checks_the_concurrency_limit(payload): + _, _, _, service = await _post( + SessionStreamCommandResponse( + mode=CommandMode.send, session_id=_SESSION, turn_id="turn-1" + ), + payload, + ) + + service.check_runner_concurrency_limit.assert_awaited_once() diff --git a/api/oss/tests/pytest/unit/sessions/test_cancel_stop_guard.py b/api/oss/tests/pytest/unit/sessions/test_cancel_stop_guard.py new file mode 100644 index 00000000000..e033293b71f --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_cancel_stop_guard.py @@ -0,0 +1,454 @@ +"""The Stop guard: a cancel must not kill a turn the caller never meant to cancel. + +Before this, CANCEL called `_displace_turns` unconditionally, which tombstones whichever turn +holds `alive`/`running` at that instant. A Stop pressed for turn one but applied after turn one +ended and turn two started therefore killed turn two, and the tombstone lives for +SUPERSEDED_TTL_SECONDS with a refresh on every read — so the session stays wedged (#6417). + +Two guards close it, in order of strength: + + 1. `expected_execution_id` on the request names the turn. The public DTO keeps the RFC's + name; internally it IS a turn id. A different turn holding the session means the turn the + caller meant is gone: refuse with `SessionTurnMismatch` (409) and touch nothing. + 2. With no id, refuse when a holding turn started AFTER the request arrived. This needs the + turn-start key the coordination plane now records, because nothing else knows when a turn + began early enough to be useful. + +Also covered: cancel reports the turns it ended, which is what lets the router cancel exactly +those turns' pending gates. +""" + +from typing import Optional +from unittest.mock import patch +from uuid import UUID, uuid4 + +import pytest +import pytest_asyncio + +from oss.src.core.sessions.streams.dtos import ( + CommandMode, + SessionHeartbeatRequest, + SessionStream, + SessionStreamCommandRequest, +) +from oss.src.core.sessions.streams.service import SessionStreamsService +from oss.src.core.sessions.streams.types import SessionTurnMismatch +from oss.src.dbs.redis.sessions.locks import ( + acquire_alive, + acquire_running, + get_alive_owner, + get_running_owner, + is_turn_superseded, + record_turn_start, +) + +from unit.sessions.test_project_scoped_locks import _FakeRedis + + +_PROJECT = uuid4() +_USER = uuid4() +_SESSION = "session_stop-guard" + + +class _FakeStreamsDAO: + """Enough of the streams DAO for the cancel path: read, create, update.""" + + def __init__(self, existing: Optional[SessionStream] = None): + self.row = existing + + async def get_by_session_id(self, *, project_id: UUID, session_id: str): + return self.row + + async def create(self, *, project_id, user_id, stream): + self.row = SessionStream( + id=uuid4(), + project_id=project_id, + session_id=stream.session_id, + flags=stream.flags, + turn_id=stream.turn_id, + ) + return self.row + + async def update(self, *, project_id, user_id, session_id, stream): + prior = self.row + self.row = SessionStream( + id=prior.id if prior else uuid4(), + project_id=project_id, + session_id=session_id, + flags=stream.flags + if stream.flags is not None + else (prior.flags if prior else None), + turn_id=stream.turn_id + if stream.turn_id is not None + else (prior.turn_id if prior else None), + ) + return self.row + + async def fill_missing(self, *, project_id, session_id, name=None, references=None): + return self.row + + async def unarchive_by_session_id(self, *, project_id, user_id, session_id): + return self.row + + async def clear_archived_by_session_id(self, *, project_id, user_id, session_id): + return self.row + + async def delete_by_session_id(self, *, project_id, session_id): + return True + + +@pytest_asyncio.fixture +async def lock_engine(): + from oss.src.dbs.redis.shared.engine import LockEngine + + eng = LockEngine() + with patch.object(eng, "_client", return_value=_FakeRedis()): + yield eng + + +def _service(lock_engine, dao=None): + return SessionStreamsService( + streams_dao=dao or _FakeStreamsDAO(), lock_engine=lock_engine + ) + + +def _cancel(expected: Optional[str] = None) -> SessionStreamCommandRequest: + """A Stop: no inputs, force=False. That is what the browser sends.""" + return SessionStreamCommandRequest( + session_id=_SESSION, + expected_execution_id=expected, + ) + + +async def _seat_turn(lock_engine, turn_id: str, started_at_ms: Optional[int] = None): + """Put `turn_id` in the nest the way a running turn holds it, with a start time.""" + await acquire_alive( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION, turn_id=turn_id + ) + await acquire_running( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION, turn_id=turn_id + ) + await record_turn_start( + lock_engine, + project_id=str(_PROJECT), + session_id=_SESSION, + turn_id=turn_id, + started_at_ms=started_at_ms, + ) + + +# --------------------------------------------------------------------------- # +# Guard 1 — expected_execution_id +# --------------------------------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_cancel_with_matching_expected_id_cancels_that_turn(lock_engine): + svc = _service(lock_engine) + await _seat_turn(lock_engine, "turn-1", started_at_ms=1_000) + + result = await svc.command( + project_id=_PROJECT, user_id=_USER, request=_cancel("turn-1") + ) + + assert result.mode == CommandMode.cancel + assert result.turn_id == "turn-1" + assert result.cancelled_turn_ids == ["turn-1"] + assert await is_turn_superseded( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION, turn_id="turn-1" + ) + assert ( + await get_alive_owner( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION + ) + is None + ) + + +@pytest.mark.asyncio +async def test_cancel_with_stale_expected_id_is_refused_and_touches_nothing( + lock_engine, +): + """The headline case: the Stop names turn one, turn two now holds the session.""" + svc = _service(lock_engine) + await _seat_turn(lock_engine, "turn-2", started_at_ms=2_000) + + with pytest.raises(SessionTurnMismatch) as excinfo: + await svc.command(project_id=_PROJECT, user_id=_USER, request=_cancel("turn-1")) + + assert excinfo.value.expected_turn_id == "turn-1" + assert excinfo.value.actual_turn_id == "turn-2" + + # Turn two keeps the whole nest and is NOT tombstoned — that is the bug this closes. + assert ( + await get_alive_owner( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION + ) + == "turn-2" + ) + assert ( + await get_running_owner( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION + ) + == "turn-2" + ) + assert not await is_turn_superseded( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION, turn_id="turn-2" + ) + + +@pytest.mark.asyncio +async def test_cancel_refuses_owner_replaced_immediately_before_atomic_displacement( + lock_engine, monkeypatch +): + svc = _service(lock_engine) + await _seat_turn(lock_engine, "turn-1", started_at_ms=1_000) + redis = lock_engine._client() + original_eval = redis.eval + + async def replace_owner_then_eval(script, numkeys, *keys_and_args): + if "AGENTA_DISPLACE_TURNS" in script: + await redis.set(keys_and_args[0], b"turn-2", ex=60) + await redis.set(keys_and_args[1], b"turn-2", ex=60) + return await original_eval(script, numkeys, *keys_and_args) + + monkeypatch.setattr(redis, "eval", replace_owner_then_eval) + + with pytest.raises(SessionTurnMismatch) as excinfo: + await svc.command(project_id=_PROJECT, user_id=_USER, request=_cancel("turn-1")) + + assert excinfo.value.actual_turn_id == "turn-2" + assert ( + await get_alive_owner( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION + ) + == "turn-2" + ) + assert ( + await get_running_owner( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION + ) + == "turn-2" + ) + assert not await is_turn_superseded( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION, turn_id="turn-2" + ) + + +@pytest.mark.asyncio +async def test_cancel_with_expected_id_tombstones_a_turn_that_holds_nothing( + lock_engine, +): + """A named turn whose beat is still in flight must not be able to re-take the session.""" + svc = _service(lock_engine) + + result = await svc.command( + project_id=_PROJECT, user_id=_USER, request=_cancel("turn-1") + ) + + assert result.cancelled_turn_ids == ["turn-1"] + assert await is_turn_superseded( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION, turn_id="turn-1" + ) + + +@pytest.mark.asyncio +async def test_blank_expected_id_is_read_as_absent(lock_engine): + """A whitespace guard is a client bug. Reading it as "no guard" is the safe failure.""" + request = SessionStreamCommandRequest( + session_id=_SESSION, expected_execution_id=" " + ) + assert request.expected_execution_id is None + + +# --------------------------------------------------------------------------- # +# Guard 2 — arrival time, for callers that send no id +# --------------------------------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_cancel_without_id_refuses_a_turn_that_started_after_it_arrived( + lock_engine, +): + svc = _service(lock_engine) + # Far in the future relative to this cancel's arrival: the turn began after the ask. + await _seat_turn(lock_engine, "turn-2", started_at_ms=4_000_000_000_000) + + with pytest.raises(SessionTurnMismatch) as excinfo: + await svc.command(project_id=_PROJECT, user_id=_USER, request=_cancel()) + + assert excinfo.value.expected_turn_id is None + assert excinfo.value.actual_turn_id == "turn-2" + assert ( + await get_alive_owner( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION + ) + == "turn-2" + ) + assert not await is_turn_superseded( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION, turn_id="turn-2" + ) + + +@pytest.mark.asyncio +async def test_cancel_without_id_still_cancels_a_turn_that_started_earlier(lock_engine): + svc = _service(lock_engine) + await _seat_turn(lock_engine, "turn-1", started_at_ms=1_000) + + result = await svc.command(project_id=_PROJECT, user_id=_USER, request=_cancel()) + + assert result.cancelled_turn_ids == ["turn-1"] + assert await is_turn_superseded( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION, turn_id="turn-1" + ) + + +@pytest.mark.asyncio +async def test_cancel_without_id_still_cancels_a_turn_with_no_recorded_start( + lock_engine, +): + """Unknown must mean unknown, never "new". A running pre-deploy turn stays stoppable.""" + svc = _service(lock_engine) + await acquire_alive( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION, turn_id="turn-old" + ) + await acquire_running( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION, turn_id="turn-old" + ) + + result = await svc.command(project_id=_PROJECT, user_id=_USER, request=_cancel()) + + assert result.cancelled_turn_ids == ["turn-old"] + + +@pytest.mark.asyncio +async def test_cancel_ordering_uses_the_shared_redis_clock(lock_engine): + svc = _service(lock_engine) + redis = lock_engine._client() + redis.now_ms = 10_000 + arrived_at_ms = await svc.clock_ms() + redis.now_ms = 11_000 + await _seat_turn(lock_engine, "turn-2") + + with pytest.raises(SessionTurnMismatch): + await svc.command( + project_id=_PROJECT, + user_id=_USER, + request=_cancel(), + arrived_at_ms=arrived_at_ms, + ) + + assert ( + await get_alive_owner( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION + ) + == "turn-2" + ) + + +# --------------------------------------------------------------------------- # +# The turn-start record itself +# --------------------------------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_start_turn_records_a_start_time(lock_engine): + from oss.src.dbs.redis.sessions.locks import get_turn_start + + svc = _service(lock_engine) + turn_id = await svc._start_turn( + project_id=_PROJECT, user_id=_USER, session_id=_SESSION + ) + + assert ( + await get_turn_start( + lock_engine, + project_id=str(_PROJECT), + session_id=_SESSION, + turn_id=turn_id, + ) + is not None + ) + + +@pytest.mark.asyncio +async def test_heartbeat_records_a_start_time_for_a_runner_minted_turn(lock_engine): + """A browser turn's id is minted by the runner, so its first beat is where it is stamped.""" + from oss.src.dbs.redis.sessions.locks import get_turn_start + + svc = _service(lock_engine) + await svc.heartbeat( + project_id=_PROJECT, + request=SessionHeartbeatRequest( + session_id=_SESSION, replica_id="replica-a", turn_id="turn-runner" + ), + ) + + assert ( + await get_turn_start( + lock_engine, + project_id=str(_PROJECT), + session_id=_SESSION, + turn_id="turn-runner", + ) + is not None + ) + + +@pytest.mark.asyncio +async def test_turn_start_is_written_once(lock_engine): + """Later beats refresh the record, never move it: the guard needs the FIRST start.""" + from oss.src.dbs.redis.sessions.locks import get_turn_start + + first = await record_turn_start( + lock_engine, + project_id=str(_PROJECT), + session_id=_SESSION, + turn_id="turn-1", + started_at_ms=1_000, + ) + second = await record_turn_start( + lock_engine, + project_id=str(_PROJECT), + session_id=_SESSION, + turn_id="turn-1", + started_at_ms=9_000, + ) + + assert first == 1_000 + assert second == 1_000 + assert ( + await get_turn_start( + lock_engine, + project_id=str(_PROJECT), + session_id=_SESSION, + turn_id="turn-1", + ) + == 1_000 + ) + + +# --------------------------------------------------------------------------- # +# Steer and kill are not guarded — both mean "take this session from whoever has it" +# --------------------------------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_steer_is_not_subject_to_the_guard(lock_engine): + svc = _service(lock_engine) + await _seat_turn(lock_engine, "turn-2", started_at_ms=4_000_000_000_000) + + result = await svc.command( + project_id=_PROJECT, + user_id=_USER, + request=SessionStreamCommandRequest( + session_id=_SESSION, + force=True, + data={"inputs": {"messages": [{"role": "user", "content": "again"}]}}, + ), + ) + + assert result.mode == CommandMode.steer + assert await is_turn_superseded( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION, turn_id="turn-2" + ) diff --git a/api/oss/tests/pytest/unit/sessions/test_command_claim_unknown_kind.py b/api/oss/tests/pytest/unit/sessions/test_command_claim_unknown_kind.py new file mode 100644 index 00000000000..849d0237290 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_command_claim_unknown_kind.py @@ -0,0 +1,89 @@ +"""A runner's claim must take the commands it understands past a row it cannot map. + +`claim_commands` returned `[map_command_dbe_to_dto(dbe) for dbe in claimed]`, the same batch +map that poisoned the abandoned-command sweep: a newer API replica can write a command `kind` +this older replica's enum does not know, and `map_command_dbe_to_dto` raises `ValueError` on +that row. One such row in a claimed batch would have thrown away the whole claim, including a +Stop the runner could act on. The claim path now maps through +`_map_commands_skipping_unmappable`, which skips the rows this API cannot map, warns once per +batch, and returns the rest. +""" + +from datetime import datetime, timezone +from uuid import uuid4 + +from types import SimpleNamespace + +from oss.src.core.sessions.commands.dtos import ( + SessionCommandKind, + SessionCommandState, +) +from oss.src.dbs.postgres.sessions.commands import dao as commands_dao + + +def _row(kind: str): + """A claimed command row as the DAO reads it, with a given `kind` string.""" + return SimpleNamespace( + id=uuid4(), + created_at=datetime.now(timezone.utc), + updated_at=None, + deleted_at=None, + created_by_id=None, + updated_by_id=None, + deleted_by_id=None, + project_id=uuid4(), + session_id="sess-" + kind, + kind=kind, + target_turn_id="turn-1", + expected_turn_id=None, + data=None, + state=SessionCommandState.claimed.value, + claimed_by="runner-1", + claim_expires_at=datetime.now(timezone.utc), + claim_count=1, + outcome=None, + idempotency_key=None, + settled_at=None, + tags=None, + meta=None, + ) + + +class _RecordingLog: + def __init__(self): + self.warnings = [] + + def warning(self, *args, **kwargs): + self.warnings.append((args, kwargs)) + + +def test_a_claimable_stop_survives_an_unknown_kind_in_the_batch(monkeypatch): + recorder = _RecordingLog() + monkeypatch.setattr(commands_dao, "log", recorder) + + stop = _row(SessionCommandKind.cancel.value) + unknown = _row("continue_interaction") + + mapped = commands_dao._map_commands_skipping_unmappable( + [stop, unknown], context="claimed" + ) + + # The Stop is handed to the runner; the unknown row is left for a replica that knows it. + assert [c.id for c in mapped] == [stop.id] + assert unknown.id not in {c.id for c in mapped} + + +def test_the_claim_warning_names_the_claimed_context_and_the_kind(monkeypatch): + recorder = _RecordingLog() + monkeypatch.setattr(commands_dao, "log", recorder) + + commands_dao._map_commands_skipping_unmappable( + [_row(SessionCommandKind.cancel.value), _row("continue_interaction")], + context="claimed", + ) + + assert len(recorder.warnings) == 1 + args = recorder.warnings[0][0] + assert args[1] == 1 # one unmappable row + assert args[2] == "claimed" # the batch context + assert "continue_interaction=1" in args[3] diff --git a/api/oss/tests/pytest/unit/sessions/test_command_matrix_inputs_data.py b/api/oss/tests/pytest/unit/sessions/test_command_matrix_inputs_data.py index d260c85e830..84b6e854003 100644 --- a/api/oss/tests/pytest/unit/sessions/test_command_matrix_inputs_data.py +++ b/api/oss/tests/pytest/unit/sessions/test_command_matrix_inputs_data.py @@ -23,13 +23,20 @@ from agenta.sdk.models.workflows import WorkflowServiceRequestData +from oss.src.apis.fastapi.sessions.models import SessionCancelRequest from oss.src.core.sessions.streams.dtos import ( CommandMode, SessionStream, SessionStreamCommandRequest, ) from oss.src.core.sessions.streams.service import SessionStreamsService -from oss.src.core.sessions.streams.types import SessionTurnInUse +from oss.src.core.sessions.streams.types import SessionTurnInUse, SessionTurnMismatch +from oss.src.dbs.redis.sessions.locks import ( + acquire_alive, + get_alive_owner, + get_running_owner, + is_turn_superseded, +) from unit.sessions.test_project_scoped_locks import _FakeRedis @@ -158,6 +165,148 @@ async def test_no_inputs_no_force_is_cancel(lock_engine): assert result.mode == CommandMode.cancel +@pytest.mark.asyncio +async def test_unfenced_cancel_before_new_turn_admission_ignores_the_parked_owner( + lock_engine, +): + session_id = _session_id() + await acquire_alive( + lock_engine, + project_id=str(_PROJECT), + session_id=session_id, + turn_id="turn-A", + ) + existing = SessionStream( + id=uuid4(), + project_id=_PROJECT, + session_id=session_id, + turn_id="turn-A", + ) + dao = _FakeStreamsDAO(existing) + svc = _service(lock_engine, dao=dao) + + # Turn B was submitted by the browser but has not reached `_start_turn` yet. + result = await svc.command( + project_id=_PROJECT, + user_id=_USER, + request=SessionStreamCommandRequest(session_id=session_id), + ) + + assert result.mode == CommandMode.cancel + assert result.cancelled_turn_ids == [] + assert dao.row == existing + assert ( + await get_alive_owner( + lock_engine, project_id=str(_PROJECT), session_id=session_id + ) + == "turn-A" + ) + assert ( + await get_running_owner( + lock_engine, project_id=str(_PROJECT), session_id=session_id + ) + is None + ) + assert not await is_turn_superseded( + lock_engine, + project_id=str(_PROJECT), + session_id=session_id, + turn_id="turn-A", + ) + + +@pytest.mark.asyncio +async def test_unfenced_cancel_targets_the_turn_once_it_is_running(lock_engine): + dao = _FakeStreamsDAO() + svc = _service(lock_engine, dao=dao) + session_id = _session_id() + started = await svc.command( + project_id=_PROJECT, + user_id=_USER, + request=SessionStreamCommandRequest( + session_id=session_id, + data=WorkflowServiceRequestData(inputs={"messages": ["hi"]}), + ), + ) + + result = await svc.command( + project_id=_PROJECT, + user_id=_USER, + request=SessionStreamCommandRequest(session_id=session_id), + ) + + assert started.turn_id is not None + assert result.cancelled_turn_ids == [started.turn_id] + assert ( + await get_alive_owner( + lock_engine, project_id=str(_PROJECT), session_id=session_id + ) + is None + ) + assert ( + await get_running_owner( + lock_engine, project_id=str(_PROJECT), session_id=session_id + ) + is None + ) + assert await is_turn_superseded( + lock_engine, + project_id=str(_PROJECT), + session_id=session_id, + turn_id=started.turn_id, + ) + + +@pytest.mark.asyncio +async def test_cancel_with_a_stale_execution_guard_touches_no_holder(lock_engine): + svc = _service(lock_engine) + session_id = _session_id() + started = await svc.command( + project_id=_PROJECT, + user_id=_USER, + request=SessionStreamCommandRequest( + session_id=session_id, + data=WorkflowServiceRequestData(inputs={"messages": ["first"]}), + ), + ) + + with pytest.raises(SessionTurnMismatch): + await svc.command( + project_id=_PROJECT, + user_id=_USER, + request=SessionStreamCommandRequest( + session_id=session_id, + expected_execution_id="another-turn", + ), + ) + + assert ( + await get_alive_owner( + lock_engine, + project_id=str(_PROJECT), + session_id=session_id, + ) + == started.turn_id + ) + assert ( + await get_running_owner( + lock_engine, + project_id=str(_PROJECT), + session_id=session_id, + ) + == started.turn_id + ) + + +def test_expected_execution_id_schema_documents_cancel_only_guard(): + description = SessionCancelRequest.model_json_schema()["properties"][ + "expected_execution_id" + ]["description"] + + assert "only in cancel mode" in description + assert "ignored for send, steer, and attach" in description + + @pytest.mark.asyncio async def test_no_inputs_and_force_is_attach(lock_engine): svc = _service(lock_engine) diff --git a/api/oss/tests/pytest/unit/sessions/test_command_settle_unknown_kind.py b/api/oss/tests/pytest/unit/sessions/test_command_settle_unknown_kind.py new file mode 100644 index 00000000000..3bc171e3134 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_command_settle_unknown_kind.py @@ -0,0 +1,112 @@ +"""The watchdog must settle the commands it understands past a row it cannot map. + +A newer API replica can write a command `kind` (or state, or outcome) an older replica's +enums do not know. On the integration stack a `continue_interaction` row (increment 6, not on +this head) sat in the claimed table next to an abandoned Stop. The abandoned-command sweep +mapped the whole batch to DTOs before it settled any of it, and `map_command_dbe_to_dto` +raised `ValueError: 'continue_interaction' is not a valid SessionCommandKind` on that one row. +The ValueError escaped the batch, so NO command was settled and the Stop stayed pending pass +after pass. + +`_map_commands_skipping_unmappable` now skips the rows this API cannot map, warns once with the kinds and +count, and returns the rest. These tests hold that contract: the known Stop survives as a +settle candidate, the unknown row is dropped and left for a replica that knows its kind, and +the skip is logged exactly once. +""" + +from datetime import datetime, timezone +from types import SimpleNamespace +from uuid import uuid4 + +from oss.src.core.sessions.commands.dtos import ( + SessionCommandKind, + SessionCommandState, +) +from oss.src.dbs.postgres.sessions.commands import dao as commands_dao + + +def _row(kind: str): + """A claimed, abandoned command row as the DAO reads it, with a given `kind` string.""" + return SimpleNamespace( + id=uuid4(), + created_at=datetime.now(timezone.utc), + updated_at=None, + deleted_at=None, + created_by_id=None, + updated_by_id=None, + deleted_by_id=None, + project_id=uuid4(), + session_id="sess-" + kind, + kind=kind, + target_turn_id="turn-1", + expected_turn_id=None, + data=None, + state=SessionCommandState.claimed.value, + claimed_by="runner-1", + claim_expires_at=datetime.now(timezone.utc), + claim_count=1, + outcome=None, + idempotency_key=None, + settled_at=None, + tags=None, + meta=None, + ) + + +class _RecordingLog: + def __init__(self): + self.warnings = [] + + def warning(self, *args, **kwargs): + self.warnings.append((args, kwargs)) + + +def test_a_known_stop_survives_and_an_unknown_kind_is_left_alone(monkeypatch): + recorder = _RecordingLog() + monkeypatch.setattr(commands_dao, "log", recorder) + + stop = _row(SessionCommandKind.cancel.value) + unknown = _row("continue_interaction") + + mapped = commands_dao._map_commands_skipping_unmappable( + [stop, unknown], context="abandoned" + ) + + # The Stop is returned, so the sweep will settle it. + assert [c.id for c in mapped] == [stop.id] + assert mapped[0].kind is SessionCommandKind.cancel + # The unknown-kind row is dropped, not settled -- left for a replica that knows its kind. + assert unknown.id not in {c.id for c in mapped} + + +def test_the_unknown_kind_is_warned_once_with_its_kind_and_count(monkeypatch): + recorder = _RecordingLog() + monkeypatch.setattr(commands_dao, "log", recorder) + + rows = [ + _row(SessionCommandKind.cancel.value), + _row("continue_interaction"), + _row("continue_interaction"), + ] + + commands_dao._map_commands_skipping_unmappable(rows, context="abandoned") + + assert len(recorder.warnings) == 1, "exactly one warning per pass" + args = recorder.warnings[0][0] + # The message and its args name the count, the batch context, and the offending kind. + assert args[1] == 2 # two unmappable rows + assert args[2] == "abandoned" # the batch context + assert "continue_interaction=2" in args[3] + + +def test_an_all_mappable_batch_logs_nothing(monkeypatch): + recorder = _RecordingLog() + monkeypatch.setattr(commands_dao, "log", recorder) + + mapped = commands_dao._map_commands_skipping_unmappable( + [_row(SessionCommandKind.cancel.value), _row(SessionCommandKind.cancel.value)], + context="abandoned", + ) + + assert len(mapped) == 2 + assert recorder.warnings == [] diff --git a/api/oss/tests/pytest/unit/sessions/test_durable_events.py b/api/oss/tests/pytest/unit/sessions/test_durable_events.py new file mode 100644 index 00000000000..ee638e58b8d --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_durable_events.py @@ -0,0 +1,148 @@ +from datetime import datetime, timezone +from uuid import uuid4 + +from oss.src.core.sessions.records.dtos import SessionRecord +from oss.src.core.sessions.records.events import durable_events_from_records + + +def _record(*, sequence: int, record_type: str, attributes: dict, source="agent"): + return SessionRecord( + project_id=uuid4(), + session_id="session-1", + record_id=uuid4(), + sequence=sequence, + turn_id="execution-1", + record_type=record_type, + record_source=source, + attributes=attributes, + created_at=datetime.now(timezone.utc), + ) + + +def test_maps_message_and_completed_tool_to_versioned_durable_events(): + records = [ + _record( + sequence=1, + record_type="message", + source="user", + attributes={"type": "message", "id": "message-1", "text": "hello"}, + ), + _record( + sequence=2, + record_type="tool_call", + attributes={ + "type": "tool_call", + "id": "tool-1", + "name": "read", + "input": {"path": "README.md"}, + }, + ), + _record( + sequence=3, + record_type="tool_result", + attributes={"type": "tool_result", "id": "tool-1", "output": "ok"}, + ), + ] + + events = durable_events_from_records(records) + + assert [event.type for event in events] == ["message.completed", "tool.completed"] + assert [event.sequence for event in events] == [1, 3] + assert [event.watermark for event in events] == [3, 3] + assert events[0].payload.role == "user" + assert events[1].payload.name == "read" + assert events[1].payload.input == {"path": "README.md"} + assert events[1].payload.output == "ok" + + +def test_accepts_the_six_direct_event_types_and_ignores_unknown_types(): + records = [ + _record( + sequence=1, + record_type="execution.started", + attributes={"started_at": datetime.now(timezone.utc).isoformat()}, + ), + _record( + sequence=2, + record_type="future.event", + attributes={"value": True}, + ), + ] + + events = durable_events_from_records(records) + + assert len(events) == 1 + assert events[0].type == "execution.started" + assert events[0].sequence == 1 + assert events[0].watermark == 2 + + +def test_maps_interaction_records_to_durable_lifecycle_events(): + records = [ + _record( + sequence=1, + record_type="interaction_request", + attributes={ + "type": "interaction_request", + "id": "interaction-1", + "kind": "client_tool", + }, + ), + _record( + sequence=2, + record_type="interaction_response", + attributes={ + "type": "interaction_response", + "id": "interaction-1", + "kind": "user_approval", + }, + ), + ] + + events = durable_events_from_records(records) + + assert [event.type for event in events] == [ + "interaction.requested", + "interaction.responded", + ] + assert [event.sequence for event in events] == [1, 2] + assert [event.watermark for event in events] == [2, 2] + assert events[0].entity_id == "interaction-1" + assert events[0].payload.interaction_id == "interaction-1" + assert events[0].payload.kind == "client_tool" + assert events[1].payload.kind == "user_approval" + + +def test_non_dict_payload_reads_as_absent_instead_of_raising(): + """A record whose `payload` attribute is not a dict must not poison the batch. + + `attributes` is an open dict filled from the ingest wire. Before this guard, a string or + list `payload` raised `AttributeError` outside the projection's `try`, so the whole batch + failed after its rows were committed and the same record returned on every redelivery. + """ + records = [ + _record( + sequence=1, + record_type="execution.started", + attributes={ + "payload": "not-a-dict", + "started_at": datetime.now(timezone.utc).isoformat(), + }, + ), + _record( + sequence=2, + record_type="execution.started", + attributes={ + "payload": ["also", "not", "a", "dict"], + "started_at": datetime.now(timezone.utc).isoformat(), + }, + ), + ] + + events = durable_events_from_records(records) + + assert [event.type for event in events] == [ + "execution.started", + "execution.started", + ] + assert [event.sequence for event in events] == [1, 2] diff --git a/api/oss/tests/pytest/unit/sessions/test_execution_watchdog.py b/api/oss/tests/pytest/unit/sessions/test_execution_watchdog.py new file mode 100644 index 00000000000..503f8a21780 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_execution_watchdog.py @@ -0,0 +1,1235 @@ +"""The execution watchdog must give a lost turn a real ending, exactly once. + +Before this, the sweep collapsed a dead session's flags and cleared its Redis nest, but wrote +nothing to the transcript: the turn simply stopped mid-sentence and the browser kept showing +it as running until the user reloaded. The invariant these tests hold is the RFC's — every +accepted execution reaches exactly ONE durable terminal outcome — so they check both halves: +an ending IS written for a turn that has none, and a SECOND ending is never written for a turn +that already has one. + +The threshold predicate itself is covered by `test_orphan_sweep_thresholds.py`; the fake +session here models the execution filter, order, and batch limit so the durable candidate +window is also covered. +""" + +from contextlib import asynccontextmanager +from datetime import datetime, timezone, timedelta +from typing import List, Optional, Sequence, Set, Tuple +from uuid import UUID + +import pytest + +from oss.src.core.sessions.records.dtos import ( + RECORD_SETTLED_BY_ATTRIBUTE, + SETTLED_BY_WATCHDOG, + SessionRecordEvent, +) +from oss.src.dbs.redis.sessions.contract import make_owner_value, owner_replica_id +from oss.src.dbs.redis.sessions.locks import claim_owner, is_turn_superseded +from oss.src.tasks.asyncio.sessions.orphan_sweep import ( + LOST_ERROR_CODE, + LOST_ERROR_MESSAGE, + ORPHAN_THRESHOLD_SECONDS, + IDLE_THRESHOLD_SECONDS, + SWEEP_BATCH_SIZE, + _unsettled_turns, + run_orphan_sweep, +) +from oss.src.utils.env import env + +_PROJECT_ID = UUID("00000000-0000-4000-8000-000000000001") + + +# --------------------------------------------------------------------------- # +# Fakes +# --------------------------------------------------------------------------- # + + +class _FakeRow: + def __init__( + self, + *, + session_id: str, + turn_id: Optional[str], + is_running: bool, + age_seconds: int, + ): + self.session_id = session_id + self.project_id = _PROJECT_ID + self.id = f"stream-{session_id}" + self.turn_id = turn_id + self.deleted_at = None + self.flags = { + "is_alive": True, + "is_running": is_running, + "is_attached": False, + } + self.created_at = datetime.now(timezone.utc) - timedelta(days=1) + self.updated_at = datetime.now(timezone.utc) - timedelta(seconds=age_seconds) + + +class _FakeExecutionRow: + def __init__( + self, + *, + session_id: str, + execution_id: str, + terminal_outcome: str = "stopped", + age_seconds: int = ORPHAN_THRESHOLD_SECONDS + 30, + ending_written_at: Optional[datetime] = None, + ): + self.project_id = _PROJECT_ID + self.session_id = session_id + self.execution_id = execution_id + self.terminal_outcome = terminal_outcome + self.settled_by = "runner" + self.settled_at = datetime.now(timezone.utc) - timedelta(seconds=age_seconds) + self.ending_written_at = ending_written_at + + +class _FakeResult: + def __init__(self, rows, *, rowcount=0): + self._rows = rows + self.rowcount = rowcount + + def scalars(self): + return self + + def all(self): + return self._rows + + +class _FakePgSession: + def __init__(self, rows, executions, before_stream_update=None, on_commit=None): + self._rows = rows + self._executions = executions + self._before_stream_update = before_stream_update + self._on_commit = on_commit + self.commits = 0 + + async def execute(self, stmt): + # Evaluate the sweep's two selections the way Postgres would, so a test can tell a + # collapsed row from one that only owed an ending. The ending-only statement is the + # one that filters on `turn_id IS NOT NULL`; the first statement carries the OR of + # the running and idle branches. + text = str(stmt) + now = datetime.now(timezone.utc) + + if "session_executions" in text: + if text.startswith("UPDATE"): + params = stmt.compile().params + keys = next( + value + for value in params.values() + if isinstance(value, (list, set, tuple)) + and all(isinstance(key, tuple) and len(key) == 3 for key in value) + ) + for execution in self._executions: + key = ( + execution.project_id, + execution.session_id, + execution.execution_id, + ) + if key in keys and execution.ending_written_at is None: + execution.ending_written_at = now + return _FakeResult([]) + rows = [ + execution + for execution in self._executions + if execution.terminal_outcome in {"stopped", "lost"} + and (now - execution.settled_at).total_seconds() + > ORPHAN_THRESHOLD_SECONDS + ] + if "ending_written_at IS NULL" in text: + rows = [row for row in rows if row.ending_written_at is None] + return _FakeResult( + sorted( + rows, + key=lambda row: row.settled_at, + reverse="DESC" in text, + )[:SWEEP_BATCH_SIZE] + ) + + # Both session_streams writes are Core UPDATEs of flags/updated_at keyed by row id, and + # never ORM attribute writes (finding 7). Apply them to the in-memory rows so a test + # sees what Postgres would. The collapse binds `id IN (...)`, a list. The lost-turn + # clear binds `id = ...`, a scalar. Row ids are strings here and are the only string + # bind in either statement. + if text.startswith("UPDATE") and "session_streams" in text: + if self._before_stream_update is not None: + self._before_stream_update() + self._before_stream_update = None + return _FakeResult([], rowcount=0) + params = stmt.compile().params + flags_val = next( + (v for v in params.values() if isinstance(v, dict) and "is_alive" in v), + None, + ) + ids = set() + for value in params.values(): + if isinstance(value, (list, set, tuple)): + ids.update(x for x in value if isinstance(x, (str, UUID))) + elif isinstance(value, (str, UUID)): + ids.add(value) + if flags_val is not None: + matched = 0 + for r in self._rows: + if r.id in ids: + r.flags = dict(flags_val) + r.updated_at = now + matched += 1 + return _FakeResult([], rowcount=matched) + return _FakeResult([]) + + # The lost-turn is_running clear: a session_streams SELECT keyed by a list of + # (project_id, session_id, turn_id) tuples. Return the rows those keys name that still + # read is_running true, so the sweep can clear the flag on them. + params = stmt.compile().params + key_lists = [ + value + for value in params.values() + if isinstance(value, (list, set, tuple)) + and value + and all(isinstance(key, tuple) and len(key) == 3 for key in value) + ] + if key_lists: + keys = set(key_lists[0]) + return _FakeResult( + [ + r + for r in self._rows + if r.flags.get("is_running") is True + and (r.project_id, r.session_id, str(r.turn_id)) in keys + ] + ) + + def age(row): + return (now - (row.updated_at or row.created_at)).total_seconds() + + if "IS NOT NULL" in text: + rows = [ + r + for r in self._rows + if r.flags.get("is_alive") is True + and r.flags.get("is_running") is not True + and r.turn_id is not None + and age(r) > ORPHAN_THRESHOLD_SECONDS + ] + else: + rows = [ + r + for r in self._rows + if r.flags.get("is_alive") is True + and ( + ( + r.flags.get("is_running") is True + and age(r) > ORPHAN_THRESHOLD_SECONDS + ) + or ( + r.flags.get("is_running") is not True + and age(r) > IDLE_THRESHOLD_SECONDS + ) + ) + ] + return _FakeResult(rows) + + async def commit(self): + self.commits += 1 + if self._on_commit is not None: + self._on_commit() + + +class _FakeTransactionsEngine: + def __init__( + self, + rows, + executions=None, + before_stream_update=None, + after_commit=None, + ): + self._rows = rows + self._executions = executions or [] + self._before_stream_update = before_stream_update + self._after_commit = after_commit + self.committed = False + + def _mark_committed(self): + self.committed = True + if self._after_commit is not None: + self._after_commit() + + @asynccontextmanager + async def session(self): + yield _FakePgSession( + self._rows, + self._executions, + self._before_stream_update, + self._mark_committed, + ) + + +class _FakeRedis: + def __init__(self): + self._store: dict = {} + + async def get(self, key): + return self._store.get(key) + + async def set(self, key, value, nx=False, ex=None): + if nx and key in self._store: + return None + self._store[key] = value + return True + + async def delete(self, key): + self._store.pop(key, None) + return 1 + + async def expire(self, key, ttl): + return True + + async def eval(self, script, numkeys, *keys_and_args): + def decode(value): + return value.decode() if isinstance(value, bytes) else str(value) + + keys = [decode(value) for value in keys_and_args[:numkeys]] + argv = [decode(value) for value in keys_and_args[numkeys:]] + if "AGENTA_WATCHDOG_RELEASE_TURN" in script: + alive, running, owner, superseded = keys + expected_turn, expected_owner, _ttl = argv + alive_value = decode(self._store[alive]) if alive in self._store else "" + running_value = ( + decode(self._store[running]) if running in self._store else "" + ) + owner_value = decode(self._store[owner]) if owner in self._store else "" + released_alive = int(bool(expected_turn) and alive_value == expected_turn) + released_running = int( + bool(expected_turn) and running_value == expected_turn + ) + if released_alive: + self._store.pop(alive, None) + if released_running: + self._store.pop(running, None) + foreign_turn = (alive_value and alive_value != expected_turn) or ( + running_value and running_value != expected_turn + ) + released_owner = int( + bool(expected_owner) + and owner_value == expected_owner + and not foreign_turn + ) + if released_owner: + self._store.pop(owner, None) + if expected_turn: + self._store[superseded] = b"1" + return [released_alive, released_running, released_owner] + + k = keys[0] + v = argv[0] + current = self._store.get(k) + if isinstance(current, bytes): + current = current.decode() + if len(argv) > 1: + if current is None or owner_replica_id(current) == owner_replica_id(v): + self._store[k] = v.encode() + return v.encode() + return current.encode() + # The sweep's script is release-if-owner: delete only when the value matches. + if current == v: + self._store.pop(k, None) + return 1 + return 0 + + +class _CommitObservingRedis(_FakeRedis): + def __init__(self, engine: _FakeTransactionsEngine): + super().__init__() + self._engine = engine + + async def eval(self, *args, **kwargs): + assert self._engine.committed, ( + "Redis ownership was released before the DB commit" + ) + return await super().eval(*args, **kwargs) + + +class _FakeRecordsService: + """Stands in for the records plane. `settled` is what the tracing DB already holds.""" + + def __init__(self, settled: Optional[Set[Tuple[str, str]]] = None): + self.settled = settled or set() + self.queries: List[Sequence[Tuple[str, str]]] = [] + + async def settled_turns(self, *, project_id, keys): + self.queries.append(list(keys)) + return {key for key in keys if key in self.settled} + + +@pytest.mark.anyio +async def test_terminal_record_checks_are_batched_once_per_project(anyio_backend): + other_project = UUID("00000000-0000-4000-8000-000000000002") + + class _RecordingRecords: + def __init__(self): + self.queries = [] + + async def settled_turns(self, *, project_id, keys): + self.queries.append((project_id, list(keys))) + return set() + + records = _RecordingRecords() + first_project = [ + (_PROJECT_ID, f"session-{index}", f"turn-{index}") for index in range(100) + ] + second_project = [(other_project, "session-other", "turn-other")] + + unsettled, ended, deferred = await _unsettled_turns( + records_service=records, + candidates=[*first_project, *second_project], + ) + + assert ended == set() + assert deferred == set() + assert unsettled == set(first_project + second_project) + assert [(project_id, len(keys)) for project_id, keys in records.queries] == [ + (_PROJECT_ID, 100), + (other_project, 1), + ] + + +class _FakeWatchPublisher: + def __init__(self): + self.lifecycles: List[Tuple[str, str, str]] = [] + self.changes: List[Tuple[str, str, str]] = [] + + async def lifecycle(self, *, project_id, session_id, state): + self.lifecycles.append((project_id, session_id, state)) + + async def changed(self, *, project_id, entity, id): + self.changes.append((project_id, entity, id)) + + +class _Publisher: + """Captures what the watchdog would put on the record ingest stream.""" + + def __init__(self): + self.published: List[SessionRecordEvent] = [] + + async def __call__(self, *, project_id, record_event): + self.published.append(record_event) + return True + + +class _CommandsService: + def __init__(self): + self.execution_lost_calls = [] + + async def settle_execution_lost(self, **kwargs): + assert kwargs["transaction"] is not None + self.execution_lost_calls.append(kwargs) + return True + + async def settle_abandoned_commands(self, *, now): + return 0 + + async def repair_terminal_redis(self): + return 0 + + +def _stale_running_row(session_id="sess-lost", turn_id="turn-1") -> _FakeRow: + return _FakeRow( + session_id=session_id, + turn_id=turn_id, + is_running=True, + age_seconds=ORPHAN_THRESHOLD_SECONDS + 60, + ) + + +def _collapsed(row: _FakeRow) -> bool: + return row.flags == {"is_alive": False, "is_running": False, "is_attached": False} + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +# --------------------------------------------------------------------------- # +# Tests +# --------------------------------------------------------------------------- # + + +@pytest.mark.anyio +async def test_a_lost_turn_gets_an_error_then_a_done(anyio_backend): + """The shape a runner writes when a turn ends badly, written on its behalf. + + A lone `done` would render as a clean finish, which is the opposite of what happened, so + the error must come first and must carry the class a client can act on. + """ + row = _stale_running_row() + publisher = _Publisher() + + await run_orphan_sweep( + _FakeTransactionsEngine([row]), + _FakeRedis(), + records_service=_FakeRecordsService(), + publish=publisher, + ) + + assert [event.record_type for event in publisher.published] == ["error", "done"] + + error_event, done_event = publisher.published + # Both carry the writer marker. It is the ONLY thing separating this ending from a + # runner's — the wording and the `done` shape are copied deliberately — and the ingest + # guard reads it to tell a thawed runner's tail apart from ordinary history. See + # `RecordsService.append_many`. + assert error_event.attributes == { + "type": "error", + "message": LOST_ERROR_MESSAGE, + "code": LOST_ERROR_CODE, + RECORD_SETTLED_BY_ATTRIBUTE: SETTLED_BY_WATCHDOG, + } + assert done_event.attributes == { + "type": "done", + RECORD_SETTLED_BY_ATTRIBUTE: SETTLED_BY_WATCHDOG, + } + assert error_event.turn_id == "turn-1" + assert done_event.turn_id == "turn-1" + assert error_event.session_id == "sess-lost" + assert _collapsed(row), "the row must still be marked ended" + + +@pytest.mark.anyio +async def test_a_second_pass_writes_no_second_ending(anyio_backend): + """Idempotency, the guarantee the RFC asks for: exactly one terminal outcome. + + Two passes can see the same turn — a crash between the record write and the flag + collapse, or two API replicas sweeping at once. The second pass reads the record the + first one wrote and must stay silent. + """ + records = _FakeRecordsService() + first_publisher = _Publisher() + + await run_orphan_sweep( + _FakeTransactionsEngine([_stale_running_row()]), + _FakeRedis(), + records_service=records, + publish=first_publisher, + ) + assert len(first_publisher.published) == 2 + + # The records worker has now landed those rows in the tracing DB. + records.settled.add(("sess-lost", "turn-1")) + + second_publisher = _Publisher() + row = _stale_running_row() + await run_orphan_sweep( + _FakeTransactionsEngine([row]), + _FakeRedis(), + records_service=records, + publish=second_publisher, + ) + + assert second_publisher.published == [], ( + "a turn that already carries a terminal record must never be given a second one" + ) + assert _collapsed(row), "the row is still settled even when no record is owed" + + +@pytest.mark.anyio +async def test_record_ids_are_stable_across_passes(anyio_backend): + """The second guard, for the window before the worker has landed the first write. + + Ingest upserts on (project_id, record_id), so two publishes of the same id write the + same row rather than appending a duplicate. + """ + first, second = _Publisher(), _Publisher() + + for publisher in (first, second): + await run_orphan_sweep( + _FakeTransactionsEngine([_stale_running_row()]), + _FakeRedis(), + records_service=_FakeRecordsService(), + publish=publisher, + ) + + assert [event.record_id for event in first.published] == [ + event.record_id for event in second.published + ] + assert len({event.record_id for event in first.published}) == 2, ( + "the error and the done must not collide on one id" + ) + + +@pytest.mark.anyio +async def test_an_idle_row_owes_no_ending(anyio_backend): + """A row that was alive between turns has no running turn to end. + + Its last turn already reached its own terminal record. Writing an error here would + invent a failure that never happened. + + The records fake says so, because that record is now what decides. The `is_running` flag + used to decide instead, and a durable Stop broke it: settlement clears the flag before the + runner has written its ending. + """ + row = _FakeRow( + session_id="sess-idle", + turn_id="turn-old", + is_running=False, + age_seconds=99_999, + ) + publisher = _Publisher() + + await run_orphan_sweep( + _FakeTransactionsEngine([row]), + _FakeRedis(), + records_service=_FakeRecordsService({("sess-idle", "turn-old")}), + publish=publisher, + ) + + assert publisher.published == [] + assert _collapsed(row) + + +@pytest.mark.anyio +async def test_a_parked_approval_is_never_settled(anyio_backend): + """The hazard the heartbeat-age rule creates, pinned. + + A turn that parks for a human sends one final beat with `is_running: false` and then stops + beating on purpose. Its heartbeat therefore goes stale immediately, and it is exactly the + state we most need to keep: the sandbox is warm, the user is about to answer, and the turn + is resumable. + + What protects it is its own terminal record: a turn that parks writes `done` with + `stopReason: paused` at the moment it parks, and any terminal record makes `settled_turns` + answer yes. Verified on the integration stack, session f0018938: `done`/`paused` landed in + the same second as the `interaction_request`. The `is_running` flag protected it before, + and stopped being able to when a durable Stop began clearing that flag early. + """ + row = _FakeRow( + session_id="sess-parked", + turn_id="turn-parked", + is_running=False, + age_seconds=ORPHAN_THRESHOLD_SECONDS * 5, + ) + publisher = _Publisher() + + await run_orphan_sweep( + _FakeTransactionsEngine([row]), + _FakeRedis(), + records_service=_FakeRecordsService({("sess-parked", "turn-parked")}), + publish=publisher, + ) + + assert publisher.published == [], ( + "a parked approval must never be given a terminal record: the user is still going to " + "answer it" + ) + + +@pytest.mark.anyio +async def test_a_running_row_without_a_turn_id_is_settled_silently(anyio_backend): + """Nothing to attribute an ending to, so the row is collapsed and no record is written.""" + row = _FakeRow( + session_id="sess-no-turn", + turn_id=None, + is_running=True, + age_seconds=ORPHAN_THRESHOLD_SECONDS + 60, + ) + publisher = _Publisher() + + await run_orphan_sweep( + _FakeTransactionsEngine([row]), + _FakeRedis(), + records_service=_FakeRecordsService(), + publish=publisher, + ) + + assert publisher.published == [] + assert _collapsed(row) + + +@pytest.mark.anyio +async def test_open_readers_are_told_the_session_ended(anyio_backend): + """Without this a browser keeps rendering the dead turn as running until a reload.""" + row = _stale_running_row(session_id="sess-watch") + watch = _FakeWatchPublisher() + + await run_orphan_sweep( + _FakeTransactionsEngine([row]), + _FakeRedis(), + records_service=_FakeRecordsService(), + watch_publisher=watch, + publish=_Publisher(), + ) + + assert watch.lifecycles == [(str(_PROJECT_ID), "sess-watch", "ended")] + + +@pytest.mark.anyio +async def test_the_redis_nest_follows_the_settled_row(anyio_backend): + """The SEND gate reads Redis, not the row: a session left nested keeps refusing a + new message long after the watchdog declared its turn lost.""" + redis = _FakeRedis() + project = str(_PROJECT_ID) + await redis.set(f"alive:{project}:session:sess-lost", b"turn-1", ex=3600) + await redis.set(f"running:{project}:session:sess-lost", b"turn-1", ex=3600) + await redis.set(f"owner:{project}:session:sess-lost", b"replica-1", ex=3600) + + await run_orphan_sweep( + _FakeTransactionsEngine([_stale_running_row()]), + redis, + records_service=_FakeRecordsService(), + publish=_Publisher(), + ) + + assert await redis.get(f"alive:{project}:session:sess-lost") is None + assert await redis.get(f"running:{project}:session:sess-lost") is None + assert await redis.get(f"owner:{project}:session:sess-lost") is None + assert ( + await redis.get(f"superseded:{project}:session:sess-lost:turn:turn-1") + is not None + ), "a late beat from the lost turn must not re-nest the session" + + +@pytest.mark.anyio +async def test_cleanup_preserves_same_replica_owner_refresh_before_new_turn_locks( + anyio_backend, +): + stream = _stale_running_row(session_id="sess-cleanup-race", turn_id="turn-a") + redis = _FakeRedis() + project = str(stream.project_id) + alive_key = f"alive:{project}:session:{stream.session_id}" + running_key = f"running:{project}:session:{stream.session_id}" + owner_key = f"owner:{project}:session:{stream.session_id}" + redis._store[alive_key] = b"turn-a" + redis._store[running_key] = b"turn-a" + redis._store[owner_key] = make_owner_value( + replica_id="replica-a", turn_id="turn-a" + ).encode() + + def refresh_turn_b_owner(): + # Exact ABA gap: the same replica refreshed affinity for B, but has not installed B's + # alive/running keys yet. Cleanup must compare the owner generation, not the replica. + redis._store[owner_key] = make_owner_value( + replica_id="replica-a", turn_id="turn-b" + ).encode() + + await run_orphan_sweep( + _FakeTransactionsEngine([stream], after_commit=refresh_turn_b_owner), + redis, + records_service=_FakeRecordsService(), + publish=_Publisher(), + ) + + assert alive_key not in redis._store + assert running_key not in redis._store + assert ( + redis._store[owner_key] + == make_owner_value(replica_id="replica-a", turn_id="turn-b").encode() + ) + assert ( + redis._store[f"superseded:{project}:session:{stream.session_id}:turn:turn-a"] + == b"1" + ) + assert ( + f"superseded:{project}:session:{stream.session_id}:turn:turn-b" + not in redis._store + ) + + +@pytest.mark.anyio +async def test_a_failed_lookup_never_invents_an_ending(anyio_backend): + """If we cannot tell whether the turn already ended, say nothing rather than risk a + second, contradictory ending. Preserve the row and Redis ownership so the next pass retries.""" + + class _FlakyRecords(_FakeRecordsService): + def __init__(self): + super().__init__() + self.calls = 0 + + async def settled_turns(self, *, project_id, keys): + self.calls += 1 + if self.calls == 1: + raise RuntimeError("tracing db unreachable") + return set() + + row = _stale_running_row() + publisher = _Publisher() + records = _FlakyRecords() + redis = _FakeRedis() + project = str(row.project_id) + alive_key = f"alive:{project}:session:{row.session_id}" + running_key = f"running:{project}:session:{row.session_id}" + redis._store[alive_key] = b"turn-1" + redis._store[running_key] = b"turn-1" + + await run_orphan_sweep( + _FakeTransactionsEngine([row]), + redis, + records_service=records, + publish=publisher, + ) + + assert publisher.published == [] + assert not _collapsed(row) + assert redis._store[alive_key] == b"turn-1" + assert redis._store[running_key] == b"turn-1" + + await run_orphan_sweep( + _FakeTransactionsEngine([row]), + redis, + records_service=records, + publish=publisher, + ) + + assert _collapsed(row) + assert alive_key not in redis._store + assert running_key not in redis._store + assert [event.record_type for event in publisher.published] == ["error", "done"] + + +@pytest.mark.anyio +async def test_a_stopped_turn_whose_runner_died_still_gets_an_ending(anyio_backend): + """The seam between the durable Stop and the watchdog, found by running the cells. + + Settlement writes `is_running: false` onto the row the moment it releases the Redis key, + so the tab that pressed Stop is not left spinning. The runner still owes its own terminal + record. If it dies in that window the row is already not-running, and the old rule — only + a row that CLAIMS running owes an ending — skipped it for ever: the 30-minute idle branch + collapses such a row and writes nothing. + + Observed live on the integration stack. Command 01a06763-5807 settled `applied`/`stopped` + at 13:09:19, the runner was killed a moment later, and turn 295351c3 still carried nothing + but the user's own `message` five minutes and five sweep passes later. + + This row is deliberately NOT collapsed here: it is younger than the idle grace, and a + parked approval of the same age must survive. Only the ending is owed. + """ + row = _FakeRow( + session_id="sess-stopped", + turn_id="turn-stopped", + is_running=False, + age_seconds=ORPHAN_THRESHOLD_SECONDS + 30, + ) + publisher = _Publisher() + execution = _FakeExecutionRow( + session_id=row.session_id, + execution_id="turn-stopped", + ) + redis = _FakeRedis() + # Settlement leaves `alive` to its TTL, so the dead turn still holds the session's + # alive lock when the sweep runs; the SEND gate reads that lock. + alive_key = f"alive:{row.project_id}:session:{row.session_id}" + redis._store[alive_key] = b"turn-stopped" + assert ( + await claim_owner( + redis, + project_id=str(row.project_id), + session_id=row.session_id, + replica_id="replica-dead", + ) + == "replica-dead" + ) + + await run_orphan_sweep( + _FakeTransactionsEngine([row], [execution]), + redis, + records_service=_FakeRecordsService(), + publish=publisher, + ) + + assert [event.record_type for event in publisher.published] == ["done"], ( + "a stopped turn whose runner never wrote an ending must be given one" + ) + assert publisher.published[0].attributes == { + "type": "done", + "stopReason": "cancelled", + RECORD_SETTLED_BY_ATTRIBUTE: SETTLED_BY_WATCHDOG, + } + assert all(event.turn_id == "turn-stopped" for event in publisher.published) + assert execution.ending_written_at is not None + assert alive_key not in redis._store, ( + "the dead turn's alive lock must be released, or the next Send is refused for an hour" + ) + assert ( + await claim_owner( + redis, + project_id=str(row.project_id), + session_id=row.session_id, + replica_id="replica-new", + ) + == "replica-new" + ), "the next runner must claim affinity without waiting for the dead owner's TTL" + assert row.flags["is_alive"] is True, "the stopped row itself is not collapsed" + + +async def test_a_stopped_turn_owned_by_a_newer_turn_keeps_that_lock(anyio_backend): + """Release is owner-checked: if a newer turn already holds `alive`, leave it alone.""" + row = _FakeRow( + session_id="sess-stopped", + turn_id="turn-stopped", + is_running=False, + age_seconds=ORPHAN_THRESHOLD_SECONDS + 30, + ) + redis = _FakeRedis() + alive_key = f"alive:{row.project_id}:session:{row.session_id}" + redis._store[alive_key] = b"turn-newer" + await claim_owner( + redis, + project_id=str(row.project_id), + session_id=row.session_id, + replica_id="replica-newer", + ) + + await run_orphan_sweep( + _FakeTransactionsEngine([row]), + redis, + records_service=_FakeRecordsService(), + publish=_Publisher(), + ) + + assert redis._store.get(alive_key) == b"turn-newer" + assert ( + await claim_owner( + redis, + project_id=str(row.project_id), + session_id=row.session_id, + replica_id="replica-other", + ) + == "replica-newer" + ), "settling an older turn must not clear a newer turn's affinity" + + +@pytest.mark.anyio +async def test_a_stopped_execution_gets_an_ending_after_stream_advances( + anyio_backend, +): + stream = _FakeRow( + session_id="sess-advanced", + turn_id="turn-later", + is_running=False, + age_seconds=0, + ) + execution = _FakeExecutionRow( + session_id=stream.session_id, + execution_id="turn-stopped", + ) + redis = _FakeRedis() + alive_key = f"alive:{stream.project_id}:session:{stream.session_id}" + running_key = f"running:{stream.project_id}:session:{stream.session_id}" + redis._store[alive_key] = b"turn-later" + redis._store[running_key] = b"turn-later" + publisher = _Publisher() + + await run_orphan_sweep( + _FakeTransactionsEngine([stream], [execution]), + redis, + records_service=_FakeRecordsService({("sess-advanced", "turn-later")}), + publish=publisher, + ) + + assert [event.record_type for event in publisher.published] == ["done"] + assert publisher.published[0].attributes["stopReason"] == "cancelled" + assert all(event.turn_id == "turn-stopped" for event in publisher.published) + assert redis._store[alive_key] == b"turn-later" + assert redis._store[running_key] == b"turn-later" + + +@pytest.mark.anyio +async def test_a_stopped_execution_does_not_touch_a_newer_running_turn( + anyio_backend, +): + stream = _FakeRow( + session_id="sess-advanced-running", + turn_id="turn-running", + is_running=True, + age_seconds=0, + ) + execution = _FakeExecutionRow( + session_id=stream.session_id, + execution_id="turn-stopped", + ) + redis = _FakeRedis() + alive_key = f"alive:{stream.project_id}:session:{stream.session_id}" + running_key = f"running:{stream.project_id}:session:{stream.session_id}" + redis._store[alive_key] = b"turn-running" + redis._store[running_key] = b"turn-running" + publisher = _Publisher() + + await run_orphan_sweep( + _FakeTransactionsEngine([stream], [execution]), + redis, + records_service=_FakeRecordsService(), + publish=publisher, + ) + + assert [event.record_type for event in publisher.published] == ["done"] + assert publisher.published[0].attributes["stopReason"] == "cancelled" + assert all(event.turn_id == "turn-stopped" for event in publisher.published) + assert redis._store[alive_key] == b"turn-running" + assert redis._store[running_key] == b"turn-running" + + +@pytest.mark.anyio +async def test_ended_execution_backlog_cannot_hide_a_recent_orphan(anyio_backend): + ended_at = datetime.now(timezone.utc) + ended = [ + _FakeExecutionRow( + session_id=f"sess-ended-{index}", + execution_id=f"turn-ended-{index}", + age_seconds=ORPHAN_THRESHOLD_SECONDS + 1_000 + index, + ending_written_at=ended_at, + ) + for index in range(SWEEP_BATCH_SIZE + 1) + ] + orphan = _FakeExecutionRow( + session_id="sess-recent-orphan", + execution_id="turn-recent-orphan", + ) + records = _FakeRecordsService() + publisher = _Publisher() + + await run_orphan_sweep( + _FakeTransactionsEngine([], [*ended, orphan]), + _FakeRedis(), + records_service=records, + publish=publisher, + ) + + assert records.queries == [[("sess-recent-orphan", "turn-recent-orphan")]] + assert [event.record_type for event in publisher.published] == ["done"] + assert publisher.published[0].turn_id == "turn-recent-orphan" + assert orphan.ending_written_at is not None + + +@pytest.mark.anyio +async def test_records_plane_ending_marks_candidate_and_skips_publish(anyio_backend): + execution = _FakeExecutionRow( + session_id="sess-already-ended", + execution_id="turn-already-ended", + terminal_outcome="lost", + ) + publisher = _Publisher() + + await run_orphan_sweep( + _FakeTransactionsEngine([], [execution]), + _FakeRedis(), + records_service=_FakeRecordsService( + {("sess-already-ended", "turn-already-ended")} + ), + publish=publisher, + ) + + assert publisher.published == [] + assert execution.ending_written_at is not None + + +@pytest.mark.anyio +async def test_a_lost_execution_clears_is_running_on_a_row_that_still_names_it( + anyio_backend, +): + # The execution is settled lost, but the session's stream row still names that turn and + # still reads is_running true, so the SEND gate would refuse the next message. The pass + # that writes the lost ending must clear is_running (keeping is_alive so the session stays + # resumable), clear the running lock, and update the mirror -- in the same pass. The row is + # fresh here so the went-silent collapse never touches it; the fix must. + stream = _FakeRow( + session_id="sess-stuck-running", + turn_id="turn-lost", + is_running=True, + age_seconds=0, + ) + execution = _FakeExecutionRow( + session_id=stream.session_id, + execution_id="turn-lost", + terminal_outcome="lost", + ) + redis = _FakeRedis() + alive_key = f"alive:{stream.project_id}:session:{stream.session_id}" + running_key = f"running:{stream.project_id}:session:{stream.session_id}" + redis._store[running_key] = b"turn-lost" + redis._store[alive_key] = b"turn-lost" + publisher = _Publisher() + watch = _FakeWatchPublisher() + + await run_orphan_sweep( + _FakeTransactionsEngine([stream], [execution]), + redis, + records_service=_FakeRecordsService(), + watch_publisher=watch, + publish=publisher, + ) + + # is_running is cleared on the row, is_alive is kept, and the row is NOT collapsed. + assert stream.flags == { + "is_alive": True, + "is_running": False, + "is_attached": False, + } + # The running lock the SEND gate reads is cleared too, guarded on the dead turn. + assert running_key not in redis._store + # The mirror update reaches open readers. + assert (str(stream.project_id), "session", stream.session_id) in watch.changes + + +@pytest.mark.anyio +async def test_lost_turn_redis_release_follows_the_stream_commit(anyio_backend): + stream = _FakeRow( + session_id="sess-commit-before-release", + turn_id="turn-lost", + is_running=True, + age_seconds=0, + ) + execution = _FakeExecutionRow( + session_id=stream.session_id, + execution_id="turn-lost", + terminal_outcome="lost", + ) + engine = _FakeTransactionsEngine([stream], [execution]) + redis = _CommitObservingRedis(engine) + for prefix in ("alive", "running"): + redis._store[f"{prefix}:{stream.project_id}:session:{stream.session_id}"] = ( + b"turn-lost" + ) + + await run_orphan_sweep( + engine, + redis, + records_service=_FakeRecordsService(), + publish=_Publisher(), + ) + + assert engine.committed is True + + +@pytest.mark.anyio +async def test_lost_turn_clear_loses_to_a_concurrent_turn_advance(anyio_backend): + stream = _FakeRow( + session_id="sess-advance-during-lost-clear", + turn_id="turn-old", + is_running=True, + age_seconds=0, + ) + execution = _FakeExecutionRow( + session_id=stream.session_id, + execution_id="turn-old", + terminal_outcome="lost", + ) + redis = _FakeRedis() + alive_key = f"alive:{stream.project_id}:session:{stream.session_id}" + running_key = f"running:{stream.project_id}:session:{stream.session_id}" + owner_key = f"owner:{stream.project_id}:session:{stream.session_id}" + redis._store[alive_key] = b"turn-new" + redis._store[running_key] = b"turn-new" + redis._store[owner_key] = b"runner-new" + + def advance_stream(): + stream.turn_id = "turn-new" + stream.updated_at = datetime.now(timezone.utc) + + await run_orphan_sweep( + _FakeTransactionsEngine( + [stream], [execution], before_stream_update=advance_stream + ), + redis, + records_service=_FakeRecordsService(), + publish=_Publisher(), + ) + + assert stream.turn_id == "turn-new" + assert stream.flags["is_running"] is True + assert redis._store[alive_key] == b"turn-new" + assert redis._store[running_key] == b"turn-new" + assert redis._store[owner_key] == b"runner-new" + + +@pytest.mark.anyio +async def test_heartbeat_before_orphan_cas_prevents_settlement_and_records( + anyio_backend, + monkeypatch, +): + monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + stream = _stale_running_row( + session_id="sess-heartbeat-before-cas", turn_id="turn-current" + ) + publisher = _Publisher() + commands = _CommandsService() + + def heartbeat(): + stream.updated_at = datetime.now(timezone.utc) + + await run_orphan_sweep( + _FakeTransactionsEngine([stream], before_stream_update=heartbeat), + _FakeRedis(), + records_service=_FakeRecordsService(), + commands_service=commands, + publish=publisher, + ) + + assert commands.execution_lost_calls == [] + assert publisher.published == [] + assert stream.flags["is_alive"] is True + assert stream.flags["is_running"] is True + + +@pytest.mark.anyio +async def test_a_lost_execution_leaves_a_newer_running_turn_running(anyio_backend): + # The row has advanced to a NEWER turn that is genuinely running. Settling the OLD turn + # lost must not clear is_running on that row, nor its running lock. + stream = _FakeRow( + session_id="sess-advanced-newer", + turn_id="turn-new", + is_running=True, + age_seconds=0, + ) + execution = _FakeExecutionRow( + session_id=stream.session_id, + execution_id="turn-old", + terminal_outcome="lost", + ) + redis = _FakeRedis() + running_key = f"running:{stream.project_id}:session:{stream.session_id}" + redis._store[running_key] = b"turn-new" + publisher = _Publisher() + watch = _FakeWatchPublisher() + + await run_orphan_sweep( + _FakeTransactionsEngine([stream], [execution]), + redis, + records_service=_FakeRecordsService(), + watch_publisher=watch, + publish=publisher, + ) + + # The newer running turn is untouched: its flag stands and its lock survives. + assert stream.flags["is_running"] is True + assert redis._store[running_key] == b"turn-new" + + +@pytest.mark.anyio +async def test_a_swept_turn_is_tombstoned_even_when_it_holds_no_redis_keys( + anyio_backend, +): + # A prior Stop settlement can clear the alive/running keys before the sweep runs, so the + # collapse finds nothing to displace. The turn is still dead: tombstone it anyway, or a + # returning runner's beat for that turn is admitted and re-sets is_running on the row the + # sweep just collapsed (observed live: run 1e, a beat 3.5 s after the settle). + stream = _stale_running_row(session_id="sess-returning-runner", turn_id="turn-gone") + redis = _FakeRedis() # deliberately empty: no alive/running keys to displace + publisher = _Publisher() + + await run_orphan_sweep( + _FakeTransactionsEngine([stream], []), + redis, + records_service=_FakeRecordsService(), + publish=publisher, + ) + + assert _collapsed(stream) + assert await is_turn_superseded( + redis, + project_id=str(stream.project_id), + session_id=stream.session_id, + turn_id="turn-gone", + ) diff --git a/api/oss/tests/pytest/unit/sessions/test_execution_watchdog_loop.py b/api/oss/tests/pytest/unit/sessions/test_execution_watchdog_loop.py new file mode 100644 index 00000000000..f507229457e --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_execution_watchdog_loop.py @@ -0,0 +1,146 @@ +"""The watchdog loop must survive a failing pass and go round again. + +Root cause of the integration-stack silence on 2026-09-04: `orphan_sweep_loop`'s generic +error handler called `log.exception(...)`, but `log` is a `MultiLogger`, which defines no +`exception` method and no `__getattr__`. The first sweep error -- a `session_executions` +column that did not exist yet during a migration window -- turned that handler into an +`AttributeError` that escaped the `while` loop and killed the watchdog task for the life of +the process. There was no timeout log, no error log, and no further pass, so stale rows were +never settled. The `asyncio.wait_for` guard could not help, because the defect was in the +handler, not in a pass that ran long. + +These tests drive the loop, not a single pass, so the error handler is exercised: a pass +that raises must be logged and the loop must run a second pass; the same must hold for a pass +the timeout cuts. The single-pass behavior lives in `test_execution_watchdog.py`. +""" + +import asyncio + +import pytest + +from oss.src.tasks.asyncio.sessions import orphan_sweep + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +async def _noop_sleep(*_args, **_kwargs): + return None + + +class _RecordingLog: + """A stand-in for the module `MultiLogger`. + + It exposes only the methods `MultiLogger` really has, so a call the real logger cannot + serve (for example `exception`) raises `AttributeError` here too, exactly as it did live. + """ + + def __init__(self): + self.calls = [] + + def error(self, *args, **kwargs): + self.calls.append(("error", args, kwargs)) + + def info(self, *args, **kwargs): + self.calls.append(("info", args, kwargs)) + + def warning(self, *args, **kwargs): + self.calls.append(("warning", args, kwargs)) + + +def _logged_errors(recorder): + return [c for c in recorder.calls if c[0] == "error"] + + +async def _run_loop_over(monkeypatch, first_pass_raises): + """Drive the loop over two passes: the first raises `first_pass_raises`, the second stops + the loop with `CancelledError`. Returns the pass count and the recording logger.""" + passes = 0 + + async def fake_sweep(*_args, **_kwargs): + nonlocal passes + passes += 1 + if passes == 1: + raise first_pass_raises + raise asyncio.CancelledError() + + recorder = _RecordingLog() + monkeypatch.setattr(orphan_sweep, "run_orphan_sweep", fake_sweep) + monkeypatch.setattr(orphan_sweep, "log", recorder) + monkeypatch.setattr(orphan_sweep, "SWEEP_INTERVAL_SECONDS", 0) + monkeypatch.setattr(orphan_sweep.asyncio, "sleep", _noop_sleep) + + with pytest.raises(asyncio.CancelledError): + await orphan_sweep.orphan_sweep_loop(engine=None, lock_engine=None) + + return passes, recorder + + +@pytest.mark.anyio +async def test_a_failing_pass_is_logged_and_the_loop_continues( + anyio_backend, monkeypatch +): + # A real error from inside a pass -- the shape of the live UndefinedColumnError. + passes, recorder = await _run_loop_over( + monkeypatch, + RuntimeError("column session_executions.ending_written_at does not exist"), + ) + + # The loop survived the first error and ran a second pass. Before the fix, the handler + # itself raised AttributeError on the first pass and the loop never reached pass two. + assert passes == 2 + + errors = _logged_errors(recorder) + assert errors, "the failing pass must be logged" + assert errors[0][2].get("exc_info"), "the error must carry the traceback" + + +@pytest.mark.anyio +async def test_a_timed_out_pass_is_logged_and_the_loop_continues( + anyio_backend, monkeypatch +): + # `asyncio.wait_for` raises TimeoutError when it cuts a pass that runs too long. The loop + # must log it and go round again, never die. + passes, recorder = await _run_loop_over(monkeypatch, asyncio.TimeoutError()) + + assert passes == 2 + assert _logged_errors(recorder), "the timed-out pass must be logged" + + +@pytest.mark.anyio +async def test_a_hanging_pass_is_cut_by_the_timeout(anyio_backend, monkeypatch): + """A pass that blocks forever must be cut by `asyncio.wait_for`, not hang the loop. + + The production floor on `pass_timeout` is 120 s, so the loop's own timeout is patched to a + short value here to keep the test fast while still exercising the real `asyncio.wait_for`. + """ + passes = 0 + + async def fake_sweep(*_args, **_kwargs): + nonlocal passes + passes += 1 + if passes == 1: + await asyncio.Event().wait() # blocks forever + raise asyncio.CancelledError() + + recorder = _RecordingLog() + monkeypatch.setattr(orphan_sweep, "run_orphan_sweep", fake_sweep) + monkeypatch.setattr(orphan_sweep, "log", recorder) + monkeypatch.setattr(orphan_sweep, "SWEEP_INTERVAL_SECONDS", 0) + monkeypatch.setattr(orphan_sweep.asyncio, "sleep", _noop_sleep) + + real_wait_for = asyncio.wait_for + + async def short_wait_for(awaitable, timeout): # noqa: ARG001 + return await real_wait_for(awaitable, timeout=0.05) + + monkeypatch.setattr(orphan_sweep.asyncio, "wait_for", short_wait_for) + + with pytest.raises(asyncio.CancelledError): + await orphan_sweep.orphan_sweep_loop(engine=None, lock_engine=None) + + # The first pass hung; the timeout cut it and the loop ran a second pass. + assert passes == 2 + assert _logged_errors(recorder), "the cut pass must be logged" diff --git a/api/oss/tests/pytest/unit/sessions/test_heartbeat_departed_replica_affinity.py b/api/oss/tests/pytest/unit/sessions/test_heartbeat_departed_replica_affinity.py new file mode 100644 index 00000000000..8ce33508000 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_heartbeat_departed_replica_affinity.py @@ -0,0 +1,284 @@ +"""A runner that dies ungracefully must not lock its sessions out for the owner lease. + +Live failure this pins (matrix run 3, cell `runner-gone-late`, harness codex, session +2fdf43f0-c728-42e5-987e-2371501fe748): a Stop settled, the runner reported the outcome, and +the runner was then killed with no grace period. Nothing released `owner:session:`, so it +stayed pointing at the dead replica for the rest of OWNER_TTL_SECONDS. The replacement replica +picked up the user's next message 6 s later, its first heartbeat lost the non-stealing +`claim_owner`, the API answered `is_current_turn: false`, and the runner turned that into +"This session is already running a turn" although no turn was running anywhere. + +`running` is what tells a serving replica from a departed one, so these tests drive both +sides of it: + + - no running turn -> the new replica takes affinity and its first beat is current; + - a different turn holding `running` -> the claim is honoured and the newcomer is refused; + - the caller's OWN turn holding `running` (the `_start_turn` path) -> reclaim allowed; + - a turn-end beat never reclaims; + - the reclaim survives the alive lock the dead turn left behind (the whole point: the next + message has to actually run). +""" + +from typing import Optional +from unittest.mock import patch +from uuid import UUID, uuid4 + +import pytest +import pytest_asyncio + +from oss.src.core.sessions.streams.dtos import ( + SessionHeartbeatRequest, + SessionStream, +) +from oss.src.core.sessions.streams.service import SessionStreamsService +from oss.src.dbs.redis.sessions.locks import ( + claim_owner, + get_alive_owner, + get_owner, + get_owner_value, + get_running_owner, +) +from oss.src.dbs.redis.sessions.contract import make_owner_value + +from unit.sessions.test_project_scoped_locks import _FakeRedis + + +_PROJECT = uuid4() +_SESSION = "session_departed_replica" + +_DEAD = "replica-that-was-killed" +_FRESH = "replica-that-replaced-it" + + +class _FakeDAO: + def __init__(self, existing: Optional[SessionStream] = None): + self.row = existing + + async def get_by_session_id(self, *, project_id: UUID, session_id: str): + return self.row + + async def create(self, *, project_id, user_id, stream): + self.row = SessionStream( + id=uuid4(), + project_id=project_id, + session_id=stream.session_id, + flags=stream.flags, + turn_id=stream.turn_id, + ) + return self.row + + async def update(self, *, project_id, user_id, session_id, stream): + prior = self.row + self.row = SessionStream( + id=prior.id if prior else uuid4(), + project_id=project_id, + session_id=session_id, + flags=stream.flags + if stream.flags is not None + else (prior.flags if prior else None), + turn_id=stream.turn_id + if stream.turn_id is not None + else (prior.turn_id if prior else None), + ) + return self.row + + async def delete_by_session_id(self, *, project_id, session_id): + return True + + +@pytest_asyncio.fixture +async def lock_engine(): + from oss.src.dbs.redis.shared.engine import LockEngine + + eng = LockEngine() + with patch.object(eng, "_client", return_value=_FakeRedis()): + yield eng + + +def _service(lock_engine, dao=None): + return SessionStreamsService(streams_dao=dao or _FakeDAO(), lock_engine=lock_engine) + + +def _beat(replica: str, turn: Optional[str], running: bool = True): + return SessionHeartbeatRequest( + session_id=_SESSION, replica_id=replica, turn_id=turn, is_running=running + ) + + +async def _replay_the_killed_runner(svc): + """The exact state the live failure left: a turn that ran, was stopped, reported + `is_running: false`, and whose replica then died without releasing affinity.""" + await svc.heartbeat(project_id=_PROJECT, request=_beat(_DEAD, "turn-stopped")) + await svc.heartbeat( + project_id=_PROJECT, request=_beat(_DEAD, "turn-stopped", running=False) + ) + + +@pytest.mark.asyncio +async def test_next_turn_is_admitted_after_the_owning_runner_is_killed(lock_engine): + svc = _service(lock_engine) + pid = str(_PROJECT) + await _replay_the_killed_runner(svc) + + # Preconditions: affinity still names the dead replica, nothing is running, and the dead + # turn's `alive` lock outlives it by design. + assert await get_owner(lock_engine, project_id=pid, session_id=_SESSION) == _DEAD + assert ( + await get_running_owner(lock_engine, project_id=pid, session_id=_SESSION) + ) is None + assert await get_alive_owner(lock_engine, project_id=pid, session_id=_SESSION) == ( + "turn-stopped" + ) + + recovery = await svc.heartbeat( + project_id=_PROJECT, request=_beat(_FRESH, "turn-recovery") + ) + + assert recovery.is_current_turn is True, ( + "the replacement replica was refused, so the user's next message is rejected as " + "'this session is already running a turn' for the rest of the owner lease" + ) + assert recovery.replica_id == _FRESH + assert await get_owner(lock_engine, project_id=pid, session_id=_SESSION) == _FRESH + + +@pytest.mark.asyncio +async def test_recovery_turn_takes_the_nest_the_dead_turn_left(lock_engine): + """Admission is not enough: the recovery turn must end up owning alive and running, or + the next beat sees a foreign nest and aborts the turn it just started.""" + svc = _service(lock_engine) + pid = str(_PROJECT) + await _replay_the_killed_runner(svc) + + await svc.heartbeat(project_id=_PROJECT, request=_beat(_FRESH, "turn-recovery")) + + assert await get_alive_owner(lock_engine, project_id=pid, session_id=_SESSION) == ( + "turn-recovery" + ) + assert await get_running_owner( + lock_engine, project_id=pid, session_id=_SESSION + ) == ("turn-recovery") + + second = await svc.heartbeat( + project_id=_PROJECT, request=_beat(_FRESH, "turn-recovery") + ) + assert second.is_current_turn is True + + +@pytest.mark.asyncio +async def test_a_live_turn_on_another_replica_still_refuses_the_newcomer(lock_engine): + """The guard this reclaim relaxes must still hold where it matters: a replica running a + turn keeps its session, and a second replica's turn is refused rather than admitted + alongside it.""" + svc = _service(lock_engine) + pid = str(_PROJECT) + + await svc.heartbeat(project_id=_PROJECT, request=_beat(_DEAD, "turn-live")) + + intruder = await svc.heartbeat( + project_id=_PROJECT, request=_beat(_FRESH, "turn-intruder") + ) + + assert intruder.is_current_turn is False + assert intruder.replica_id == _DEAD + assert await get_owner(lock_engine, project_id=pid, session_id=_SESSION) == _DEAD + assert await get_alive_owner(lock_engine, project_id=pid, session_id=_SESSION) == ( + "turn-live" + ) + assert await get_running_owner( + lock_engine, project_id=pid, session_id=_SESSION + ) == ("turn-live") + + +@pytest.mark.asyncio +async def test_a_turn_that_already_holds_running_may_reclaim(lock_engine): + """`_start_turn` arms alive and running before the runner beats at all, so an API-minted + turn reaches the heartbeat with its own `running` lock already held. That must not read as + 'another turn is live here'.""" + from oss.src.dbs.redis.sessions.locks import acquire_alive, acquire_running + + svc = _service(lock_engine) + pid = str(_PROJECT) + await _replay_the_killed_runner(svc) + # The API starts the recovery turn itself, then the replacement replica beats for it. + from oss.src.dbs.redis.sessions.locks import force_cancel_alive + + await force_cancel_alive(lock_engine, project_id=pid, session_id=_SESSION) + await acquire_alive( + lock_engine, project_id=pid, session_id=_SESSION, turn_id="turn-api-minted" + ) + await acquire_running( + lock_engine, project_id=pid, session_id=_SESSION, turn_id="turn-api-minted" + ) + + result = await svc.heartbeat( + project_id=_PROJECT, request=_beat(_FRESH, "turn-api-minted") + ) + + assert result.is_current_turn is True + assert await get_owner(lock_engine, project_id=pid, session_id=_SESSION) == _FRESH + + +@pytest.mark.asyncio +async def test_reclaim_does_not_clear_a_refreshed_owner_generation(lock_engine): + """A same-replica new turn may refresh affinity after the failed claim is observed.""" + svc = _service(lock_engine) + pid = str(_PROJECT) + await _replay_the_killed_runner(svc) + + async def refresh_owner_generation(engine, *, project_id, session_id): + await claim_owner( + engine, + project_id=project_id, + session_id=session_id, + replica_id=_DEAD, + turn_id="turn-new-on-incumbent", + ) + return None + + with patch( + "oss.src.core.sessions.streams.service.get_running_owner", + side_effect=refresh_owner_generation, + ): + result = await svc.heartbeat( + project_id=_PROJECT, request=_beat(_FRESH, "turn-challenger") + ) + + assert result.is_current_turn is False + assert result.replica_id == _DEAD + assert await get_owner_value( + lock_engine, project_id=pid, session_id=_SESSION + ) == make_owner_value( + replica_id=_DEAD, + turn_id="turn-new-on-incumbent", + ) + + +@pytest.mark.asyncio +async def test_a_turn_end_beat_never_reclaims_affinity(lock_engine): + """A beat that reports a turn ENDING asserts nothing about who should serve the session + next, so it must leave affinity alone.""" + svc = _service(lock_engine) + pid = str(_PROJECT) + await _replay_the_killed_runner(svc) + + result = await svc.heartbeat( + project_id=_PROJECT, request=_beat(_FRESH, "turn-recovery", running=False) + ) + + assert result.is_current_turn is False + assert result.replica_id == _DEAD + assert await get_owner(lock_engine, project_id=pid, session_id=_SESSION) == _DEAD + + +@pytest.mark.asyncio +async def test_a_beat_with_no_turn_never_reclaims_affinity(lock_engine): + """The ownership-probe beat carries no turn id. It reads affinity; it may not move it.""" + svc = _service(lock_engine) + pid = str(_PROJECT) + await _replay_the_killed_runner(svc) + + result = await svc.heartbeat(project_id=_PROJECT, request=_beat(_FRESH, None)) + + assert result.replica_id == _DEAD + assert await get_owner(lock_engine, project_id=pid, session_id=_SESSION) == _DEAD diff --git a/api/oss/tests/pytest/unit/sessions/test_heartbeat_is_current_turn.py b/api/oss/tests/pytest/unit/sessions/test_heartbeat_is_current_turn.py index fa3595d73c3..39b2c88f1fe 100644 --- a/api/oss/tests/pytest/unit/sessions/test_heartbeat_is_current_turn.py +++ b/api/oss/tests/pytest/unit/sessions/test_heartbeat_is_current_turn.py @@ -200,3 +200,109 @@ async def test_new_turn_on_a_previously_run_session_is_current(lock_engine): assert fresh.is_current_turn is True, ( "a new turn must not be aborted just because the row still named the old one" ) + + +@pytest.mark.asyncio +async def test_second_turn_on_a_RUNNING_session_is_refused(lock_engine): + """Single-turn admission (#6417, #5539, #5538): the answer the runner's edge now acts on. + + A second user message on a session with a turn in flight reaches the runner as its own turn. + Its FIRST beat is the admission request, and this is what must come back: `is_current_turn` + False, with the running turn's locks untouched. The API already answered this correctly; the + runner used to read it only as "abort later", walk into the keepalive pool, and destroy the + running turn's environment on the way. It now stops at the edge, so this answer is the whole + gate and it needs its own test. + """ + svc = _service(lock_engine) + + # turn-1 is live: it holds both `alive` and `running`. + await svc.heartbeat(project_id=_PROJECT, request=_beat("replica-a", "turn-1")) + + # The second message arrives on the SAME replica as its own turn. Nothing cancelled turn-1, + # so `running` still names it — the discriminator that separates this from a handover. + second = await svc.heartbeat( + project_id=_PROJECT, request=_beat("replica-a", "turn-2") + ) + + assert second.is_current_turn is False, ( + "a turn that arrives while a DIFFERENT turn holds `running` must be refused" + ) + assert ( + await get_alive_owner( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION + ) + == "turn-1" + ), "the refused turn must not take the running turn's alive lock" + + # And the live turn's own next beat is unaffected: it was never displaced. + still_live = await svc.heartbeat( + project_id=_PROJECT, request=_beat("replica-a", "turn-1") + ) + assert still_live.is_current_turn is True + + +@pytest.mark.asyncio +async def test_a_refused_turns_end_beat_cannot_clear_the_live_turns_running( + lock_engine, +): + """The refused turn's watchdog release sends `is_running: false`. That beat must be inert. + + The runner stops a refused turn by releasing its watchdog, which sends one end beat under the + REFUSED turn's id. Releasing `running` on behalf of whoever holds it would end the live turn + from under itself, which is the failure this whole slice exists to remove. The release is + owner-scoped, so it is a no-op here. + """ + svc = _service(lock_engine) + + await svc.heartbeat(project_id=_PROJECT, request=_beat("replica-a", "turn-1")) + await svc.heartbeat(project_id=_PROJECT, request=_beat("replica-a", "turn-2")) + + # The refused turn's end beat. + await svc.heartbeat( + project_id=_PROJECT, request=_beat("replica-a", "turn-2", running=False) + ) + + live = await svc.heartbeat( + project_id=_PROJECT, request=_beat("replica-a", "turn-1") + ) + assert live.is_current_turn is True, ( + "the refused turn's end beat released the LIVE turn's locks" + ) + assert ( + await get_alive_owner( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION + ) + == "turn-1" + ) + + +@pytest.mark.asyncio +async def test_a_resume_is_admitted_while_the_previous_turn_is_PARKED(lock_engine): + """The case a naive "is anything alive?" gate gets wrong, and the reason `running` exists. + + A turn parked awaiting approval still holds `alive` — that is what makes the session + reattachable — but its turn-end beat released `running`. The approval resume arrives as a NEW + turn and must be admitted, or every approval in the product stops resuming. `alive` alone + cannot tell this apart from the refusal case above; the absent `running` owner is what does. + """ + svc = _service(lock_engine) + + await svc.heartbeat(project_id=_PROJECT, request=_beat("replica-a", "turn-1")) + # Park: the turn ends its execution but the session stays alive. + await svc.heartbeat( + project_id=_PROJECT, request=_beat("replica-a", "turn-1", running=False) + ) + + resume = await svc.heartbeat( + project_id=_PROJECT, request=_beat("replica-a", "turn-2") + ) + + assert resume.is_current_turn is True, ( + "an approval resume must be admitted while the previous turn is parked, not running" + ) + assert ( + await get_alive_owner( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION + ) + == "turn-2" + ), "the resume takes the nest as a legitimate handover" diff --git a/api/oss/tests/pytest/unit/sessions/test_heartbeat_lock_races.py b/api/oss/tests/pytest/unit/sessions/test_heartbeat_lock_races.py index e41784244df..7fd1bca0521 100644 --- a/api/oss/tests/pytest/unit/sessions/test_heartbeat_lock_races.py +++ b/api/oss/tests/pytest/unit/sessions/test_heartbeat_lock_races.py @@ -20,7 +20,6 @@ ) from oss.src.core.sessions.streams.service import SessionStreamsService from oss.src.dbs.redis.sessions.locks import ( - clear_running, force_clear_owner, get_alive_owner, get_owner, @@ -163,24 +162,23 @@ async def test_handover_will_not_evict_a_turn_that_took_the_lock_mid_read(lock_e @pytest.mark.asyncio -async def test_cancel_tombstones_before_it_clears_the_locks(lock_engine): - """Cancel clears `alive` and then tombstones the turn it displaced. A beat from that very - turn arriving between the two finds `alive` free, nx-acquires it back, and the cancelled - session reads as alive for a full ALIVE_TTL. Writing the tombstone first closes it.""" +async def test_cancel_atomically_tombstones_and_clears_the_locks(lock_engine): + """The displaced turn cannot re-arm the session after the atomic operation returns.""" dao = _FakeStreamsDAO() svc = _service(lock_engine, dao) await svc.heartbeat(project_id=_PROJECT, request=_beat("turn-a")) assert await _alive(lock_engine) == "turn-a" + redis = lock_engine._client() + original_eval = redis.eval - async def _beat_mid_displacement(engine, *, project_id: str, session_id: str): - await svc.heartbeat(project_id=_PROJECT, request=_beat("turn-a")) - return await clear_running(engine, project_id=project_id, session_id=session_id) + async def _beat_after_atomic_displacement(script, numkeys, *keys_and_args): + result = await original_eval(script, numkeys, *keys_and_args) + if "AGENTA_DISPLACE_TURNS" in script: + late = await svc.heartbeat(project_id=_PROJECT, request=_beat("turn-a")) + assert late.is_current_turn is False + return result - # `clear_running` runs after `alive` is cleared, i.e. inside the old window. - with patch( - "oss.src.core.sessions.streams.service.clear_running", - new=_beat_mid_displacement, - ): + with patch.object(redis, "eval", new=_beat_after_atomic_displacement): await svc.command(project_id=_PROJECT, user_id=_USER, request=_cancel()) assert await _alive(lock_engine) is None, ( diff --git a/api/oss/tests/pytest/unit/sessions/test_heartbeat_release_auth.py b/api/oss/tests/pytest/unit/sessions/test_heartbeat_release_auth.py new file mode 100644 index 00000000000..96c0e421335 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_heartbeat_release_auth.py @@ -0,0 +1,97 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import UUID + +import pytest +from fastapi import HTTPException + +from oss.src.apis.fastapi.sessions import router as router_module +from oss.src.apis.fastapi.sessions.router import SessionStreamsRouter +from oss.src.core.sessions.streams.dtos import ( + SessionHeartbeatRequest, + SessionHeartbeatResult, +) +from oss.src.utils.env import env + + +_PROJECT = UUID("00000000-0000-0000-0000-0000000000aa") +_USER = UUID("00000000-0000-0000-0000-0000000000bb") + + +def _request(headers=None): + return SimpleNamespace( + state=SimpleNamespace(project_id=_PROJECT, user_id=_USER), + headers=headers or {}, + ) + + +def _router(service): + return SessionStreamsRouter( + service=service, + interactions_service=SimpleNamespace(), + ) + + +@pytest.mark.asyncio +async def test_release_owner_heartbeat_requires_the_runner_token(monkeypatch): + monkeypatch.setattr(env.runner, "token", "runner-secret") + monkeypatch.setattr( + router_module, "check_action_access", AsyncMock(return_value=True) + ) + service = SimpleNamespace(heartbeat=AsyncMock()) + + with pytest.raises(HTTPException) as exc_info: + await _router(service).heartbeat_session_stream( + _request(), + SessionHeartbeatRequest( + session_id="session-1", + replica_id="replica-1", + release_owner=True, + ), + ) + + assert exc_info.value.status_code == 401 + service.heartbeat.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_regular_heartbeat_keeps_user_authentication_only(monkeypatch): + monkeypatch.setattr(env.runner, "token", "runner-secret") + monkeypatch.setattr( + router_module, "check_action_access", AsyncMock(return_value=True) + ) + service = SimpleNamespace( + heartbeat=AsyncMock(return_value=SessionHeartbeatResult(replica_id="replica-1")) + ) + payload = SessionHeartbeatRequest( + session_id="session-1", + replica_id="replica-1", + turn_id="turn-1", + ) + + result = await _router(service).heartbeat_session_stream(_request(), payload) + + assert result.replica_id == "replica-1" + service.heartbeat.assert_awaited_once_with(project_id=_PROJECT, request=payload) + + +@pytest.mark.asyncio +async def test_release_owner_accepts_the_shared_runner_token(monkeypatch): + monkeypatch.setattr(env.runner, "token", "runner-secret") + monkeypatch.setattr( + router_module, "check_action_access", AsyncMock(return_value=True) + ) + service = SimpleNamespace( + heartbeat=AsyncMock(return_value=SessionHeartbeatResult(replica_id="replica-1")) + ) + payload = SessionHeartbeatRequest( + session_id="session-1", + replica_id="replica-1", + release_owner=True, + ) + + await _router(service).heartbeat_session_stream( + _request({"X-Agenta-Runner-Token": "runner-secret"}), payload + ) + + service.heartbeat.assert_awaited_once_with(project_id=_PROJECT, request=payload) diff --git a/api/oss/tests/pytest/unit/sessions/test_heartbeat_release_owner.py b/api/oss/tests/pytest/unit/sessions/test_heartbeat_release_owner.py new file mode 100644 index 00000000000..880dc7a507f --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_heartbeat_release_owner.py @@ -0,0 +1,220 @@ +"""The shutdown beat: a departing runner hands its `owner:session:` affinity key back. + +`claim_owner` never steals, and nothing released the key, so a replica that exited while +holding claims locked each of those sessions out of its replacement for the rest of +OWNER_TTL_SECONDS. On the local sandbox provider that is a two-minute outage after every +runner restart, because the replacement refuses to cold-start a session it does not own. + +`release_owner` is deliberately narrow, and these tests pin exactly how narrow: it releases +only while the caller still owns the session, it touches no turn lock and no stream row, and a +beat from a replica that lost the session is a no-op rather than a takeover in reverse. +""" + +from typing import Optional +from unittest.mock import patch +from uuid import UUID, uuid4 + +import pytest +import pytest_asyncio + +from oss.src.core.sessions.streams.dtos import ( + SessionHeartbeatRequest, + SessionStream, +) +from oss.src.core.sessions.streams.service import SessionStreamsService +from oss.src.dbs.redis.sessions.locks import ( + get_alive_owner, + get_owner, + get_running_owner, +) + +from unit.sessions.test_project_scoped_locks import _FakeRedis + + +_PROJECT = uuid4() +_SESSION = "session_shutdown" + + +class _FakeDAO: + """Records every write, so a test can assert the release beat wrote nothing.""" + + def __init__(self, existing: Optional[SessionStream] = None): + self.row = existing + self.creates = 0 + self.updates = 0 + + async def get_by_session_id(self, *, project_id: UUID, session_id: str): + return self.row + + async def create(self, *, project_id, user_id, stream): + self.creates += 1 + self.row = SessionStream( + id=uuid4(), + project_id=project_id, + session_id=stream.session_id, + flags=stream.flags, + ) + return self.row + + async def update(self, *, project_id, user_id, session_id, stream): + self.updates += 1 + self.row = SessionStream( + id=self.row.id if self.row else uuid4(), + project_id=project_id, + session_id=session_id, + flags=stream.flags, + ) + return self.row + + async def delete_by_session_id(self, *, project_id, session_id): + return True + + +@pytest_asyncio.fixture +async def lock_engine(): + from oss.src.dbs.redis.shared.engine import LockEngine + + eng = LockEngine() + with patch.object(eng, "_client", return_value=_FakeRedis()): + yield eng + + +def _service(lock_engine, dao): + return SessionStreamsService(streams_dao=dao, lock_engine=lock_engine) + + +def _beat(replica: str, turn: str, running: bool = True) -> SessionHeartbeatRequest: + return SessionHeartbeatRequest( + session_id=_SESSION, replica_id=replica, turn_id=turn, is_running=running + ) + + +def _shutdown_beat(replica: str) -> SessionHeartbeatRequest: + """What the runner sends per owned session as it exits: no turn, no liveness.""" + return SessionHeartbeatRequest( + session_id=_SESSION, replica_id=replica, release_owner=True + ) + + +@pytest.mark.asyncio +async def test_owner_release_drops_the_affinity_key(lock_engine): + dao = _FakeDAO() + svc = _service(lock_engine, dao) + pid = str(_PROJECT) + + await svc.heartbeat(project_id=_PROJECT, request=_beat("replica-a", "turn-a")) + assert await get_owner(lock_engine, project_id=pid, session_id=_SESSION) == ( + "replica-a" + ) + + await svc.heartbeat(project_id=_PROJECT, request=_shutdown_beat("replica-a")) + + assert await get_owner(lock_engine, project_id=pid, session_id=_SESSION) is None, ( + "the departing replica still owns the session" + ) + + +@pytest.mark.asyncio +async def test_the_next_replica_can_claim_the_session_at_once(lock_engine): + """The whole point: no waiting out OWNER_TTL_SECONDS after a restart.""" + dao = _FakeDAO() + svc = _service(lock_engine, dao) + pid = str(_PROJECT) + + await svc.heartbeat(project_id=_PROJECT, request=_beat("replica-a", "turn-a")) + await svc.heartbeat(project_id=_PROJECT, request=_shutdown_beat("replica-a")) + + result = await svc.heartbeat( + project_id=_PROJECT, request=_beat("replica-b", "turn-b") + ) + + assert result.replica_id == "replica-b" + assert await get_owner(lock_engine, project_id=pid, session_id=_SESSION) == ( + "replica-b" + ) + + +@pytest.mark.asyncio +async def test_release_touches_no_turn_lock_and_no_row(lock_engine): + dao = _FakeDAO() + svc = _service(lock_engine, dao) + pid = str(_PROJECT) + + await svc.heartbeat(project_id=_PROJECT, request=_beat("replica-a", "turn-a")) + writes_before = dao.creates + dao.updates + + await svc.heartbeat(project_id=_PROJECT, request=_shutdown_beat("replica-a")) + + assert await get_alive_owner(lock_engine, project_id=pid, session_id=_SESSION) == ( + "turn-a" + ), "the release beat cleared the alive lock" + assert await get_running_owner( + lock_engine, project_id=pid, session_id=_SESSION + ) == ("turn-a"), "the release beat cleared the running lock" + assert dao.creates + dao.updates == writes_before, ( + "the release beat stamped the stream row" + ) + + +@pytest.mark.asyncio +async def test_a_replica_that_lost_the_session_releases_nothing(lock_engine): + """Release-if-owner: a stale runner must not free a session a live one now holds.""" + dao = _FakeDAO() + svc = _service(lock_engine, dao) + pid = str(_PROJECT) + + await svc.heartbeat(project_id=_PROJECT, request=_beat("replica-a", "turn-a")) + + result = await svc.heartbeat( + project_id=_PROJECT, request=_shutdown_beat("replica-b") + ) + + assert await get_owner(lock_engine, project_id=pid, session_id=_SESSION) == ( + "replica-a" + ), "replica B released a session it never owned" + assert result.replica_id == "replica-a", "the loser must learn the true owner" + + +@pytest.mark.asyncio +async def test_release_is_idempotent(lock_engine): + dao = _FakeDAO() + svc = _service(lock_engine, dao) + pid = str(_PROJECT) + + await svc.heartbeat(project_id=_PROJECT, request=_beat("replica-a", "turn-a")) + await svc.heartbeat(project_id=_PROJECT, request=_shutdown_beat("replica-a")) + result = await svc.heartbeat( + project_id=_PROJECT, request=_shutdown_beat("replica-a") + ) + + assert await get_owner(lock_engine, project_id=pid, session_id=_SESSION) is None + assert result.replica_id == "replica-a", "an unowned session reports the caller" + assert result.is_current_turn is False, "a release beat refreshes no turn" + + +@pytest.mark.asyncio +async def test_release_of_a_session_nobody_owns_is_harmless(lock_engine): + dao = _FakeDAO() + svc = _service(lock_engine, dao) + + result = await svc.heartbeat( + project_id=_PROJECT, request=_shutdown_beat("replica-a") + ) + + assert result.stream is None + assert dao.creates + dao.updates == 0 + + +@pytest.mark.asyncio +async def test_an_ordinary_beat_still_claims(lock_engine): + """The default must not change: `release_owner` is False unless a caller asks for it.""" + dao = _FakeDAO() + svc = _service(lock_engine, dao) + pid = str(_PROJECT) + + assert _beat("replica-a", "turn-a").release_owner is False + await svc.heartbeat(project_id=_PROJECT, request=_beat("replica-a", "turn-a")) + + assert await get_owner(lock_engine, project_id=pid, session_id=_SESSION) == ( + "replica-a" + ) diff --git a/api/oss/tests/pytest/unit/sessions/test_heartbeat_turn_handover.py b/api/oss/tests/pytest/unit/sessions/test_heartbeat_turn_handover.py index 68fc63e4573..e206cb7076d 100644 --- a/api/oss/tests/pytest/unit/sessions/test_heartbeat_turn_handover.py +++ b/api/oss/tests/pytest/unit/sessions/test_heartbeat_turn_handover.py @@ -174,28 +174,15 @@ async def test_overlapping_beats_of_the_same_turn_stay_current(lock_engine): lock_engine, project_id=str(_PROJECT), session_id=_SESSION, turn_id="turn-1" ) - cancels: list[str] = [] - - async def _spy_force_cancel(engine, *, project_id, session_id): - cancels.append(session_id) - return None - # refresh_alive returning False while the key holds OUR id is exactly the interleaving: # the GET raced the concurrent beat's write. - with ( - patch( - "oss.src.core.sessions.streams.service.refresh_alive", - new=AsyncMock(return_value=False), - ), - patch( - "oss.src.core.sessions.streams.service.force_cancel_alive", - new=_spy_force_cancel, - ), + with patch( + "oss.src.core.sessions.streams.service.refresh_alive", + new=AsyncMock(return_value=False), ): result = await svc.heartbeat(project_id=_PROJECT, request=_beat("turn-1")) assert result.is_current_turn is True - assert cancels == [], "we already own `alive`; there is nothing to hand over" assert await _alive(lock_engine) == "turn-1" diff --git a/api/oss/tests/pytest/unit/sessions/test_interaction_cancel_records.py b/api/oss/tests/pytest/unit/sessions/test_interaction_cancel_records.py new file mode 100644 index 00000000000..743d734d4b5 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_interaction_cancel_records.py @@ -0,0 +1,205 @@ +from unittest.mock import AsyncMock, patch +from uuid import uuid4 + +import pytest +from fastapi import FastAPI, HTTPException, Request + +from oss.src.apis.fastapi.sessions.models import SessionInteractionRespondRequest +from oss.src.apis.fastapi.sessions.router import InteractionsRouter +from oss.src.core.sessions.interactions.dtos import ( + SessionInteraction, + SessionInteractionKind, + SessionInteractionStatus, +) +from oss.src.core.sessions.interactions.service import SessionInteractionsService + + +class _RecordingPublisher: + def __init__(self, journal): + self.journal = journal + self.calls = [] + + async def interaction(self, *, project_id, session_id, status): + self.journal.append("publish") + self.calls.append((project_id, session_id, status)) + + +class _RecordingRecordsService: + def __init__(self, journal): + self.journal = journal + self.events = [] + + async def append_many(self, *, events): + self.journal.append("records") + self.events.extend(events) + return [] + + +class _FailingRecordsService: + async def append_many(self, *, events): + raise RuntimeError("records unavailable") + + +def _interaction(*, project_id, token, turn_id="turn-1"): + return SessionInteraction( + id=uuid4(), + project_id=project_id, + session_id="sess-1", + turn_id=turn_id, + token=token, + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.cancelled, + ) + + +@pytest.mark.asyncio +async def test_stop_cancel_writes_one_record_per_cancelled_interaction_before_publish(): + project_id = uuid4() + command_id = uuid4() + cancelled = [ + _interaction(project_id=project_id, token="gate-1"), + _interaction(project_id=project_id, token="gate-2"), + ] + dao = AsyncMock() + dao.cancel_session_pending = AsyncMock(return_value=cancelled) + journal = [] + records = _RecordingRecordsService(journal) + publisher = _RecordingPublisher(journal) + service = SessionInteractionsService( + interactions_dao=dao, + records_service=records, + watch_publisher=publisher, + ) + + count = await service.cancel_session_pending( + project_id=project_id, + session_id="sess-1", + only_turn_id="turn-1", + command_id=command_id, + ) + + assert count == 2 + assert len(records.events) == 2 + assert len({event.record_id for event in records.events}) == 2 + for event, interaction in zip(records.events, cancelled): + assert event.record_type == "interaction_response" + assert event.record_source == "agent" + assert event.turn_id == "turn-1" + assert event.attributes == { + "type": "interaction_response", + "id": interaction.token, + "kind": "user_approval", + "payload": { + "outcome": "cancelled", + "turnId": "turn-1", + "commandId": str(command_id), + }, + } + assert journal == ["records", "publish"] + assert publisher.calls == [(str(project_id), "sess-1", "resolved")] + + +@pytest.mark.asyncio +async def test_stop_cancel_writes_no_record_when_nothing_was_pending(): + project_id = uuid4() + dao = AsyncMock() + dao.cancel_session_pending = AsyncMock(return_value=[]) + journal = [] + records = _RecordingRecordsService(journal) + publisher = _RecordingPublisher(journal) + service = SessionInteractionsService( + interactions_dao=dao, + records_service=records, + watch_publisher=publisher, + ) + + count = await service.cancel_session_pending( + project_id=project_id, + session_id="sess-1", + only_turn_id="turn-1", + command_id=uuid4(), + ) + + assert count == 0 + assert records.events == [] + assert publisher.calls == [] + assert journal == [] + + +@pytest.mark.asyncio +async def test_record_failure_does_not_block_interaction_resolution_publish(): + project_id = uuid4() + dao = AsyncMock() + dao.cancel_session_pending = AsyncMock( + return_value=[_interaction(project_id=project_id, token="gate-1")] + ) + journal = [] + publisher = _RecordingPublisher(journal) + service = SessionInteractionsService( + interactions_dao=dao, + records_service=_FailingRecordsService(), + watch_publisher=publisher, + ) + + count = await service.cancel_session_pending( + project_id=project_id, + session_id="sess-1", + only_turn_id="turn-1", + command_id=uuid4(), + ) + + assert count == 1 + assert journal == ["publish"] + assert publisher.calls == [(str(project_id), "sess-1", "resolved")] + + +@pytest.mark.asyncio +async def test_answer_after_stop_returns_the_terminal_interaction_409_contract(): + project_id = uuid4() + user_id = uuid4() + interaction_id = uuid4() + interactions_service = AsyncMock() + interactions_service.fetch_interaction.return_value = SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="sess-1", + turn_id="turn-1", + token="gate-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.cancelled, + ) + respond_task = AsyncMock() + respond_task.kiq = AsyncMock() + router = InteractionsRouter( + interactions_service=interactions_service, + workflows_service=AsyncMock(), + respond_task=respond_task, + ) + request = Request( + { + "type": "http", + "method": "POST", + "path": f"/sessions/interactions/{interaction_id}/respond", + "headers": [], + "app": FastAPI(), + } + ) + request.state.project_id = project_id + request.state.user_id = user_id + + with patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ): + with pytest.raises(HTTPException) as exc_info: + await router.respond_interaction( + request=request, + interaction_id=interaction_id, + body=SessionInteractionRespondRequest(answer={"approved": True}), + ) + + assert exc_info.value.status_code == 409 + assert exc_info.value.detail == "Interaction is no longer pending" + interactions_service.transition_interaction.assert_not_awaited() + respond_task.kiq.assert_not_awaited() diff --git a/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine.py b/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine.py new file mode 100644 index 00000000000..b163fd60c6b --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine.py @@ -0,0 +1,572 @@ +"""The ingest guard that keeps one execution to one ending. + +RFC "Required behavior / Execution" item 3: after an execution reaches its terminal outcome, +later non-terminal output for it is rejected or quarantined. `RecordsService.append_many` is +where that is enforced, because ingest is the only place the watchdog and the runner meet. + +The case these tests pin was caught live. A runner wedges past the watchdog's stale-heartbeat +threshold, the watchdog writes the turn's `error` and `done` on its behalf, and the runner +then thaws and submits everything it had buffered: a tool call, its result, a `usage`, and a +second `done`. The reader was left with a failure notice followed by the work the agent went +on to do, and with two endings for one turn. + +Every test here drives the service against a stub DAO, so they run with no Postgres. The +DAO-level half — that a quarantined row is invisible to `get_records` and does not answer +`settled_turns` — lives in `test_late_record_quarantine_dao.py` against a real database. +""" + +from datetime import datetime, timezone +from typing import Dict, List, Optional, Sequence, Set, Tuple +from uuid import UUID, uuid4 + +import pytest + +from oss.src.core.sessions.records.dtos import ( + RECORD_SETTLED_BY_ATTRIBUTE, + SETTLED_BY_WATCHDOG, + SessionRecord, + SessionRecordEvent, +) +from oss.src.core.sessions.records.interfaces import RecordsDAOInterface +from oss.src.core.sessions.records.service import RecordsService +from oss.src.core.sessions.executions.dtos import ( + SessionExecutionSettlement, + SessionExecutionSettlementResult, +) +from oss.src.utils.env import env + + +_PROJECT = UUID("00000000-0000-0000-0000-0000000000aa") +_SESSION = "sess-late-tail" +_TURN = "turn-abc" + + +@pytest.fixture(autouse=True) +def _durable_stop_enabled(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + + +class _StubDAO(RecordsDAOInterface): + """Answers `settled_turns` from a fixed set and remembers what `append_many` was given. + + The settled sets belong to `project`, and the real DAO scopes its query the same way, so a + key from another project is never a hit however it is spelled. + """ + + def __init__( + self, + *, + watchdog_settled: Optional[Set[Tuple[str, str]]] = None, + any_settled: Optional[Set[Tuple[str, str]]] = None, + project: UUID = _PROJECT, + raises: bool = False, + ): + self.watchdog_settled = watchdog_settled or set() + self.any_settled = any_settled or set() + self.project = project + self.raises = raises + self.appended: List[SessionRecordEvent] = [] + self.lookups: List[Dict] = [] + + async def settled_turns( + self, + *, + project_id: UUID, + keys: Sequence[Tuple[str, str]], + settled_by: Optional[str] = None, + ) -> Set[Tuple[str, str]]: + self.lookups.append({"project_id": project_id, "settled_by": settled_by}) + if self.raises: + raise RuntimeError("tracing database is unreachable") + if project_id != self.project: + return set() + source = ( + self.watchdog_settled + if settled_by == SETTLED_BY_WATCHDOG + else self.any_settled + ) + return {key for key in keys if key in source} + + async def append_many( + self, *, events: List[SessionRecordEvent] + ) -> List[SessionRecord]: + self.appended.extend(events) + return [ + SessionRecord( + record_id=event.record_id or uuid4(), + session_id=event.session_id, + project_id=event.project_id, + record_index=event.record_index, + record_type=event.record_type, + record_source=event.record_source, + attributes=event.attributes, + turn_id=event.turn_id, + quarantined_at=event.quarantined_at, + ) + for event in events + ] + + +class _ExecutionSettlements: + def __init__(self, *, raises: bool = False, mark_raises: bool = False): + self.rows: Dict[Tuple[str, str], SessionExecutionSettlement] = {} + self.raises = raises + self.mark_raises = mark_raises + + async def settle( + self, + *, + project_id, + session_id, + execution_id, + terminal_outcome, + settled_by, + settled_at=None, + ): + key = (session_id, execution_id) + if key in self.rows: + return SessionExecutionSettlementResult( + settlement=self.rows[key], won=False + ) + row = SessionExecutionSettlement( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + terminal_outcome=terminal_outcome, + settled_by=settled_by, + settled_at=settled_at or datetime.now(timezone.utc), + ) + self.rows[key] = row + return SessionExecutionSettlementResult(settlement=row, won=True) + + async def query_settled(self, *, project_id, keys): + if self.raises: + raise RuntimeError("core database is unreachable") + return {key: self.rows[key] for key in keys if key in self.rows} + + async def mark_endings_written(self, *, project_id, keys, written_at=None): + if self.raises or self.mark_raises: + raise RuntimeError("core database is unreachable") + for key in keys: + if key in self.rows and self.rows[key].ending_written_at is None: + self.rows[key] = self.rows[key].model_copy( + update={ + "ending_written_at": written_at or datetime.now(timezone.utc) + } + ) + + +def _event(record_type: str, **over) -> SessionRecordEvent: + base = { + "project_id": _PROJECT, + "session_id": _SESSION, + "record_id": uuid4(), + "record_type": record_type, + "record_source": "agent", + "attributes": {"type": record_type}, + "turn_id": _TURN, + } + base.update(over) + return SessionRecordEvent(**base) + + +def _watchdog_event(record_type: str, **over) -> SessionRecordEvent: + """What `orphan_sweep._lost_turn_records` puts on the stream.""" + event = _event(record_type, **over) + event.attributes = { + **(event.attributes or {}), + RECORD_SETTLED_BY_ATTRIBUTE: SETTLED_BY_WATCHDOG, + } + return event + + +def _quarantined(dao: _StubDAO) -> List[SessionRecordEvent]: + return [event for event in dao.appended if event.quarantined_at is not None] + + +# --------------------------------------------------------------------------- # +# The tail: output produced before termination, delivered after it +# --------------------------------------------------------------------------- # + + +async def test_a_thawed_runners_tail_remains_visible_with_durable_stop_off( + monkeypatch, +): + """The live defect, in one test: four records land after the watchdog's ending.""" + monkeypatch.setattr(env.agenta.sessions, "durable_stop", False) + dao = _StubDAO(watchdog_settled={(_SESSION, _TURN)}) + service = RecordsService( + records_dao=dao, + executions_dao=_ExecutionSettlements(), + ) + + tail = [ + _event("tool_call"), + _event("tool_result"), + _event("usage"), + _event("done", attributes={"type": "done", "stopReason": "cancelled"}), + ] + results = await service.append_many(events=tail) + + # Flag-off retains the pre-milestone presentation: every late record remains visible. + assert len(results) == 4 + assert _quarantined(dao) == [] + assert all(row.quarantined_at is None for row in results) + + +async def test_reject_policy_drops_a_late_tail(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "late_output", "reject") + dao = _StubDAO(watchdog_settled={(_SESSION, _TURN)}) + service = RecordsService(records_dao=dao) + + results = await service.append_many(events=[_event("tool_result"), _event("usage")]) + + assert results == [] + assert dao.appended == [] + + +async def test_watchdog_winner_quarantines_the_runners_records(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + executions = _ExecutionSettlements() + winner = await executions.settle( + project_id=_PROJECT, + session_id=_SESSION, + execution_id=_TURN, + terminal_outcome="lost", + settled_by="watchdog", + ) + assert winner.won is True + dao = _StubDAO() + service = RecordsService(records_dao=dao, executions_dao=executions) + + await service.append_many( + events=[ + _event("usage"), + _event("done", attributes={"type": "done", "stopReason": "cancelled"}), + ] + ) + + assert [event.record_type for event in _quarantined(dao)] == ["usage", "done"] + + +async def test_watchdog_winner_rejects_the_runners_records_when_configured(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + monkeypatch.setattr(env.agenta.sessions, "late_output", "reject") + executions = _ExecutionSettlements() + await executions.settle( + project_id=_PROJECT, + session_id=_SESSION, + execution_id=_TURN, + terminal_outcome="lost", + settled_by="watchdog", + ) + dao = _StubDAO() + service = RecordsService(records_dao=dao, executions_dao=executions) + + results = await service.append_many(events=[_event("usage"), _event("done")]) + + assert results == [] + assert dao.appended == [] + + +async def test_runner_winner_quarantines_the_watchdogs_records(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + executions = _ExecutionSettlements() + winner = await executions.settle( + project_id=_PROJECT, + session_id=_SESSION, + execution_id=_TURN, + terminal_outcome="stopped", + settled_by="runner", + ) + assert winner.won is True + dao = _StubDAO() + service = RecordsService(records_dao=dao, executions_dao=executions) + + await service.append_many( + events=[_watchdog_event("error"), _watchdog_event("done")] + ) + + assert [event.record_type for event in _quarantined(dao)] == ["error", "done"] + + +async def test_output_after_the_runners_own_stop_is_ordinary_history(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + executions = _ExecutionSettlements() + dao = _StubDAO() + service = RecordsService(records_dao=dao, executions_dao=executions) + + await service.append_many(events=[_event("usage"), _event("done")]) + await service.append_many(events=[_event("tool_result")]) + + assert _quarantined(dao) == [] + + +async def test_an_ordinary_completion_row_does_not_make_trailing_usage_late( + monkeypatch, +): + monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + executions = _ExecutionSettlements() + await executions.settle( + project_id=_PROJECT, + session_id=_SESSION, + execution_id=_TURN, + terminal_outcome="completed", + settled_by="runner", + ) + dao = _StubDAO() + service = RecordsService(records_dao=dao, executions_dao=executions) + + await service.append_many(events=[_event("usage")]) + + assert _quarantined(dao) == [] + + +async def test_execution_lookup_failure_appends_the_batch_unguarded(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + dao = _StubDAO() + service = RecordsService( + records_dao=dao, + executions_dao=_ExecutionSettlements(raises=True), + ) + events = [_event("usage"), _event("done")] + + results = await service.append_many(events=events) + + assert len(results) == 2 + assert dao.appended == events + assert _quarantined(dao) == [] + + +async def test_ingest_does_not_write_a_terminal_execution(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + executions = _ExecutionSettlements() + service = RecordsService(records_dao=_StubDAO(), executions_dao=executions) + + await service.append_many( + events=[_event("done", attributes={"type": "done", "stopReason": "cancelled"})] + ) + + assert executions.rows == {} + + +async def test_ingest_marks_the_runners_terminal_record_written(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + executions = _ExecutionSettlements() + await executions.settle( + project_id=_PROJECT, + session_id=_SESSION, + execution_id=_TURN, + terminal_outcome="stopped", + settled_by="runner", + ) + service = RecordsService(records_dao=_StubDAO(), executions_dao=executions) + + await service.append_many( + events=[_event("done", attributes={"type": "done", "stopReason": "cancelled"})] + ) + + assert executions.rows[(_SESSION, _TURN)].ending_written_at is not None + + +async def test_ending_marker_failure_does_not_fail_record_ingest(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + executions = _ExecutionSettlements(mark_raises=True) + await executions.settle( + project_id=_PROJECT, + session_id=_SESSION, + execution_id=_TURN, + terminal_outcome="stopped", + settled_by="runner", + ) + dao = _StubDAO() + service = RecordsService(records_dao=dao, executions_dao=executions) + + results = await service.append_many(events=[_event("done")]) + + assert len(results) == 1 + assert [event.record_type for event in dao.appended] == ["done"] + assert executions.rows[(_SESSION, _TURN)].ending_written_at is None + + +async def test_the_guard_asks_only_about_watchdog_endings(): + dao = _StubDAO(watchdog_settled={(_SESSION, _TURN)}) + service = RecordsService(records_dao=dao) + + await service.append_many(events=[_event("usage")]) + + assert [lookup["settled_by"] for lookup in dao.lookups] == [SETTLED_BY_WATCHDOG] + + +async def test_a_late_terminal_record_is_quarantined_like_the_rest_of_the_tail(): + """One effective ending. The runner's contradicting `done` is kept, but not as history. + + Folding it into the watchdog's ending would rewrite the record the user has already + read, and would hide that two writers disagreed about how the turn finished. + """ + dao = _StubDAO(watchdog_settled={(_SESSION, _TURN)}) + service = RecordsService(records_dao=dao) + + await service.append_many( + events=[_event("done", attributes={"type": "done", "stopReason": "cancelled"})] + ) + + assert len(_quarantined(dao)) == 1 + assert _quarantined(dao)[0].record_type == "done" + + +# --------------------------------------------------------------------------- # +# What the guard must never touch +# --------------------------------------------------------------------------- # + + +async def test_an_ordinary_stop_the_watchdog_never_saw_is_untouched(): + """The runner's own honest single ending still lands, unmarked.""" + dao = _StubDAO(watchdog_settled=set()) + service = RecordsService(records_dao=dao) + + ending = [ + _event("usage"), + _event("done", attributes={"type": "done", "stopReason": "cancelled"}), + ] + results = await service.append_many(events=ending) + + assert _quarantined(dao) == [] + assert all(row.quarantined_at is None for row in results) + + +async def test_a_turn_the_runner_settled_itself_does_not_trigger_the_guard(): + """A terminal record is not enough; it has to be the WATCHDOG's. + + A `usage` that trails its own `done` through the stream is ordinary history, and a turn + that reached its own ending never lost the argument with the platform. + """ + dao = _StubDAO(watchdog_settled=set(), any_settled={(_SESSION, _TURN)}) + service = RecordsService(records_dao=dao) + + await service.append_many(events=[_event("usage")]) + + assert _quarantined(dao) == [] + + +async def test_the_watchdogs_own_records_are_never_quarantined(): + """Its `error` is not terminal, so without the exemption a redelivery would mark it.""" + dao = _StubDAO(watchdog_settled={(_SESSION, _TURN)}) + service = RecordsService(records_dao=dao) + + await service.append_many( + events=[ + _watchdog_event( + "error", attributes={"type": "error", "code": "execution_lost"} + ), + _watchdog_event("done"), + ] + ) + + assert _quarantined(dao) == [] + # And they are not even looked up: a watchdog record can never be late for its own turn. + assert dao.lookups == [] + + +async def test_a_record_with_no_turn_id_is_never_quarantined(): + """Nothing to attribute it to. Old records carry no turn key at all.""" + dao = _StubDAO(watchdog_settled={(_SESSION, _TURN)}) + service = RecordsService(records_dao=dao) + + await service.append_many(events=[_event("message", turn_id=None)]) + + assert _quarantined(dao) == [] + + +async def test_another_turn_in_the_same_session_is_untouched(): + """The user sent a new message after the failure; that turn is nobody's tail.""" + dao = _StubDAO(watchdog_settled={(_SESSION, _TURN)}) + service = RecordsService(records_dao=dao) + + await service.append_many( + events=[_event("message", turn_id="turn-next"), _event("usage")] + ) + + assert [event.record_type for event in _quarantined(dao)] == ["usage"] + + +# --------------------------------------------------------------------------- # +# Batching, redelivery, and failure +# --------------------------------------------------------------------------- # + + +async def test_a_watchdog_ending_settles_its_turn_for_the_rest_of_its_own_batch(): + """Ingest batches up to fifty messages; the tail can share one with the ending. + + Without this the DB lookup would find nothing — the ending is not committed yet — and the + tail would be appended as ordinary history. + """ + dao = _StubDAO(watchdog_settled=set()) + service = RecordsService(records_dao=dao) + + await service.append_many( + events=[ + _watchdog_event( + "error", attributes={"type": "error", "code": "execution_lost"} + ), + _watchdog_event("done"), + _event("tool_result"), + _event("done", attributes={"type": "done", "stopReason": "cancelled"}), + ] + ) + + assert [event.record_type for event in _quarantined(dao)] == ["tool_result", "done"] + + +async def test_redelivery_quarantines_the_same_records_again(): + """The stream replays on a consumer-group failure; the outcome must not drift. + + The upsert coalesces `quarantined_at`, so the row keeps the instant it was FIRST marked; + what this pins is that the guard's own verdict is the same on every delivery. + """ + dao = _StubDAO(watchdog_settled={(_SESSION, _TURN)}) + service = RecordsService(records_dao=dao) + + tail = [_event("tool_call"), _event("usage")] + first = await service.append_many(events=tail) + second = await service.append_many(events=tail) + + assert [row.record_id for row in first] == [row.record_id for row in second] + assert all(row.quarantined_at is not None for row in first + second) + + +async def test_a_failed_lookup_appends_the_batch_rather_than_losing_it(): + """Losing a record is worse than showing one that should have been hidden.""" + dao = _StubDAO(raises=True) + service = RecordsService(records_dao=dao) + + results = await service.append_many(events=[_event("tool_call"), _event("done")]) + + assert len(results) == 2 + assert _quarantined(dao) == [] + + +async def test_an_empty_batch_asks_the_database_nothing(): + dao = _StubDAO(watchdog_settled={(_SESSION, _TURN)}) + service = RecordsService(records_dao=dao) + + assert await service.append_many(events=[]) == [] + assert dao.lookups == [] + assert dao.appended == [] + + +async def test_each_project_in_a_batch_gets_its_own_lookup(): + """`settled_turns` is project-scoped; a mixed batch must not ask across the boundary.""" + other_project = UUID("00000000-0000-0000-0000-0000000000bb") + dao = _StubDAO(watchdog_settled={(_SESSION, _TURN)}) + service = RecordsService(records_dao=dao) + + await service.append_many( + events=[_event("usage"), _event("usage", project_id=other_project)] + ) + + assert sorted(str(lookup["project_id"]) for lookup in dao.lookups) == sorted( + [str(_PROJECT), str(other_project)] + ) + # Only the project whose turn the watchdog settled is affected. + assert [event.project_id for event in _quarantined(dao)] == [_PROJECT] diff --git a/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine_dao.py b/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine_dao.py new file mode 100644 index 00000000000..2d90c28626d --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine_dao.py @@ -0,0 +1,245 @@ +"""The database half of the late-record guard, against a real Postgres. + +The service decides WHICH records are late (`test_late_record_quarantine.py`, no database +needed). These tests pin what the mark then does, and none of it is visible from a stub: + + - a quarantined row is invisible to `get_records`, which is the read every transcript + reconstruction goes through, so one execution renders one ending; + - a quarantined row does not answer `settled_turns`, so a late `done` can never stand in + for the real ending and suppress the watchdog's next pass; + - `settled_by` narrows `settled_turns` to one writer; + - the upsert coalesces `quarantined_at`, so a redelivery keeps the first mark and can never + resurrect a row into the transcript. + +Requires the tracing_oss chain through oss000000005_add_records_quarantined_at, with +POSTGRES_URI_TRACING pointed at that database. +""" + +import uuid +from datetime import datetime, timedelta, timezone + +import pytest + +from oss.src.core.sessions.records.dtos import ( + RECORD_SETTLED_BY_ATTRIBUTE, + SETTLED_BY_WATCHDOG, + SessionRecordEvent, +) +from oss.src.dbs.postgres.sessions.records.dao import RecordsDAO +import oss.src.dbs.postgres.shared.engine as engine_module +from oss.src.dbs.postgres.shared.engine import get_analytics_engine + + +pytestmark = pytest.mark.integration + + +@pytest.fixture(autouse=True) +async def _fresh_engine_per_test(): + """Each pytest-asyncio test gets its own event loop; the module-level engine singleton + binds its asyncpg pool to the first loop that touches it.""" + engine_module._analytics_engine = None + yield + if engine_module._analytics_engine is not None: + await engine_module._analytics_engine.close() + engine_module._analytics_engine = None + + +def _ids(): + return uuid.uuid4(), f"late-record-test-{uuid.uuid4().hex[:8]}" + + +def _event(project_id, session_id, turn_id, record_type, **over): + base = dict( + project_id=project_id, + session_id=session_id, + record_id=uuid.uuid4(), + record_index=0, + record_type=record_type, + record_source="agent", + attributes={"type": record_type}, + turn_id=turn_id, + ) + base.update(over) + return SessionRecordEvent(**base) + + +def _watchdog_done(project_id, session_id, turn_id): + return _event( + project_id, + session_id, + turn_id, + "done", + record_index=1, + attributes={"type": "done", RECORD_SETTLED_BY_ATTRIBUTE: SETTLED_BY_WATCHDOG}, + ) + + +async def test_a_quarantined_record_is_absent_from_the_transcript(): + project_id, session_id = _ids() + turn_id = f"turn-{uuid.uuid4().hex[:8]}" + dao = RecordsDAO(engine=get_analytics_engine()) + + await dao.append_many( + events=[ + _event(project_id, session_id, turn_id, "message", record_index=0), + _watchdog_done(project_id, session_id, turn_id), + _event( + project_id, + session_id, + turn_id, + "tool_call", + record_index=2, + quarantined_at=datetime.now(timezone.utc), + ), + _event( + project_id, + session_id, + turn_id, + "done", + record_index=3, + quarantined_at=datetime.now(timezone.utc), + ), + ] + ) + + rows = await dao.get_records(project_id=project_id, session_id=session_id) + + assert [row.record_type for row in rows] == ["message", "done"] + # Exactly one ending, and it is the watchdog's. + endings = [row for row in rows if row.record_type == "done"] + assert len(endings) == 1 + assert endings[0].attributes[RECORD_SETTLED_BY_ATTRIBUTE] == SETTLED_BY_WATCHDOG + + +async def test_a_quarantined_terminal_record_does_not_settle_its_turn(): + project_id, session_id = _ids() + turn_id = f"turn-{uuid.uuid4().hex[:8]}" + dao = RecordsDAO(engine=get_analytics_engine()) + + await dao.append_many( + events=[ + _event( + project_id, + session_id, + turn_id, + "done", + quarantined_at=datetime.now(timezone.utc), + ) + ] + ) + + settled = await dao.settled_turns( + project_id=project_id, keys=[(session_id, turn_id)] + ) + + assert settled == set() + + +async def test_settled_by_narrows_the_answer_to_one_writer(): + project_id, session_id = _ids() + runner_turn = f"turn-{uuid.uuid4().hex[:8]}" + watchdog_turn = f"turn-{uuid.uuid4().hex[:8]}" + dao = RecordsDAO(engine=get_analytics_engine()) + + await dao.append_many( + events=[ + _event(project_id, session_id, runner_turn, "done"), + _watchdog_done(project_id, session_id, watchdog_turn), + ] + ) + + keys = [(session_id, runner_turn), (session_id, watchdog_turn)] + + # The watchdog's own idempotency question: has this turn ANY ending? + assert await dao.settled_turns(project_id=project_id, keys=keys) == set(keys) + # The ingest guard's question: did the PLATFORM end this turn? + assert await dao.settled_turns( + project_id=project_id, keys=keys, settled_by=SETTLED_BY_WATCHDOG + ) == {(session_id, watchdog_turn)} + + +async def test_a_redelivery_keeps_the_first_quarantine_instant(): + project_id, session_id = _ids() + turn_id = f"turn-{uuid.uuid4().hex[:8]}" + dao = RecordsDAO(engine=get_analytics_engine()) + + first_mark = datetime(2026, 9, 3, 12, 0, 0, tzinfo=timezone.utc) + event = _event(project_id, session_id, turn_id, "usage", quarantined_at=first_mark) + + await dao.append_many(events=[event]) + later = event.model_copy( + update={"quarantined_at": datetime(2026, 9, 3, 13, 0, 0, tzinfo=timezone.utc)} + ) + rows = await dao.append_many(events=[later]) + + assert rows[0].quarantined_at == first_mark + + +async def test_an_unmarked_redelivery_cannot_resurrect_a_quarantined_record(): + """Quarantine is one-way. A delivery that somehow arrives unguarded must not undo it.""" + project_id, session_id = _ids() + turn_id = f"turn-{uuid.uuid4().hex[:8]}" + dao = RecordsDAO(engine=get_analytics_engine()) + + mark = datetime.now(timezone.utc) + event = _event(project_id, session_id, turn_id, "tool_call", quarantined_at=mark) + await dao.append_many(events=[event]) + + await dao.append_many(events=[event.model_copy(update={"quarantined_at": None})]) + + rows = await dao.get_records(project_id=project_id, session_id=session_id) + assert rows == [] + + +async def test_an_ordinary_record_is_still_written_and_read_unmarked(): + project_id, session_id = _ids() + turn_id = f"turn-{uuid.uuid4().hex[:8]}" + dao = RecordsDAO(engine=get_analytics_engine()) + + await dao.append_many( + events=[ + _event(project_id, session_id, turn_id, "message", record_index=0), + _event(project_id, session_id, turn_id, "done", record_index=1), + ] + ) + + rows = await dao.get_records(project_id=project_id, session_id=session_id) + + assert [row.record_type for row in rows] == ["message", "done"] + assert all(row.quarantined_at is None for row in rows) + + +async def test_a_quarantined_message_never_becomes_the_session_preview(): + project_id, session_id = _ids() + turn_id = f"turn-{uuid.uuid4().hex[:8]}" + dao = RecordsDAO(engine=get_analytics_engine()) + + now = datetime.now(timezone.utc) + await dao.append_many( + events=[ + _event( + project_id, + session_id, + turn_id, + "message", + attributes={"type": "message", "text": "the real last message"}, + timestamp=now, + ), + _event( + project_id, + session_id, + turn_id, + "message", + attributes={"type": "message", "text": "written after the ending"}, + # Newer than the real one: without the filter this would win the preview. + timestamp=now + timedelta(seconds=10), + quarantined_at=now, + ), + ] + ) + + previews = await dao.latest_message_per_session( + project_id=project_id, session_ids=[session_id] + ) + + assert previews[session_id].text == "the real last message" diff --git a/api/oss/tests/pytest/unit/sessions/test_live_frame_ingest.py b/api/oss/tests/pytest/unit/sessions/test_live_frame_ingest.py new file mode 100644 index 00000000000..6e9cb628bf8 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_live_frame_ingest.py @@ -0,0 +1,377 @@ +from datetime import datetime, timezone +from unittest.mock import AsyncMock, patch +from uuid import uuid4 + +import pytest +from fastapi import FastAPI, HTTPException, Request +from pydantic import TypeAdapter, ValidationError +from oss.src.apis.fastapi.sessions.models import ( + SessionRecordIngestBody, + SessionRecordIngestRequest, +) +from oss.src.apis.fastapi.sessions.router import RecordsRouter +from oss.src.core.sessions.records.dtos import ( + MAX_LIVE_FRAME_BYTES, + MessageCompletedEvent, + SessionLiveFrame, + SessionRecordEvent, +) +from oss.src.core.sessions.records.streaming import ( + LIVE_FRAME_STREAM_NAME, + MAXLEN_STREAMS_RECORDS, + RECORD_STREAM_NAME, + publish_durable_event, + publish_live_frame, + publish_record, + trim_live_stream, +) +from oss.src.tasks.asyncio.sessions.records_worker import RecordsWorker +from oss.src.utils.env import env + + +def _request( + project_id, user_id, organization_id, *, content_length: int | None = None +) -> Request: + headers = [] + if content_length is not None: + headers.append((b"content-length", str(content_length).encode())) + request = Request( + { + "type": "http", + "method": "POST", + "path": "/sessions/records/ingest", + "headers": headers, + "app": FastAPI(), + } + ) + request.state.project_id = str(project_id) + request.state.user_id = str(user_id) + request.state.organization_id = str(organization_id) + return request + + +def _frame( + session_id: str = "session-1", + execution_id: str = "execution-1", + payload: dict | None = None, + frame_index: int = 0, +): + return SessionRecordIngestRequest( + version=1, + kind="frame", + session_id=session_id, + execution_id=execution_id, + frame_or_event_id=f"{execution_id}:{frame_index}", + frame_index=frame_index, + entity_id="message-1", + type="text-delta", + payload=payload or {"id": "message-1", "delta": "hello"}, + created_at=datetime.now(timezone.utc), + ) + + +async def test_frame_ingest_checks_current_execution_and_publishes(): + project_id = uuid4() + user_id = uuid4() + organization_id = uuid4() + router = RecordsRouter(records_service=AsyncMock()) + + with ( + patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ), + patch( + "oss.src.apis.fastapi.sessions.router.get_running_owner", + new_callable=AsyncMock, + return_value="execution-1", + ) as current, + patch( + "oss.src.apis.fastapi.sessions.router.publish_live_frame", + new_callable=AsyncMock, + return_value=True, + ) as publish, + ): + result = await router.ingest_record_event( + request=_request(project_id, user_id, organization_id), + body=_frame(), + ) + + assert result == {"ok": True} + current.assert_awaited_once() + published = publish.await_args.kwargs["frame"] + assert published.execution_id == "execution-1" + assert published.type == "text-delta" + + +async def test_frame_ingest_accepts_a_batch_and_publishes_in_order(): + project_id = uuid4() + user_id = uuid4() + organization_id = uuid4() + router = RecordsRouter(records_service=AsyncMock()) + payload = [_frame(frame_index=index).model_dump(mode="json") for index in range(3)] + body = TypeAdapter(SessionRecordIngestBody).validate_python(payload) + + assert isinstance(body, list) + with ( + patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ), + patch( + "oss.src.apis.fastapi.sessions.router.get_running_owner", + new_callable=AsyncMock, + return_value="execution-1", + ) as current, + patch( + "oss.src.apis.fastapi.sessions.router.publish_live_frame", + new_callable=AsyncMock, + return_value=True, + ) as publish, + ): + result = await router.ingest_record_event( + request=_request(project_id, user_id, organization_id), + body=body, + ) + + assert result == {"ok": True} + current.assert_awaited_once() + assert [call.kwargs["frame"].frame_index for call in publish.await_args_list] == [ + 0, + 1, + 2, + ] + + +async def test_frame_ingest_rejects_a_stale_execution(): + router = RecordsRouter(records_service=AsyncMock()) + with ( + patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ), + patch( + "oss.src.apis.fastapi.sessions.router.get_running_owner", + new_callable=AsyncMock, + return_value="execution-new", + ), + patch( + "oss.src.apis.fastapi.sessions.router.publish_live_frame", + new_callable=AsyncMock, + ) as publish, + ): + with pytest.raises(HTTPException) as exc_info: + await router.ingest_record_event( + request=_request(uuid4(), uuid4(), uuid4()), + body=_frame(execution_id="execution-stale"), + ) + + assert exc_info.value.status_code == 403 + publish.assert_not_awaited() + + +def test_frame_request_rejects_oversized_serialized_payload(): + with pytest.raises(ValidationError, match="serialized live frame exceeds"): + _frame(payload={"delta": "x" * MAX_LIVE_FRAME_BYTES}) + + +async def test_frame_ingest_rejects_oversized_content_length(): + router = RecordsRouter(records_service=AsyncMock()) + owner = AsyncMock() + publish = AsyncMock() + with ( + patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ), + patch( + "oss.src.apis.fastapi.sessions.router.get_running_owner", + owner, + ), + patch( + "oss.src.apis.fastapi.sessions.router.publish_live_frame", + publish, + ), + ): + with pytest.raises(HTTPException) as exc_info: + await router.ingest_record_event( + request=_request( + uuid4(), + uuid4(), + uuid4(), + content_length=MAX_LIVE_FRAME_BYTES + 1, + ), + body=_frame(), + ) + + assert exc_info.value.status_code == 413 + owner.assert_not_awaited() + publish.assert_not_awaited() + + +async def test_publish_frame_rejects_oversized_mutated_payload(): + redis = AsyncMock() + frame = SessionLiveFrame( + version=1, + kind="frame", + session_id="session-1", + execution_id="execution-1", + frame_or_event_id="execution-1:0", + frame_index=0, + entity_id="message-1", + type="text-delta", + payload={"delta": "hello"}, + created_at=datetime.now(timezone.utc), + ) + frame.payload = {"delta": "x" * MAX_LIVE_FRAME_BYTES} + + with patch( + "oss.src.core.sessions.records.streaming._get_redis", return_value=redis + ): + assert not await publish_live_frame(project_id=uuid4(), frame=frame) + + redis.xadd.assert_not_awaited() + + +async def test_publish_frame_uses_dedicated_bounded_stream(): + redis = AsyncMock() + frame = SessionLiveFrame( + version=1, + kind="frame", + session_id="session-1", + execution_id="execution-1", + frame_or_event_id="execution-1:0", + frame_index=0, + entity_id="message-1", + type="text-delta", + payload={"delta": "hello"}, + created_at=datetime.now(timezone.utc), + ) + with ( + patch("oss.src.core.sessions.records.streaming._get_redis", return_value=redis), + patch.object(env.sessions, "live_stream_maxlen", 4), + ): + assert await publish_live_frame(project_id=uuid4(), frame=frame) + + xadd = redis.xadd.await_args.kwargs + assert xadd["name"] == LIVE_FRAME_STREAM_NAME + assert isinstance(xadd["fields"]["data"], bytes) + assert xadd["maxlen"] == 4 + # The live stream carries disposable frames, so trimming is approximate on the hot path. + assert xadd["approximate"] is True + redis.xtrim.assert_awaited_once() + assert redis.xtrim.await_args.kwargs["approximate"] is True + + +async def test_publish_durable_event_uses_dedicated_bounded_stream(): + redis = AsyncMock() + event = MessageCompletedEvent.model_validate( + { + "session_id": "session-1", + "execution_id": "execution-1", + "frame_or_event_id": "event-1", + "entity_id": "message-1", + "sequence": 1, + "watermark": 1, + "type": "message.completed", + "payload": { + "message_id": "message-1", + "role": "assistant", + "content": "hello", + }, + "created_at": datetime.now(timezone.utc), + } + ) + with ( + patch("oss.src.core.sessions.records.streaming._get_redis", return_value=redis), + patch.object(env.sessions, "live_stream_maxlen", 4), + ): + assert await publish_durable_event(project_id=uuid4(), event=event) + + xadd = redis.xadd.await_args.kwargs + assert xadd["name"] == LIVE_FRAME_STREAM_NAME + assert xadd["name"] != RECORD_STREAM_NAME + assert xadd["maxlen"] == 4 + assert xadd["approximate"] is True + + +async def test_publish_record_preserves_flag_off_retention_bound(): + redis = AsyncMock() + project_id = uuid4() + record = SessionRecordEvent( + project_id=project_id, + session_id="session-1", + record_type="message", + attributes={"type": "text", "text": "hello"}, + ) + with ( + patch("oss.src.core.sessions.records.streaming._get_redis", return_value=redis), + patch.object(env.sessions, "shared_reader", False), + ): + assert await publish_record(project_id=project_id, record_event=record) + + xadd = redis.xadd.await_args.kwargs + assert xadd["name"] == RECORD_STREAM_NAME + assert xadd["maxlen"] == MAXLEN_STREAMS_RECORDS + assert xadd["approximate"] is True + + +async def test_records_worker_deletes_malformed_entries_after_ack(): + redis = AsyncMock() + worker = RecordsWorker( + service=AsyncMock(), + redis_client=redis, + stream_name=RECORD_STREAM_NAME, + consumer_group="worker-records", + ) + + appended, processed = await worker.process_batch( + [(b"1-0", {b"data": b"not-a-compressed-record"})] + ) + await worker.ack_and_delete(processed) + + assert appended == 0 + assert processed == [b"1-0"] + redis.xack.assert_awaited_once_with(RECORD_STREAM_NAME, "worker-records", b"1-0") + redis.xdel.assert_awaited_once_with(RECORD_STREAM_NAME, b"1-0") + + +async def test_live_frame_count_bound_does_not_touch_durable_records(): + fakeredis = pytest.importorskip("fakeredis") + redis = fakeredis.FakeAsyncRedis() + durable_id = await redis.xadd(RECORD_STREAM_NAME, {"data": b"durable"}) + + with ( + patch("oss.src.core.sessions.records.streaming._get_redis", return_value=redis), + patch.object(env.sessions, "live_stream_maxlen", 4), + ): + for index in range(5): + frame = SessionLiveFrame.model_validate( + { + **_frame().model_dump(), + "frame_or_event_id": f"execution-1:{index}", + "frame_index": index, + } + ) + assert await publish_live_frame(project_id=uuid4(), frame=frame) + + assert await redis.xlen(LIVE_FRAME_STREAM_NAME) == 4 + assert await redis.xrange(RECORD_STREAM_NAME, min=durable_id, max=durable_id) + + +async def test_age_trim_removes_expired_frames_from_live_stream(): + fakeredis = pytest.importorskip("fakeredis") + redis = fakeredis.FakeAsyncRedis() + expired_id = f"{int(datetime.now(timezone.utc).timestamp() * 1000) - 901_000}-0" + await redis.xadd(LIVE_FRAME_STREAM_NAME, {"data": b"expired-frame"}, id=expired_id) + + with patch.object(env.sessions, "live_frame_max_age_seconds", 900): + await trim_live_stream(redis) + + assert ( + await redis.xrange(LIVE_FRAME_STREAM_NAME, min=expired_id, max=expired_id) == [] + ) diff --git a/api/oss/tests/pytest/unit/sessions/test_live_relay.py b/api/oss/tests/pytest/unit/sessions/test_live_relay.py new file mode 100644 index 00000000000..d5b2387aef2 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_live_relay.py @@ -0,0 +1,411 @@ +import asyncio +import json +import zlib +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, patch +from uuid import uuid4 + +import pytest +from fastapi import FastAPI, HTTPException, Request +from orjson import dumps + +from oss.src.apis.fastapi.sessions.live_events import live_event_stream +from oss.src.apis.fastapi.sessions.router import SessionStreamsRouter +from oss.src.core.sessions.records.dtos import ( + MessageCompletedEvent, + SessionDurableEventsReplay, +) +from oss.src.core.sessions.records.streaming import LIVE_FRAME_STREAM_NAME +from oss.src.tasks.asyncio.sessions.live_relay_worker import LiveRelayWorker +from oss.src.utils.env import env + + +class FakePubSub: + def __init__(self, messages): + self.messages = list(messages) + self.subscribed = AsyncMock() + self.unsubscribed = AsyncMock() + self.closed = AsyncMock() + + async def subscribe(self, channel): + await self.subscribed(channel) + + async def get_message(self, **_kwargs): + await asyncio.sleep(0) + if self.messages: + return self.messages.pop(0) + await asyncio.sleep(0.01) + return None + + async def unsubscribe(self, channel): + await self.unsubscribed(channel) + + async def aclose(self): + await self.closed() + + +def _frame(index: int = 0): + return { + "version": 1, + "kind": "frame", + "session_id": "session-1", + "execution_id": "execution-1", + "frame_or_event_id": f"execution-1:{index}", + "frame_index": index, + "entity_id": "message-1", + "type": "text-delta", + "payload": {"id": "message-1", "delta": "hello"}, + "created_at": datetime.now(timezone.utc).isoformat(), + } + + +def _event(sequence: int = 1): + return { + "version": 1, + "kind": "event", + "session_id": "session-1", + "execution_id": "execution-1", + "frame_or_event_id": f"event-{sequence}", + "entity_id": "message-1", + "sequence": sequence, + "watermark": sequence, + "type": "message.completed", + "payload": { + "message_id": "message-1", + "role": "assistant", + "content": "hello", + }, + "created_at": datetime.now(timezone.utc).isoformat(), + } + + +async def test_rechecks_authorization_and_closes_revoked_reader(): + pubsub = FakePubSub([]) + checks = 0 + + async def authorize(): + nonlocal checks + checks += 1 + return False + + stream = live_event_stream( + channel="events:project-1:session:session-1", + pubsub_factory=lambda: pubsub, + authorization_check=authorize, + authorization_recheck_seconds=0.001, + heartbeat_seconds=60, + retry_milliseconds=5000, + buffer_limit=4, + ) + + assert (await anext(stream)).startswith("retry: 5000") + assert (await anext(stream)).startswith("event: ready") + terminal = await anext(stream) + assert terminal.startswith("event: relay-close") + assert ( + json.loads(terminal.split("data: ", 1)[1])["reason"] == "authorization_revoked" + ) + assert checks == 1 + + +async def test_slow_reader_gets_terminal_close_frame(): + messages = [{"type": "message", "data": dumps(_frame(index))} for index in range(3)] + pubsub = FakePubSub(messages) + stream = live_event_stream( + channel="events:project-1:session:session-1", + pubsub_factory=lambda: pubsub, + authorization_check=AsyncMock(return_value=True), + authorization_recheck_seconds=60, + heartbeat_seconds=60, + retry_milliseconds=5000, + buffer_limit=1, + ) + + assert (await anext(stream)).startswith("retry:") + assert (await anext(stream)).startswith("event: ready") + await asyncio.sleep(0.02) + terminal = await anext(stream) + assert terminal.startswith("event: relay-close") + assert json.loads(terminal.split("data: ", 1)[1])["reason"] == "slow_reader" + + +async def test_live_stream_forwards_durable_event_envelopes(): + pubsub = FakePubSub([{"type": "message", "data": dumps(_event())}]) + stream = live_event_stream( + channel="events:project-1:session:session-1", + pubsub_factory=lambda: pubsub, + authorization_check=AsyncMock(return_value=True), + authorization_recheck_seconds=60, + heartbeat_seconds=60, + retry_milliseconds=5000, + buffer_limit=4, + ) + + assert (await anext(stream)).startswith("retry:") + assert (await anext(stream)).startswith("event: ready") + event = json.loads((await anext(stream)).split("data: ", 1)[1]) + assert event["kind"] == "event" + assert event["sequence"] == 1 + assert event["watermark"] == 1 + await stream.aclose() + + +async def test_live_stream_subscribes_before_replay_and_dedupes_notification(): + subscribed = False + event = MessageCompletedEvent.model_validate(_event(sequence=3)) + + class OrderingPubSub(FakePubSub): + async def subscribe(self, channel): + nonlocal subscribed + subscribed = True + await super().subscribe(channel) + + pubsub = OrderingPubSub([{"type": "message", "data": dumps(_event(sequence=3))}]) + replay_calls = [] + + async def replay(after): + assert subscribed is True + replay_calls.append(after) + return SessionDurableEventsReplay( + events=[event] if after < 3 else [], + watermark=3, + ) + + stream = live_event_stream( + channel="events:project-1:session:session-1", + pubsub_factory=lambda: pubsub, + authorization_check=AsyncMock(return_value=True), + authorization_recheck_seconds=60, + heartbeat_seconds=60, + retry_milliseconds=5000, + buffer_limit=4, + after=2, + replay_query=replay, + ) + + assert (await anext(stream)).startswith("retry:") + replayed = json.loads((await anext(stream)).split("data: ", 1)[1]) + assert replayed["sequence"] == 3 + assert replayed["watermark"] == 3 + assert await anext(stream) == 'event: ready\ndata: {"watermark": 3}\n\n' + await asyncio.sleep(0.01) + assert replay_calls == [2, 3] + await stream.aclose() + + +async def test_replay_ready_reports_watermark_without_typed_events(): + pubsub = FakePubSub([]) + + async def replay(_after): + return SessionDurableEventsReplay(events=[], watermark=5) + + stream = live_event_stream( + channel="events:project-1:session:session-1", + pubsub_factory=lambda: pubsub, + authorization_check=AsyncMock(return_value=True), + authorization_recheck_seconds=60, + heartbeat_seconds=60, + retry_milliseconds=5000, + buffer_limit=4, + after=2, + replay_query=replay, + ) + + assert (await anext(stream)).startswith("retry:") + assert await anext(stream) == 'event: ready\ndata: {"watermark": 5}\n\n' + await stream.aclose() + + +async def test_replay_larger_than_buffer_backpressures_without_closing_reader(): + events = [ + MessageCompletedEvent.model_validate(_event(sequence)) + for sequence in range(1, 6) + ] + pubsub = FakePubSub([]) + + async def replay(_after): + return SessionDurableEventsReplay(events=events, watermark=5) + + stream = live_event_stream( + channel="events:project-1:session:session-1", + pubsub_factory=lambda: pubsub, + authorization_check=AsyncMock(return_value=True), + authorization_recheck_seconds=60, + heartbeat_seconds=60, + retry_milliseconds=5000, + buffer_limit=2, + replay_query=replay, + ) + + assert (await anext(stream)).startswith("retry:") + replayed = [json.loads((await anext(stream)).split("data: ", 1)[1]) for _ in events] + assert [event["sequence"] for event in replayed] == [1, 2, 3, 4, 5] + assert await anext(stream) == 'event: ready\ndata: {"watermark": 5}\n\n' + await stream.aclose() + + +async def test_relay_worker_publishes_and_deletes_frames(): + project_id = uuid4() + redis = AsyncMock() + worker = LiveRelayWorker( + redis_client=redis, + stream_name=LIVE_FRAME_STREAM_NAME, + consumer_group="worker-session-live-relay", + ) + frame_message = { + "organization_id": None, + "project_id": str(project_id), + "kind": "frame", + "frame": _frame(), + } + batch = [ + (b"1-0", {b"data": zlib.compress(dumps(frame_message))}), + ] + + published, processed = await worker.process_batch(batch) + await worker.ack_and_delete(processed) + + assert published == 1 + assert processed == [b"1-0"] + redis.publish.assert_awaited_once() + redis.xack.assert_awaited_once_with( + LIVE_FRAME_STREAM_NAME, "worker-session-live-relay", b"1-0" + ) + redis.xdel.assert_awaited_once_with(LIVE_FRAME_STREAM_NAME, b"1-0") + + +async def test_relay_worker_discards_frames_older_than_900_seconds(): + project_id = uuid4() + redis = AsyncMock() + worker = LiveRelayWorker( + redis_client=redis, + stream_name=LIVE_FRAME_STREAM_NAME, + consumer_group="worker-session-live-relay", + ) + expired = _frame(0) + expired["created_at"] = ( + datetime.now(timezone.utc) - timedelta(seconds=901) + ).isoformat() + fresh = _frame(1) + batch = [ + ( + b"1-0", + { + b"data": zlib.compress( + dumps( + { + "organization_id": None, + "project_id": str(project_id), + "kind": "frame", + "frame": expired, + } + ) + ) + }, + ), + ( + b"2-0", + { + b"data": zlib.compress( + dumps( + { + "organization_id": None, + "project_id": str(project_id), + "kind": "frame", + "frame": fresh, + } + ) + ) + }, + ), + ] + + with patch.object(env.sessions, "live_frame_max_age_seconds", 900): + published, processed = await worker.process_batch(batch) + + assert published == 1 + assert processed == [b"1-0", b"2-0"] + redis.publish.assert_awaited_once() + + +async def test_relay_worker_publishes_durable_events(): + project_id = uuid4() + redis = AsyncMock() + worker = LiveRelayWorker( + redis_client=redis, + stream_name=LIVE_FRAME_STREAM_NAME, + consumer_group="worker-session-live-relay", + ) + event_message = { + "organization_id": None, + "project_id": str(project_id), + "kind": "event", + "event": _event(), + } + + published, processed = await worker.process_batch( + [(b"1-0", {b"data": zlib.compress(dumps(event_message))})] + ) + + assert published == 1 + assert processed == [b"1-0"] + relayed = json.loads(redis.publish.await_args.args[1]) + assert relayed["kind"] == "event" + assert relayed["sequence"] == 1 + assert relayed["watermark"] == 1 + + +async def test_events_route_is_hidden_when_shared_reader_is_off(): + router = SessionStreamsRouter( + service=AsyncMock(), + interactions_service=AsyncMock(), + ) + request = Request( + { + "type": "http", + "method": "GET", + "path": "/sessions/session-1/events", + "headers": [], + "app": FastAPI(), + } + ) + request.state.project_id = str(uuid4()) + request.state.user_id = str(uuid4()) + + with patch.object(env.sessions, "shared_reader", False): + with pytest.raises(HTTPException) as exc_info: + await router.session_events(request=request, session_id="session-1") + + assert exc_info.value.status_code == 404 + + +async def test_events_route_disables_authenticated_response_storage(): + router = SessionStreamsRouter( + service=AsyncMock(), + interactions_service=AsyncMock(), + records_service=AsyncMock(), + ) + request = Request( + { + "type": "http", + "method": "GET", + "path": "/sessions/session-1/events", + "headers": [], + "app": FastAPI(), + } + ) + request.state.project_id = str(uuid4()) + request.state.user_id = str(uuid4()) + + with ( + patch.object(env.sessions, "shared_reader", True), + patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ), + ): + response = await router.session_events(request=request, session_id="session-1") + + assert response.headers["cache-control"] == "no-store" + await response.body_iterator.aclose() diff --git a/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_clears_redis.py b/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_clears_redis.py index db97b1eb842..7e7d62db863 100644 --- a/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_clears_redis.py +++ b/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_clears_redis.py @@ -9,6 +9,8 @@ """ from contextlib import asynccontextmanager +from typing import Optional + from datetime import datetime, timezone, timedelta import pytest @@ -22,10 +24,17 @@ class _FakeRow: - def __init__(self, *, session_id: str, updated_at: datetime): + def __init__( + self, + *, + session_id: str, + updated_at: datetime, + turn_id: Optional[str] = None, + ): self.session_id = session_id self.project_id = _PROJECT_ID self.id = "stream-1" + self.turn_id = turn_id self.deleted_at = None self.flags = {"is_alive": True, "is_running": True, "is_attached": False} self.updated_at = updated_at @@ -40,8 +49,9 @@ def all(self): class _FakeResult: - def __init__(self, rows): + def __init__(self, rows, *, rowcount=0): self._rows = rows + self.rowcount = rowcount def scalars(self): return _FakeScalars(self._rows) @@ -54,6 +64,30 @@ def __init__(self, rows, seen): async def execute(self, stmt): self._seen.append(stmt) + text = str(stmt) + if text.startswith("UPDATE") and "session_streams" in text: + # Both session_streams writes are Core UPDATEs keyed by row id, never ORM + # attribute writes (finding 7). The collapse binds `id IN (...)`, a list; the + # lost-turn clear binds `id = ...`, a scalar. Apply either to the in-memory rows. + params = stmt.compile().params + flags_val = next( + (v for v in params.values() if isinstance(v, dict) and "is_alive" in v), + None, + ) + ids = set() + for value in params.values(): + if isinstance(value, (list, set, tuple)): + ids.update(x for x in value if isinstance(x, str)) + elif isinstance(value, str): + ids.add(value) + if flags_val is not None: + matched = 0 + for row in self._rows: + if row.id in ids: + row.flags = dict(flags_val) + matched += 1 + return _FakeResult([], rowcount=matched) + return _FakeResult([]) return _FakeResult(self._rows) async def commit(self): @@ -94,6 +128,36 @@ async def delete(self, key): async def expire(self, key, ttl): return True + async def eval(self, script, numkeys, *keys_and_args): + def decode(value): + return value.decode() if isinstance(value, bytes) else str(value) + + keys = [decode(value) for value in keys_and_args[:numkeys]] + argv = [decode(value) for value in keys_and_args[numkeys:]] + assert "AGENTA_WATCHDOG_RELEASE_TURN" in script + alive, running, owner, superseded = keys + expected_turn, expected_owner, _ttl = argv + alive_value = decode(self._store[alive]) if alive in self._store else "" + running_value = decode(self._store[running]) if running in self._store else "" + owner_value = decode(self._store[owner]) if owner in self._store else "" + released_alive = int(bool(expected_turn) and alive_value == expected_turn) + released_running = int(bool(expected_turn) and running_value == expected_turn) + if released_alive: + self._store.pop(alive, None) + if released_running: + self._store.pop(running, None) + foreign_turn = (alive_value and alive_value != expected_turn) or ( + running_value and running_value != expected_turn + ) + released_owner = int( + bool(expected_owner) and owner_value == expected_owner and not foreign_turn + ) + if released_owner: + self._store.pop(owner, None) + if expected_turn: + self._store[superseded] = b"1" + return [released_alive, released_running, released_owner] + @pytest.fixture def anyio_backend(): @@ -114,10 +178,14 @@ async def test_orphan_sweep_clears_alive_lock_and_unblocks_send(anyio_backend): await lock_engine.set( f"running:{_PROJECT_ID}:session:{_SESSION_ID}", b"turn-1", ex=3600 ) + await lock_engine.set( + f"owner:{_PROJECT_ID}:session:{_SESSION_ID}", b"replica-legacy", ex=120 + ) stale_row = _FakeRow( session_id=_SESSION_ID, updated_at=datetime.now(timezone.utc) - timedelta(seconds=600), + turn_id=None, ) pg_engine = _FakeTransactionsEngine([stale_row]) @@ -141,6 +209,7 @@ async def test_orphan_sweep_clears_alive_lock_and_unblocks_send(anyio_backend): lock_engine, project_id=_PROJECT_ID, session_id=_SESSION_ID ) assert liveness_after == {"alive": False, "running": False, "attached": False} + assert await lock_engine.get(f"owner:{_PROJECT_ID}:session:{_SESSION_ID}") is None # SEND gate logic (service.py:99-101): would raise if alive were still true. def _send_gate(liveness): @@ -168,6 +237,7 @@ async def test_orphan_sweep_tombstones_the_turn_it_swept(anyio_backend): stale_row = _FakeRow( session_id=_SESSION_ID, updated_at=datetime.now(timezone.utc) - timedelta(seconds=600), + turn_id=None, ) await run_orphan_sweep(_FakeTransactionsEngine([stale_row]), lock_engine) diff --git a/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py b/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py index 20f197cadfe..3d9d8558b20 100644 --- a/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py +++ b/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py @@ -30,6 +30,7 @@ UnaryExpression, ) from sqlalchemy.sql.functions import Function +from sqlalchemy.sql.dml import Update from oss.src.tasks.asyncio.sessions.orphan_sweep import ( IDLE_THRESHOLD_SECONDS, @@ -80,8 +81,16 @@ def _evaluate(node, row) -> Optional[bool]: left, right = _value(node.left, row), _value(node.right, row) if node.operator is operators.is_: return left is right + if node.operator is operators.is_not: + # `turn_id IS NOT NULL`, from the ending-only selection. Postgres `IS NOT` is a + # total predicate: it never returns NULL, so neither does this. + return left is not right if node.operator is operators.lt: return None if left is None or right is None else left < right + if node.operator is operators.eq: + return None if left is None or right is None else left == right + if node.operator is operators.in_op: + return None if left is None else left in right if getattr(node.operator, "opstring", None) == "@>": return _contains(left, right) raise AssertionError( @@ -113,10 +122,18 @@ def _value(node, row): class _FakeRow: - def __init__(self, *, session_id: str, flags: Optional[dict], age_seconds: int): + def __init__( + self, + *, + session_id: str, + flags: Optional[dict], + age_seconds: int, + turn_id: Optional[str] = None, + ): self.session_id = session_id self.project_id = _PROJECT_ID self.id = session_id + self.turn_id = turn_id self.deleted_at = None self.flags = flags self.created_at = datetime.now(timezone.utc) - timedelta(days=1) @@ -132,18 +149,32 @@ def all(self): class _FakeResult: - def __init__(self, rows): + def __init__(self, rows, *, rowcount=0): self._rows = rows + self.rowcount = rowcount def scalars(self): return _FakeScalars(self._rows) class _FakePgSession: - def __init__(self, rows): + def __init__(self, rows, before_update=None): self._rows = rows + self._before_update = before_update async def execute(self, stmt): + if isinstance(stmt, Update): + if self._before_update is not None: + self._before_update() + self._before_update = None + matched = [ + row for row in self._rows if _evaluate(stmt.whereclause, row) is True + ] + for row in matched: + for column, value in stmt._values.items(): + key = column if isinstance(column, str) else column.key + setattr(row, key, _value(value, row)) + return _FakeResult([], rowcount=len(matched)) matched = [ row for row in self._rows if _evaluate(stmt.whereclause, row) is True ] @@ -154,12 +185,13 @@ async def commit(self): class _FakeTransactionsEngine: - def __init__(self, rows): + def __init__(self, rows, before_update=None): self._rows = rows + self._before_update = before_update @asynccontextmanager async def session(self): - yield _FakePgSession(self._rows) + yield _FakePgSession(self._rows, self._before_update) class _FakeRedis: @@ -182,6 +214,36 @@ async def delete(self, key): async def expire(self, key, ttl): return True + async def eval(self, script, numkeys, *keys_and_args): + def decode(value): + return value.decode() if isinstance(value, bytes) else str(value) + + keys = [decode(value) for value in keys_and_args[:numkeys]] + argv = [decode(value) for value in keys_and_args[numkeys:]] + assert "AGENTA_WATCHDOG_RELEASE_TURN" in script + alive, running, owner, superseded = keys + expected_turn, expected_owner, _ttl = argv + alive_value = decode(self._store[alive]) if alive in self._store else "" + running_value = decode(self._store[running]) if running in self._store else "" + owner_value = decode(self._store[owner]) if owner in self._store else "" + released_alive = int(bool(expected_turn) and alive_value == expected_turn) + released_running = int(bool(expected_turn) and running_value == expected_turn) + if released_alive: + self._store.pop(alive, None) + if released_running: + self._store.pop(running, None) + foreign_turn = (alive_value and alive_value != expected_turn) or ( + running_value and running_value != expected_turn + ) + released_owner = int( + bool(expected_owner) and owner_value == expected_owner and not foreign_turn + ) + if released_owner: + self._store.pop(owner, None) + if expected_turn: + self._store[superseded] = b"1" + return [released_alive, released_running, released_owner] + def _swept(row: _FakeRow) -> bool: return row.flags == {"is_alive": False, "is_running": False, "is_attached": False} @@ -192,6 +254,32 @@ def anyio_backend(): return "asyncio" +class _OrderedCommandsService: + def __init__(self) -> None: + self.calls = [] + + async def settle_abandoned_commands(self, *, now): + self.calls.append("settle") + return 0 + + async def repair_terminal_redis(self): + self.calls.append("repair") + return 0 + + +@pytest.mark.anyio +async def test_redis_repair_runs_after_the_sweeps_main_work(anyio_backend): + commands = _OrderedCommandsService() + + await run_orphan_sweep( + _FakeTransactionsEngine([]), + _FakeRedis(), + commands_service=commands, + ) + + assert commands.calls == ["settle", "repair"] + + @pytest.mark.anyio async def test_running_row_is_swept_at_the_short_threshold(anyio_backend): row = _FakeRow( @@ -241,8 +329,13 @@ async def test_idle_row_is_swept_at_the_long_threshold(anyio_backend): @pytest.mark.anyio -async def test_thresholds_are_five_and_thirty_minutes(anyio_backend): - assert (ORPHAN_THRESHOLD_SECONDS, IDLE_THRESHOLD_SECONDS) == (300, 1800) +async def test_default_running_threshold_uses_durable_stop(anyio_backend): + """Three missed 30-second heartbeats settle a running turn by default. + + Idle sessions retain the 30-minute approval TTL. Explicit flag-off behavior + is covered by the session cancellation configuration tests. + """ + assert (ORPHAN_THRESHOLD_SECONDS, IDLE_THRESHOLD_SECONDS) == (90, 1800) @pytest.mark.anyio @@ -295,9 +388,61 @@ async def test_sweep_clears_redis_for_the_long_threshold_branch(anyio_backend): session_id=session_id, flags={"is_alive": True, "is_running": False, "is_attached": False}, age_seconds=IDLE_THRESHOLD_SECONDS + 60, + turn_id="turn-1", ) await run_orphan_sweep(_FakeTransactionsEngine([row]), redis) assert await redis.get(f"alive:{_PROJECT_ID}:session:{session_id}") is None assert await redis.get(f"owner:{_PROJECT_ID}:session:{session_id}") is None + + +@pytest.mark.anyio +async def test_turn_advance_during_sweep_prevents_collapse_and_redis_cleanup( + anyio_backend, +): + session_id = "sess-advanced-during-sweep" + row = _FakeRow( + session_id=session_id, + flags={"is_alive": True, "is_running": True, "is_attached": False}, + age_seconds=360, + turn_id="turn-old", + ) + redis = _FakeRedis() + await redis.set(f"alive:{_PROJECT_ID}:session:{session_id}", b"turn-new") + await redis.set(f"running:{_PROJECT_ID}:session:{session_id}", b"turn-new") + await redis.set(f"owner:{_PROJECT_ID}:session:{session_id}", b"runner-new") + + def advance_row(): + row.turn_id = "turn-new" + row.updated_at = datetime.now(timezone.utc) + + await run_orphan_sweep( + _FakeTransactionsEngine([row], before_update=advance_row), redis + ) + + assert row.flags["is_alive"] is True + assert row.flags["is_running"] is True + assert await redis.get(f"alive:{_PROJECT_ID}:session:{session_id}") == b"turn-new" + assert await redis.get(f"running:{_PROJECT_ID}:session:{session_id}") == b"turn-new" + assert await redis.get(f"owner:{_PROJECT_ID}:session:{session_id}") == b"runner-new" + + +@pytest.mark.anyio +async def test_heartbeat_during_sweep_prevents_collapse(anyio_backend): + row = _FakeRow( + session_id="sess-heartbeat-during-sweep", + flags={"is_alive": True, "is_running": True, "is_attached": False}, + age_seconds=360, + turn_id="turn-current", + ) + + def heartbeat(): + row.updated_at = datetime.now(timezone.utc) + + await run_orphan_sweep( + _FakeTransactionsEngine([row], before_update=heartbeat), _FakeRedis() + ) + + assert row.flags["is_alive"] is True + assert row.flags["is_running"] is True diff --git a/api/oss/tests/pytest/unit/sessions/test_owner_claim.py b/api/oss/tests/pytest/unit/sessions/test_owner_claim.py index ba9e0b5892b..7c3538646b8 100644 --- a/api/oss/tests/pytest/unit/sessions/test_owner_claim.py +++ b/api/oss/tests/pytest/unit/sessions/test_owner_claim.py @@ -78,12 +78,16 @@ async def eval(self, script, numkeys, *keys_and_args): ) if script == CLAIM_OWNER_LUA: - replica_id, ex = argv + owner_value, ex = argv current = self._values.get(key) - replica_id_bytes = self._val(replica_id) - if current is None or current == replica_id_bytes: - await self.set(key, replica_id_bytes, ex=int(ex)) - return replica_id_bytes + owner_value_bytes = self._val(owner_value) + from oss.src.dbs.redis.sessions.contract import owner_replica_id + + if current is None or owner_replica_id( + current.decode() + ) == owner_replica_id(owner_value_bytes.decode()): + await self.set(key, owner_value_bytes, ex=int(ex)) + return owner_value_bytes return current if script == RELEASE_IF_OWNER_LUA: (owner,) = argv @@ -165,6 +169,37 @@ async def test_claim_owner_same_replica_refreshes_without_stealing(fake_redis): assert ttl <= OWNER_TTL_SECONDS +@pytest.mark.asyncio +async def test_claim_owner_same_replica_refreshes_to_the_new_turn_generation( + fake_redis, +): + from oss.src.dbs.redis.sessions.contract import make_owner_value, owner_key + from oss.src.dbs.redis.sessions.locks import claim_owner + + engine, client = fake_redis + session_id = _session_id() + + await claim_owner( + engine, + project_id=_PROJECT_ID, + session_id=session_id, + replica_id="replica-a", + turn_id="turn-a", + ) + await claim_owner( + engine, + project_id=_PROJECT_ID, + session_id=session_id, + replica_id="replica-a", + turn_id="turn-b", + ) + + assert ( + await client.get(owner_key(_PROJECT_ID, session_id)) + == make_owner_value(replica_id="replica-a", turn_id="turn-b").encode() + ) + + @pytest.mark.asyncio async def test_claim_owner_different_replica_does_not_steal(fake_redis): """The core S7 guarantee: a second replica's claim on an owned session never steals it.""" diff --git a/api/oss/tests/pytest/unit/sessions/test_project_scoped_locks.py b/api/oss/tests/pytest/unit/sessions/test_project_scoped_locks.py index e355d8d836b..90bfe149a85 100644 --- a/api/oss/tests/pytest/unit/sessions/test_project_scoped_locks.py +++ b/api/oss/tests/pytest/unit/sessions/test_project_scoped_locks.py @@ -24,12 +24,16 @@ ) from oss.src.dbs.redis.sessions.locks import ( acquire_alive, + acquire_running, claim_owner, force_cancel_alive, force_clear_owner, get_alive_owner, get_owner, + get_running_owner, get_session_liveness, + is_turn_superseded, + reconcile_stopped_turn, ) @@ -44,6 +48,7 @@ class _FakeRedis: def __init__(self): self._values: dict[str, bytes] = {} self._ttl: dict[str, int] = {} + self.now_ms = 1_000_000 @staticmethod def _norm(key) -> str: @@ -82,12 +87,74 @@ async def expire(self, key, ttl): async def ttl(self, key): return self._ttl.get(self._norm(key), -2) + async def time(self): + return divmod(self.now_ms * 1000, 1_000_000) + async def publish(self, channel, payload): return 0 async def eval(self, script, numkeys, *keys_and_args): - key = self._norm(keys_and_args[0]) + keys = [self._norm(key) for key in keys_and_args[:numkeys]] argv = [self._norm(a) for a in keys_and_args[numkeys:]] + if "AGENTA_ACQUIRE_ALIVE_WITH_START" in script: + if keys[0] in self._values: + return 0 + self._values[keys[0]] = argv[0].encode() + self._ttl[keys[0]] = int(argv[1]) + if keys[1] not in self._values: + self._values[keys[1]] = str(self.now_ms).encode() + self._ttl[keys[1]] = int(argv[2]) + return 1 + if "AGENTA_DISPLACE_TURNS" in script: + alive = self._values.get(keys[0], b"").decode() + running = self._values.get(keys[1], b"").decode() + expected = argv[0] + arrived_at_ms = int(argv[1]) if argv[1] else None + running_only = argv[5] == "1" + + def mismatches(owner: str) -> bool: + if not owner: + return False + if expected: + return owner != expected + started = self._values.get(f"{argv[3]}{owner}") + return bool( + arrived_at_ms is not None + and started is not None + and int(started.decode()) > arrived_at_ms + ) + + if not running_only and mismatches(alive): + return [0, alive.encode()] + if (running_only or running != alive) and mismatches(running): + return [0, running.encode()] + seen = set() + displaced = ( + (running, expected) if running_only else (alive, running, expected) + ) + for turn_id in displaced: + if turn_id and turn_id not in seen: + key = f"{argv[2]}{turn_id}" + self._values[key] = b"1" + self._ttl[key] = int(argv[4]) + seen.add(turn_id) + if not running_only or (alive and alive == running): + self._values.pop(keys[0], None) + self._ttl.pop(keys[0], None) + self._values.pop(keys[1], None) + self._ttl.pop(keys[1], None) + returned_alive = "" if running_only else alive + return [1, returned_alive.encode(), running.encode(), expected.encode()] + if "AGENTA_RECONCILE_STOPPED_TURN" in script: + self._values[keys[1]] = b"1" + self._ttl[keys[1]] = int(argv[1]) + if self._values.get(keys[0], b"").decode() == argv[0]: + self._values.pop(keys[0], None) + self._ttl.pop(keys[0], None) + return 1 + return 0 + + key = keys[0] current = self._values.get(key) current_s = current.decode() if current else None if "DEL" in script: # RELEASE_IF_OWNER_LUA @@ -96,7 +163,11 @@ async def eval(self, script, numkeys, *keys_and_args): return 1 return 0 # CLAIM_OWNER_LUA - if current_s is None or current_s == argv[0]: + from oss.src.dbs.redis.sessions.contract import owner_replica_id + + if current_s is None or owner_replica_id(current_s) == owner_replica_id( + argv[0] + ): self._values[key] = argv[0].encode() self._ttl[key] = int(argv[1]) return argv[0] @@ -193,6 +264,36 @@ async def test_tenant_cannot_clear_another_tenants_owner(engine): ) == "replica-b" +@pytest.mark.asyncio +async def test_durable_stop_reconciliation_preserves_alive_and_a_new_running_turn( + engine, +): + await acquire_alive( + engine, project_id=_TENANT_A, session_id=_SESSION, turn_id="turn-old" + ) + await acquire_running( + engine, project_id=_TENANT_A, session_id=_SESSION, turn_id="turn-new" + ) + + released = await reconcile_stopped_turn( + engine, + project_id=_TENANT_A, + session_id=_SESSION, + turn_id="turn-old", + ) + + assert released is False + assert ( + await get_alive_owner(engine, project_id=_TENANT_A, session_id=_SESSION) + ) == "turn-old" + assert ( + await get_running_owner(engine, project_id=_TENANT_A, session_id=_SESSION) + ) == "turn-new" + assert await is_turn_superseded( + engine, project_id=_TENANT_A, session_id=_SESSION, turn_id="turn-old" + ) + + # --------------------------------------------------------------------------- # # kill's owner drop (the 120s lockout) # --------------------------------------------------------------------------- # diff --git a/api/oss/tests/pytest/unit/sessions/test_records_config.py b/api/oss/tests/pytest/unit/sessions/test_records_config.py new file mode 100644 index 00000000000..a67cf8a3d6a --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_records_config.py @@ -0,0 +1,36 @@ +import pytest +from pydantic import ValidationError + +from oss.src.utils.env import SessionsRecordsConfig + + +def test_session_record_retry_bounds_accept_the_minimum_values(): + config = SessionsRecordsConfig(reclaim_idle_ms=0, max_deliveries=1) + + assert config.reclaim_idle_ms == 0 + assert config.max_deliveries == 1 + + +@pytest.mark.parametrize( + ("field", "value"), + [("reclaim_idle_ms", -1), ("max_deliveries", 0), ("max_deliveries", -1)], +) +def test_session_record_retry_bounds_reject_invalid_values(field, value): + with pytest.raises(ValidationError): + SessionsRecordsConfig(**{field: value}) + + +@pytest.mark.parametrize( + ("name", "value"), + [ + ("AGENTA_RECORDS_RECLAIM_IDLE_MS", "-1"), + ("AGENTA_RECORDS_MAX_DELIVERIES", "0"), + ], +) +def test_session_record_retry_bounds_validate_environment_defaults( + monkeypatch, name, value +): + monkeypatch.setenv(name, value) + + with pytest.raises(ValidationError): + SessionsRecordsConfig() diff --git a/api/oss/tests/pytest/unit/sessions/test_records_mapping_upsert.py b/api/oss/tests/pytest/unit/sessions/test_records_mapping_upsert.py index 775d146fce1..d6d8b59c6ec 100644 --- a/api/oss/tests/pytest/unit/sessions/test_records_mapping_upsert.py +++ b/api/oss/tests/pytest/unit/sessions/test_records_mapping_upsert.py @@ -56,6 +56,7 @@ def test_turn_id_and_span_id_default_to_none(): dbe = map_record_event_to_dbe(event=_event()) assert dbe.turn_id is None assert dbe.span_id is None + assert dbe.sequence is None class _FakeResult: diff --git a/api/oss/tests/pytest/unit/sessions/test_records_worker_batching.py b/api/oss/tests/pytest/unit/sessions/test_records_worker_batching.py index bb11086f810..ab0e6db4534 100644 --- a/api/oss/tests/pytest/unit/sessions/test_records_worker_batching.py +++ b/api/oss/tests/pytest/unit/sessions/test_records_worker_batching.py @@ -9,7 +9,8 @@ still pass with the old one-append-per-event code. """ -from unittest.mock import AsyncMock +from datetime import datetime, timezone +from unittest.mock import AsyncMock, patch from uuid import uuid4 import zlib @@ -117,3 +118,140 @@ async def test_process_batch_groups_by_project_one_append_many_per_project(): # never one per event (which would be 3). assert records_dao.append_many.await_count == 2 records_dao.append.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_failed_append_leaves_project_messages_unacknowledged(): + project_id = uuid4() + redis = AsyncMock() + records_dao = AsyncMock() + records_dao.append_many = AsyncMock(side_effect=RuntimeError("deadlock victim")) + worker = RecordsWorker( + service=RecordsService(records_dao=records_dao), + redis_client=redis, + stream_name="streams:records", + consumer_group="worker-records", + ) + batch = [ + ( + b"1-0", + {b"data": _payload(project_id=project_id, session_id="a", record_index=0)}, + ), + ( + b"2-0", + {b"data": _payload(project_id=project_id, session_id="b", record_index=0)}, + ), + ] + + total_appended, processed_ids = await worker.process_batch(batch) + await worker.ack_and_delete(processed_ids) + + assert total_appended == 0 + assert processed_ids == [] + redis.xack.assert_not_awaited() + redis.xdel.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_durable_event_is_published_only_after_record_commit_returns(): + project_id = uuid4() + committed = False + + class Service: + async def append_many(self, *, events): + nonlocal committed + committed = True + return [ + SessionRecord( + record_id=uuid4(), + session_id="sess-1", + project_id=project_id, + sequence=1, + turn_id="turn-1", + record_type="message", + record_source="agent", + attributes={"type": "message", "text": "done"}, + created_at=datetime.now(timezone.utc), + ) + ] + + async def publish(**kwargs): + assert committed is True + assert kwargs["event"].sequence == 1 + assert kwargs["event"].watermark == 1 + return True + + worker = RecordsWorker( + service=Service(), + redis_client=None, + stream_name="streams:records", + consumer_group="worker-records", + ) + + with patch( + "oss.src.tasks.asyncio.sessions.records_worker.publish_durable_event", + side_effect=publish, + ) as publisher: + await worker.process_batch( + [ + ( + b"1-0", + { + b"data": _payload( + project_id=project_id, session_id="sess-1", record_index=0 + ) + }, + ) + ] + ) + + publisher.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_quarantined_committed_record_is_not_published_as_a_durable_event(): + project_id = uuid4() + + class Service: + async def append_many(self, *, events): + return [ + SessionRecord( + record_id=uuid4(), + session_id="sess-1", + project_id=project_id, + sequence=1, + turn_id="turn-1", + record_type="message", + record_source="agent", + attributes={"type": "message", "text": "refused tail"}, + quarantined_at=datetime.now(timezone.utc), + created_at=datetime.now(timezone.utc), + ) + ] + + worker = RecordsWorker( + service=Service(), + redis_client=None, + stream_name="streams:records", + consumer_group="worker-records", + ) + + with patch( + "oss.src.tasks.asyncio.sessions.records_worker.publish_durable_event" + ) as publisher: + total_appended, processed_ids = await worker.process_batch( + [ + ( + b"1-0", + { + b"data": _payload( + project_id=project_id, session_id="sess-1", record_index=0 + ) + }, + ) + ] + ) + + assert total_appended == 1 + assert processed_ids == [b"1-0"] + publisher.assert_not_awaited() diff --git a/api/oss/tests/pytest/unit/sessions/test_records_worker_durability.py b/api/oss/tests/pytest/unit/sessions/test_records_worker_durability.py new file mode 100644 index 00000000000..d287d822d53 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_records_worker_durability.py @@ -0,0 +1,517 @@ +"""Records must not be acknowledged before Postgres has them (#5496, #5594). + +`RecordsWorker.process_batch` used to add every decoded Redis message id to its +acknowledged list DURING deserialization, before `append_many` ran. A failed write logged +and continued, and the shared consumer loop then acknowledged and deleted those messages +from the stream. Every Postgres hiccup was therefore permanent, silent record loss, and one +record Postgres rejected took its whole batch with it. + +These tests pin three properties: + +* a message id is acknowledged only after its rows commit, and the redelivered batch is + written exactly once; +* one bad record does not discard the rest of its batch; +* a message that never writes is dropped loudly and counted, instead of holding the + pending list forever. + +The redelivery tests run against fakeredis so the pending-list bookkeeping is real Redis +consumer-group behaviour, not a mock of it. +""" + +import asyncio +import zlib +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import fakeredis.aioredis as fakeredis +import pytest +from orjson import dumps +from sqlalchemy.exc import IntegrityError + +from oss.src.core.sessions.records.dtos import SessionRecord +from oss.src.core.sessions.records.service import RecordsService +from oss.src.tasks.asyncio.sessions import records_worker +from oss.src.tasks.asyncio.sessions.records_worker import RecordsWorker + +STREAM = "streams:records" +GROUP = "worker-records" + + +def _payload(*, project_id, session_id, record_id, record_type="message", turn_id=None): + message = { + "organization_id": None, + "project_id": str(project_id), + "record_event": { + "project_id": str(project_id), + "session_id": session_id, + "record_id": str(record_id), + "record_type": record_type, + "turn_id": turn_id, + }, + } + return zlib.compress(dumps(message)) + + +class FakeRecordsDAO: + """Records what committed, and fails the events the caller names.""" + + def __init__(self, *, poison_ids=(), fail_calls=0, transient_error=None): + self.poison_ids = {str(record_id) for record_id in poison_ids} + self.fail_calls = fail_calls + self.transient_error = transient_error or ConnectionError("postgres is down") + self.calls = 0 + self.committed: list[str] = [] + + async def append_many(self, *, events): + self.calls += 1 + if self.calls <= self.fail_calls: + raise self.transient_error + if any(str(event.record_id) in self.poison_ids for event in events): + # `append_many` is one statement in one transaction: a rejected row takes the + # whole call with it, and nothing in the call commits. + raise IntegrityError("INSERT records", {}, ValueError("record rejected")) + for event in events: + self.committed.append(str(event.record_id)) + return [ + SessionRecord( + record_id=event.record_id, + session_id=event.session_id, + project_id=event.project_id, + ) + for event in events + ] + + +def _worker(dao, *, redis_client=None, max_deliveries=5): + return RecordsWorker( + service=RecordsService(records_dao=dao), + redis_client=redis_client, + stream_name=STREAM, + consumer_group=GROUP, + consumer_name="test-consumer", + reclaim_min_idle_ms=0, + max_deliveries=max_deliveries, + ) + + +def _batch(*, project_id, record_ids): + return [ + ( + f"{index}-0".encode(), + { + b"data": _payload( + project_id=project_id, session_id="sess-1", record_id=record_id + ) + }, + ) + for index, record_id in enumerate(record_ids) + ] + + +@pytest.mark.asyncio +async def test_failed_batch_acknowledges_nothing(): + project_id = uuid4() + record_ids = [uuid4(), uuid4()] + dao = FakeRecordsDAO(fail_calls=99) + + appended, acked_ids = await _worker(dao).process_batch( + _batch(project_id=project_id, record_ids=record_ids) + ) + + assert appended == 0 + # Nothing committed, so nothing may be acknowledged: the shared consumer loop deletes + # every id this list carries. + assert acked_ids == [] + assert dao.committed == [] + assert dao.calls == 1 + + +@pytest.mark.asyncio +async def test_timeout_leaves_the_whole_batch_pending_without_single_row_retries(): + project_id = uuid4() + dao = FakeRecordsDAO(fail_calls=99, transient_error=asyncio.TimeoutError()) + + appended, acked_ids = await _worker(dao).process_batch( + _batch(project_id=project_id, record_ids=[uuid4(), uuid4(), uuid4()]) + ) + + assert appended == 0 + assert acked_ids == [] + assert dao.calls == 1 + + +@pytest.mark.asyncio +async def test_redelivered_batch_is_acknowledged_once_and_written_once(): + project_id = uuid4() + record_ids = [uuid4(), uuid4()] + batch = _batch(project_id=project_id, record_ids=record_ids) + # The whole batch stays pending on the connection failure, then Postgres is back. + dao = FakeRecordsDAO(fail_calls=1) + worker = _worker(dao) + + _, first_acked = await worker.process_batch(batch) + assert first_acked == [] + + appended, second_acked = await worker.process_batch(batch) + + assert appended == 2 + assert second_acked == [msg_id for msg_id, _ in batch] + assert dao.committed == [str(record_id) for record_id in record_ids] + assert worker.dropped_messages == 0 + + +@pytest.mark.asyncio +async def test_one_bad_record_does_not_discard_its_batch(): + project_id = uuid4() + good_a, poison, good_b = uuid4(), uuid4(), uuid4() + batch = _batch(project_id=project_id, record_ids=[good_a, poison, good_b]) + dao = FakeRecordsDAO(poison_ids=[poison]) + + appended, acked_ids = await _worker(dao).process_batch(batch) + + assert appended == 2 + assert dao.committed == [str(good_a), str(good_b)] + # Only the two good ids are acknowledged. The rejected record stays pending. + assert acked_ids == [batch[0][0], batch[2][0]] + + +@pytest.mark.asyncio +async def test_undecodable_message_is_acknowledged_and_counted(): + dao = FakeRecordsDAO() + worker = _worker(dao) + + appended, acked_ids = await worker.process_batch([(b"1-0", {b"data": b"not-zlib"})]) + + assert appended == 0 + # A message that does not decode will not decode on redelivery, so it is dropped on + # purpose rather than left to hold the pending list. + assert acked_ids == [b"1-0"] + assert worker.dropped_messages == 1 + + +@pytest.mark.asyncio +async def test_watch_and_gate_reconciliation_see_only_committed_records(): + project_id = uuid4() + good, poison = uuid4(), uuid4() + batch = [ + ( + b"1-0", + { + b"data": _payload( + project_id=project_id, + session_id="sess-good", + record_id=good, + record_type="done", + turn_id="turn-good", + ) + }, + ), + ( + b"2-0", + { + b"data": _payload( + project_id=project_id, + session_id="sess-poison", + record_id=poison, + record_type="done", + turn_id="turn-poison", + ) + }, + ), + ] + + watch_publisher = AsyncMock() + interactions_service = AsyncMock() + interactions_service.cancel_session_pending = AsyncMock(return_value=0) + + worker = RecordsWorker( + service=RecordsService(records_dao=FakeRecordsDAO(poison_ids=[poison])), + redis_client=None, + stream_name=STREAM, + consumer_group=GROUP, + watch_publisher=watch_publisher, + interactions_service=interactions_service, + ) + + await worker.process_batch(batch) + + # A record that never committed must not wake a client or cancel a gate: the reader it + # would send to Postgres cannot see the row. + notified = { + call.kwargs["session_id"] + for call in watch_publisher.records_changed.await_args_list + } + assert notified == {"sess-good"} + reconciled = { + call.kwargs["session_id"] + for call in interactions_service.cancel_session_pending.await_args_list + } + assert reconciled == {"sess-good"} + + +async def _seed(redis_client, payloads): + await redis_client.xgroup_create( + name=STREAM, groupname=GROUP, id="0", mkstream=True + ) + for payload in payloads: + await redis_client.xadd(name=STREAM, fields={"data": payload}) + + +@pytest.mark.asyncio +async def test_unacknowledged_entry_comes_back_through_the_reclaim_pass(): + project_id = uuid4() + record_id = uuid4() + redis_client = fakeredis.FakeRedis() + await _seed( + redis_client, + [_payload(project_id=project_id, session_id="s", record_id=record_id)], + ) + + dao = FakeRecordsDAO(fail_calls=1) + worker = _worker(dao, redis_client=redis_client) + + batch = await worker.read_batch() + assert len(batch) == 1 + _, acked_ids = await worker.process_batch(batch) + assert acked_ids == [] + + # `read_batch` only ever asks for `>`, so without the reclaim pass this entry is invisible + # to every later read and the "leave it pending" fix would lose it silently. + assert await worker.read_batch() == [] + + await asyncio.sleep(0.01) + reclaimed = await worker.reclaim_batch() + assert [msg_id for msg_id, _ in reclaimed] == [msg_id for msg_id, _ in batch] + + _, acked_ids = await worker.process_batch(reclaimed) + assert acked_ids == [batch[0][0]] + await worker.ack_and_delete(acked_ids) + + assert dao.committed == [str(record_id)] + assert await redis_client.xlen(STREAM) == 0 + pending = await redis_client.xpending_range( + name=STREAM, groupname=GROUP, min="-", max="+", count=10 + ) + assert pending == [] + + +@pytest.mark.asyncio +async def test_a_record_that_never_writes_is_dropped_loudly_and_counted(caplog): + project_id = uuid4() + good, poison = uuid4(), uuid4() + redis_client = fakeredis.FakeRedis() + await _seed( + redis_client, + [ + _payload(project_id=project_id, session_id="s", record_id=good), + _payload( + project_id=project_id, + session_id="doomed-session", + record_id=poison, + record_type="done", + ), + ], + ) + + dao = FakeRecordsDAO(poison_ids=[poison]) + worker = _worker(dao, redis_client=redis_client, max_deliveries=3) + + batch = await worker.read_batch() + _, acked_ids = await worker.process_batch(batch) + await worker.ack_and_delete(acked_ids) + + for _ in range(6): + await asyncio.sleep(0.01) + reclaimed = await worker.reclaim_batch() + if not reclaimed: + break + _, acked_ids = await worker.process_batch(reclaimed) + await worker.ack_and_delete(acked_ids) + + assert dao.committed == [str(good)] + assert worker.dropped_messages == 1 + pending = await redis_client.xpending_range( + name=STREAM, groupname=GROUP, min="-", max="+", count=10 + ) + # The poison entry is gone, so it stops costing a write attempt every window. + assert pending == [] + + dropped = [ + record + for record in caplog.records + if "Dropping messages after repeated delivery failures" in record.getMessage() + ] + assert dropped, "the loss must be logged at error level" + assert dropped[0].levelname == "ERROR" + # The log names the lost record so the loss is traceable after the fact. + assert worker.describe_message(batch[1][1]) == f"doomed-session:{poison}:done" + + +@pytest.mark.asyncio +async def test_nothing_is_dropped_while_the_write_path_is_down(): + """A long outage must not consume the drop budget. + + The delivery counter cannot tell a rejected record apart from an unreachable database, so + dropping on the count alone would delete every record in flight once an outage outlasts + `max_deliveries` windows. That is exactly the loss this worker exists to prevent. + """ + project_id = uuid4() + record_ids = [uuid4(), uuid4()] + redis_client = fakeredis.FakeRedis() + await _seed( + redis_client, + [ + _payload(project_id=project_id, session_id="s", record_id=record_id) + for record_id in record_ids + ], + ) + + dao = FakeRecordsDAO(fail_calls=99) + worker = _worker(dao, redis_client=redis_client, max_deliveries=2) + + batch = await worker.read_batch() + await worker.process_batch(batch) + + for _ in range(6): + await asyncio.sleep(0.01) + reclaimed = await worker.reclaim_batch() + assert len(reclaimed) == 2 + await worker.process_batch(reclaimed) + + assert worker.dropped_messages == 0 + assert await redis_client.xlen(STREAM) == 2 + + # Postgres comes back. Both records land, and neither was deleted meanwhile. + dao.fail_calls = 0 + await asyncio.sleep(0.01) + reclaimed = await worker.reclaim_batch() + _, acked_ids = await worker.process_batch(reclaimed) + await worker.ack_and_delete(acked_ids) + + assert dao.committed == [str(record_id) for record_id in record_ids] + assert await redis_client.xlen(STREAM) == 0 + + +@pytest.mark.asyncio +async def test_recovery_with_new_traffic_keeps_the_over_budget_backlog(): + project_id = uuid4() + old_record, new_record = uuid4(), uuid4() + redis_client = fakeredis.FakeRedis() + await _seed( + redis_client, + [_payload(project_id=project_id, session_id="s", record_id=old_record)], + ) + + dao = FakeRecordsDAO(fail_calls=99) + worker = _worker(dao, redis_client=redis_client, max_deliveries=2) + + old_batch = await worker.read_batch() + await worker.process_batch(old_batch) + for _ in range(3): + await asyncio.sleep(0.01) + reclaimed = await worker.reclaim_batch() + assert [msg_id for msg_id, _ in reclaimed] == [ + msg_id for msg_id, _ in old_batch + ] + await worker.process_batch(reclaimed) + + dao.fail_calls = 0 + await redis_client.xadd( + name=STREAM, + fields={ + "data": _payload( + project_id=project_id, + session_id="s", + record_id=new_record, + ) + }, + ) + new_batch = await worker.read_batch() + _, acked_ids = await worker.process_batch(new_batch) + await worker.ack_and_delete(acked_ids) + + await asyncio.sleep(0.01) + reclaimed = await worker.reclaim_batch() + assert [msg_id for msg_id, _ in reclaimed] == [msg_id for msg_id, _ in old_batch] + _, acked_ids = await worker.process_batch(reclaimed) + await worker.ack_and_delete(acked_ids) + + assert dao.committed == [str(new_record), str(old_record)] + assert worker.dropped_messages == 0 + assert await redis_client.xlen(STREAM) == 0 + + +@pytest.mark.asyncio +async def test_describe_message_survives_an_undecodable_payload(): + assert _worker(FakeRecordsDAO()).describe_message({b"data": b"not-zlib"}) is None + + +def _fake_ee(monkeypatch, *, allowed=True, raises=False): + """Run the EE quota branch of `process_batch` without an EE build.""" + + async def check_entitlements(**_): + if raises: + raise RuntimeError("entitlements unreachable") + return allowed, None, None + + monkeypatch.setattr(records_worker, "is_ee", lambda: True) + monkeypatch.setattr( + records_worker, "check_entitlements", check_entitlements, raising=False + ) + monkeypatch.setattr( + records_worker, + "Counter", + SimpleNamespace(RECORDS_INGESTED="records"), + raising=False, + ) + monkeypatch.setattr( + records_worker, "scope_from", lambda **kwargs: kwargs, raising=False + ) + + +def _org_batch(*, organization_id, project_id, record_id): + message = { + "organization_id": str(organization_id), + "project_id": str(project_id), + "record_event": { + "project_id": str(project_id), + "session_id": "sess-1", + "record_id": str(record_id), + "record_type": "message", + }, + } + return [(b"1-0", {b"data": zlib.compress(dumps(message))})] + + +@pytest.mark.asyncio +async def test_over_quota_org_is_acknowledged_and_counted(monkeypatch): + _fake_ee(monkeypatch, allowed=False) + dao = FakeRecordsDAO() + worker = _worker(dao) + + _, acked_ids = await worker.process_batch( + _org_batch(organization_id=uuid4(), project_id=uuid4(), record_id=uuid4()) + ) + + # Over quota is a deliberate product drop, so redelivering it would spin forever. + assert acked_ids == [b"1-0"] + assert worker.dropped_messages == 1 + assert dao.committed == [] + + +@pytest.mark.asyncio +async def test_unreachable_quota_meter_leaves_the_record_pending(monkeypatch): + _fake_ee(monkeypatch, raises=True) + dao = FakeRecordsDAO() + worker = _worker(dao) + + _, acked_ids = await worker.process_batch( + _org_batch(organization_id=uuid4(), project_id=uuid4(), record_id=uuid4()) + ) + + # The meter was unreachable, not exceeded. Deleting the record would turn an + # entitlements outage into a deleted conversation. + assert acked_ids == [] + assert worker.dropped_messages == 0 + assert dao.committed == [] diff --git a/api/oss/tests/pytest/unit/sessions/test_runner_client_kill.py b/api/oss/tests/pytest/unit/sessions/test_runner_client_kill.py index 1966fdbeff5..2650cee59c4 100644 --- a/api/oss/tests/pytest/unit/sessions/test_runner_client_kill.py +++ b/api/oss/tests/pytest/unit/sessions/test_runner_client_kill.py @@ -9,7 +9,11 @@ import httpx import pytest -from oss.src.core.sessions.streams.runner_client import kill_runner_sandbox +from oss.src.core.sessions.streams.runner_client import ( + RunnerCancelResult, + cancel_runner_execution, + kill_runner_sandbox, +) class _FakeRunnerEnv: @@ -120,3 +124,44 @@ async def post(self, *a, **kw): result = await kill_runner_sandbox(project_id="proj-1", session_id="sess-1") assert result is False + + +@pytest.mark.asyncio +async def test_cancel_accepts_non_object_json_without_crashing(): + class _FakeResponse: + status_code = 200 + + @staticmethod + def json(): + return ["accepted"] + + class _FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def post(self, *args, **kwargs): + return _FakeResponse() + + with ( + patch("oss.src.core.sessions.streams.runner_client.env") as mock_env, + patch( + "oss.src.core.sessions.streams.runner_client.httpx.AsyncClient", + return_value=_FakeClient(), + ), + ): + mock_env.runner = _FakeRunnerEnv( + internal_url="http://runner:8765", token="shared-secret" + ) + result = await cancel_runner_execution( + command_id="command-1", + project_id="project-1", + session_id="session-1", + target_turn_id="turn-1", + created_at="2026-09-04T00:00:00Z", + ) + + assert result.status == RunnerCancelResult.accepted + assert result.replica_id is None diff --git a/api/oss/tests/pytest/unit/sessions/test_session_cancel_admission.py b/api/oss/tests/pytest/unit/sessions/test_session_cancel_admission.py new file mode 100644 index 00000000000..35c66fbb1f5 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_session_cancel_admission.py @@ -0,0 +1,1528 @@ +"""What a Stop request decides before anything durable is written. + +Admission is where a Stop can go wrong in the two ways that matter to a user. It can miss the +run they meant, and it can kill a run they never meant. These pin the rules that stop both: + + * the arrival time is stamped BEFORE any read, and stored as the row's `created_at`, so the + value the guard compared is the value the runner can re-compare; + * a stale `expected_execution_id` is refused and writes nothing at all; + * an execution that started AFTER the request arrived is never targeted; + * only a named Stop can reach a parked session, which holds `alive` and not `running`; + * two Stops in a row collapse onto one command; + * Redis is not written at admission, so the stopping execution keeps its locks while it stops. +""" + +from contextlib import asynccontextmanager +from datetime import datetime, timedelta, timezone +from typing import Dict, List, Optional +from unittest.mock import AsyncMock, patch +from uuid import UUID, uuid4 + +import pytest +import pytest_asyncio +import uuid_utils.compat as uuid + +from oss.src.core.sessions.commands.dtos import ( + SessionCommand, + SessionCommandCreate, + SessionCommandOutcome, + SessionCommandState, +) +from oss.src.core.sessions.commands.interfaces import ( + CommandCreateResult, + DeliveryReceipt, +) +from oss.src.core.sessions.commands import service as commands_service_module +from oss.src.core.sessions.commands.service import SessionCommandsService +from oss.src.core.sessions.commands.types import ( + ExecutionExpectationFailed, + SessionCommandIdempotencyConflict, + SessionCommandNotClaimable, +) +from oss.src.core.sessions.executions.dtos import ( + SessionExecutionSettlement, + SessionExecutionSettlementResult, +) +from oss.src.core.sessions.streams.dtos import ( + CommandMode, + SessionStream, + SessionStreamCommandResponse, + SessionStreamFlags, +) +from oss.src.core.sessions.streams.types import SessionTurnMismatch +from oss.src.dbs.redis.sessions.locks import ( + acquire_alive, + acquire_running, + get_alive_owner, + get_running_owner, + get_session_liveness, + is_turn_superseded, + release_running, +) +from oss.src.utils.env import env +from oss.src.tasks.asyncio.sessions.orphan_sweep import _repair_terminal_redis + +from unit.sessions.test_project_scoped_locks import _FakeRedis + + +_PROJECT = uuid4() +_USER = uuid4() +_SESSION = "session_cancel_admission" + + +class _FakeCommandsDAO: + """Enough of the DAO to observe what admission wrote, and how many times.""" + + def __init__(self) -> None: + self.rows: List[SessionCommand] = [] + self.stopping_turn_ids: List[Optional[str]] = [] + self.claims: List[Dict] = [] + self.abandoned: List[SessionCommand] = [] + + @asynccontextmanager + async def transaction(self): + yield object() + + async def create_command( + self, *, user_id, command: SessionCommandCreate, stopping_turn_id=None + ): + row = SessionCommand( + id=uuid.uuid7(), + project_id=command.project_id, + session_id=command.session_id, + kind=command.kind, + target_turn_id=command.target_turn_id, + expected_turn_id=command.expected_turn_id, + data=command.data, + state=command.state, + outcome=command.outcome, + settled_at=command.settled_at, + idempotency_key=command.idempotency_key, + created_at=command.created_at, + ) + self.rows.append(row) + self.stopping_turn_ids.append(stopping_turn_id) + return row + + async def create_command_with_status( + self, *, user_id, command: SessionCommandCreate, stopping_turn_id=None + ): + if command.idempotency_key is not None: + for row in self.rows: + if ( + row.project_id == command.project_id + and row.session_id == command.session_id + and row.idempotency_key == command.idempotency_key + ): + return CommandCreateResult(command=row, inserted=False) + row = await self.create_command( + user_id=user_id, + command=command, + stopping_turn_id=stopping_turn_id, + ) + return CommandCreateResult(command=row, inserted=True) + + async def fetch_by_idempotency_key( + self, *, project_id, session_id, idempotency_key + ): + for row in self.rows: + if ( + row.project_id == project_id + and row.session_id == session_id + and row.idempotency_key == idempotency_key + ): + return row + return None + + async def fetch_open_command(self, *, project_id, session_id, kind, target_turn_id): + for row in reversed(self.rows): + if ( + row.project_id == project_id + and row.session_id == session_id + and row.kind == kind + and row.target_turn_id == target_turn_id + and row.state + in (SessionCommandState.pending, SessionCommandState.claimed) + ): + return row + return None + + async def fetch_command(self, *, command_id, project_id=None): + for row in self.rows: + if row.id == command_id: + return row + return None + + async def claim_for_delivery( + self, *, project_id, command_id, replica_id, lease_seconds + ): + # A copy, never a mutation of the object the caller holds — the real DAO returns a + # fresh row from RETURNING *, so admission's own view of the command stays as it was. + self.claims.append({"command_id": command_id, "replica_id": replica_id}) + for index, row in enumerate(self.rows): + if row.id == command_id and row.state == SessionCommandState.pending: + claimed = row.model_copy( + update={ + "state": SessionCommandState.claimed, + "claimed_by": replica_id, + } + ) + self.rows[index] = claimed + return claimed + return None + + async def record_delivery_attempt( + self, *, project_id, command_id, now, max_deliveries + ): + for index, row in enumerate(self.rows): + if ( + row.id == command_id + and row.state + in (SessionCommandState.pending, SessionCommandState.claimed) + and row.claim_count < max_deliveries + ): + attempted = row.model_copy( + update={ + "state": SessionCommandState.pending, + "claimed_by": None, + "claim_expires_at": None, + "claim_count": row.claim_count + 1, + "updated_at": now, + } + ) + self.rows[index] = attempted + return attempted + return None + + async def claim_commands(self, **_): + return [] + + async def settle_command(self, *, settle, transaction=None): + for index, row in enumerate(self.rows): + if row.id == settle.command_id and row.state in settle.expected_states: + # Mirrors the real guard: a `pending` row holds no claim, so a null + # `claimed_by` passes; a claimed row must be claimed by the reporter. + if ( + settle.replica_id is not None + and row.claimed_by is not None + and row.claimed_by != settle.replica_id + ): + return None + settled = row.model_copy( + update={ + "state": settle.state, + "outcome": settle.outcome, + "settled_at": datetime.now(timezone.utc), + } + ) + self.rows[index] = settled + return settled + return None + + async def clear_stopping_turn(self, *, project_id, session_id, turn_id=None): + self.stopping_turn_ids.append(None) + + async def expire_claims(self, *, now, max_deliveries, pending_before=None): + return self.abandoned + + +class _FakeStreamsService: + """The reads admission makes, plus the row settlement writes. + + `mirrored` stands in for the `session_streams` row. It records the nest exactly as the real + `_mirror_flags` would read it — from Redis, at the moment settlement calls — so a test can + assert what the ROW says and not merely that a call happened. `query_streams`, which is what + the product's liveness polls read, serves that row and never looks at Redis. + """ + + def __init__( + self, stream: Optional[SessionStream] = None, lock_engine=None + ) -> None: + self.stream = stream + self.ended: List[str] = [] + self.lock_engine = lock_engine + self.mirrored: List[Dict[str, bool]] = [] + + async def fetch_header(self, *, project_id: UUID, session_id: str): + return self.stream + + async def command(self, *, project_id, user_id, request): + actual = self.stream.turn_id if self.stream is not None else None + if ( + request.expected_execution_id is not None + and actual != request.expected_execution_id + ): + raise SessionTurnMismatch( + request.session_id, + expected_turn_id=request.expected_execution_id, + actual_turn_id=actual, + ) + return SessionStreamCommandResponse( + mode=CommandMode.cancel, + session_id=request.session_id, + turn_id=actual, + detached=True, + ) + + async def publish_session_ended(self, *, project_id: UUID, session_id: str): + self.ended.append(session_id) + + async def mirror_liveness(self, *, project_id: UUID, session_id: str, user_id=None): + snap = await get_session_liveness( + self.lock_engine, project_id=str(project_id), session_id=session_id + ) + self.mirrored.append( + { + "is_alive": snap["alive"], + "is_running": snap["running"], + "is_attached": snap["attached"], + } + ) + + async def settle_command( + self, + *, + project_id, + session_id, + turn_id, + mirror_stopped, + transaction=None, + ): + if mirror_stopped and self.stream is not None: + self.stream = self.stream.model_copy( + update={ + "flags": self.stream.flags.model_copy(update={"is_running": False}) + } + ) + + +class _FakeInteractionsService: + def __init__(self, *, cancelled_count: int = 1) -> None: + self.cancelled: List[Optional[str]] = [] + self.command_ids: List[Optional[UUID]] = [] + self.published_cancelled: List[str] = [] + self.cancelled_count = cancelled_count + + async def cancel_session_pending( + self, + *, + project_id, + session_id, + only_turn_id=None, + command_id=None, + **_, + ): + self.cancelled.append(only_turn_id) + self.command_ids.append(command_id) + return self.cancelled_count + + async def publish_session_pending_cancelled( + self, *, project_id, session_id + ) -> None: + self.published_cancelled.append(session_id) + + +class _RecordingDelivery: + def __init__(self, status: str = "accepted") -> None: + self.status = status + self.delivered: List[SessionCommand] = [] + + async def deliver(self, *, command: SessionCommand) -> DeliveryReceipt: + self.delivered.append(command) + return DeliveryReceipt(status=self.status, replica_id="runner-1") + + async def acknowledge(self, *, command_id, replica_id) -> None: + return None + + +class _FakeExecutionsDAO: + def __init__(self) -> None: + self.rows: Dict[tuple[str, str], SessionExecutionSettlement] = {} + self.commands = None + self.interactions = None + + async def settle( + self, + *, + project_id, + session_id, + execution_id, + terminal_outcome, + settled_by, + settled_at=None, + transaction=None, + ): + key = (session_id, execution_id) + if key in self.rows: + return SessionExecutionSettlementResult( + settlement=self.rows[key], won=False + ) + row = SessionExecutionSettlement( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + terminal_outcome=terminal_outcome, + settled_by=settled_by, + settled_at=settled_at or datetime.now(timezone.utc), + ) + self.rows[key] = row + return SessionExecutionSettlementResult(settlement=row, won=True) + + async def list_redis_unreconciled(self, *, limit): + return [ + row + for row in self.rows.values() + if row.settled_by == "runner" + and row.terminal_outcome == "stopped" + and row.redis_reconciled_at is None + ][:limit] + + async def mark_redis_reconciled(self, *, project_id, session_id, execution_id): + key = (session_id, execution_id) + self.rows[key] = self.rows[key].model_copy( + update={"redis_reconciled_at": datetime.now(timezone.utc)} + ) + + +def _stream( + turn_id: Optional[str], turn_started_at: Optional[datetime] +) -> SessionStream: + return SessionStream( + id=uuid4(), + project_id=_PROJECT, + session_id=_SESSION, + turn_id=turn_id, + turn_started_at=turn_started_at, + flags=SessionStreamFlags(is_alive=True, is_running=True), + updated_at=datetime.now(timezone.utc), + ) + + +@pytest_asyncio.fixture +async def lock_engine(): + from oss.src.dbs.redis.shared.engine import LockEngine + + eng = LockEngine() + with patch.object(eng, "_client", return_value=_FakeRedis()): + yield eng + + +def _service( + lock_engine, + *, + dao=None, + streams=None, + interactions=None, + delivery=None, + executions=None, +): + streams = streams or _FakeStreamsService() + # The fake mirrors from Redis, so it reads the same engine the service writes through. + if streams.lock_engine is None: + streams.lock_engine = lock_engine + commands = dao or _FakeCommandsDAO() + interactions = interactions or _FakeInteractionsService() + if executions is not None: + executions.commands = commands + executions.interactions = interactions + return SessionCommandsService( + commands_dao=commands, + streams_service=streams, + interactions_service=interactions, + lock_engine=lock_engine, + delivery=delivery or _RecordingDelivery(), + executions_dao=executions, + ) + + +async def _run_turn(lock_engine, turn_id: str) -> None: + await acquire_alive( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION, turn_id=turn_id + ) + await acquire_running( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION, turn_id=turn_id + ) + + +@pytest.mark.asyncio +async def test_stop_on_a_running_turn_is_accepted_and_pins_the_target(lock_engine): + await _run_turn(lock_engine, "turn-A") + dao = _FakeCommandsDAO() + delivery = _RecordingDelivery() + started = datetime.now(timezone.utc) - timedelta(seconds=30) + svc = _service( + lock_engine, + dao=dao, + streams=_FakeStreamsService(_stream("turn-A", started)), + delivery=delivery, + ) + + admission = await svc.request_cancel( + project_id=_PROJECT, user_id=_USER, session_id=_SESSION + ) + + assert admission.accepted is True + assert admission.execution_id == "turn-A" + assert admission.command.state == SessionCommandState.pending + assert admission.command.target_turn_id == "turn-A" + # The row and the session marker are written together. + assert dao.stopping_turn_ids == ["turn-A"] + assert len(delivery.delivered) == 1 + + +@pytest.mark.asyncio +async def test_admission_does_not_touch_redis(lock_engine): + await _run_turn(lock_engine, "turn-A") + svc = _service( + lock_engine, + streams=_FakeStreamsService( + _stream("turn-A", datetime.now(timezone.utc) - timedelta(seconds=30)) + ), + ) + + await svc.request_cancel(project_id=_PROJECT, user_id=_USER, session_id=_SESSION) + + # The stopping execution keeps both locks WHILE it stops, which is what prevents a second + # message from starting underneath it. + assert ( + await get_running_owner( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION + ) + == "turn-A" + ) + assert ( + await get_alive_owner( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION + ) + == "turn-A" + ) + + +@pytest.mark.asyncio +async def test_stop_when_nothing_runs_is_settled_at_once(lock_engine): + dao = _FakeCommandsDAO() + delivery = _RecordingDelivery() + svc = _service(lock_engine, dao=dao, delivery=delivery) + + admission = await svc.request_cancel( + project_id=_PROJECT, user_id=_USER, session_id=_SESSION + ) + + assert admission.accepted is False + assert admission.execution_id is None + assert admission.command.state == SessionCommandState.obsolete + assert admission.command.outcome == SessionCommandOutcome.not_running + assert delivery.delivered == [], "nothing to deliver to" + assert dao.stopping_turn_ids == [None], "no session is stopping" + + +@pytest.mark.asyncio +async def test_stale_expected_execution_id_is_refused_and_writes_nothing(lock_engine): + await _run_turn(lock_engine, "turn-B") + dao = _FakeCommandsDAO() + delivery = _RecordingDelivery() + svc = _service( + lock_engine, + dao=dao, + streams=_FakeStreamsService( + _stream("turn-B", datetime.now(timezone.utc) - timedelta(seconds=5)) + ), + delivery=delivery, + ) + + with pytest.raises(ExecutionExpectationFailed) as excinfo: + await svc.request_cancel( + project_id=_PROJECT, + user_id=_USER, + session_id=_SESSION, + expected_execution_id="turn-A", + ) + + assert excinfo.value.current == "turn-B" + assert dao.rows == [], "a refused Stop must insert nothing" + assert delivery.delivered == [] + + +@pytest.mark.asyncio +async def test_legacy_cancel_keeps_the_expected_execution_guard(lock_engine): + await _run_turn(lock_engine, "turn-B") + svc = _service( + lock_engine, + streams=_FakeStreamsService( + _stream("turn-B", datetime.now(timezone.utc) - timedelta(seconds=5)) + ), + ) + + with pytest.raises(ExecutionExpectationFailed) as excinfo: + await svc.request_cancel_legacy( + project_id=_PROJECT, + user_id=_USER, + session_id=_SESSION, + expected_execution_id="turn-A", + ) + + assert excinfo.value.current == "turn-B" + assert ( + await get_running_owner( + lock_engine, + project_id=str(_PROJECT), + session_id=_SESSION, + ) + == "turn-B" + ) + + +@pytest.mark.asyncio +async def test_a_turn_that_started_after_the_request_is_never_targeted(lock_engine): + # The race: the user presses Stop, turn one ends, turn two starts, and only then does the + # request get applied. Turn two must not hear about it. + await _run_turn(lock_engine, "turn-two") + dao = _FakeCommandsDAO() + delivery = _RecordingDelivery() + svc = _service( + lock_engine, + dao=dao, + streams=_FakeStreamsService( + _stream("turn-two", datetime.now(timezone.utc) + timedelta(seconds=5)) + ), + delivery=delivery, + ) + + admission = await svc.request_cancel( + project_id=_PROJECT, user_id=_USER, session_id=_SESSION + ) + + assert admission.accepted is False + assert admission.execution_id is None + assert admission.command.target_turn_id is None + assert admission.command.outcome == SessionCommandOutcome.superseded_by_newer_turn + assert delivery.delivered == [], "the newer turn is never contacted" + # And its locks are untouched. + assert ( + await get_running_owner( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION + ) + == "turn-two" + ) + + +@pytest.mark.asyncio +async def test_the_guard_does_not_fire_when_the_start_time_is_unknown(lock_engine): + # A row written before `turn_started_at` existed yields no comparison. Failing this way + # round is deliberate: refusing every Stop we cannot verify would break the common case. + await _run_turn(lock_engine, "turn-A") + svc = _service(lock_engine, streams=_FakeStreamsService(_stream("turn-A", None))) + + admission = await svc.request_cancel( + project_id=_PROJECT, user_id=_USER, session_id=_SESSION + ) + + assert admission.accepted is True + assert admission.command.target_turn_id == "turn-A" + + +@pytest.mark.asyncio +async def test_the_stored_created_at_is_the_value_that_was_compared(lock_engine): + await _run_turn(lock_engine, "turn-A") + dao = _FakeCommandsDAO() + before = datetime.now(timezone.utc) + svc = _service( + lock_engine, + dao=dao, + streams=_FakeStreamsService( + _stream("turn-A", datetime.now(timezone.utc) - timedelta(seconds=30)) + ), + ) + + await svc.request_cancel(project_id=_PROJECT, user_id=_USER, session_id=_SESSION) + after = datetime.now(timezone.utc) + + stored = dao.rows[0].created_at + assert stored is not None + # Stamped by the service, not defaulted by the server: the runner repeats this comparison. + assert before <= stored <= after + + +@pytest.mark.asyncio +async def test_unfenced_stop_before_new_turn_admission_ignores_the_parked_owner( + lock_engine, +): + # Turn B was submitted by the browser but has not established `running` yet. + await acquire_alive( + lock_engine, + project_id=str(_PROJECT), + session_id=_SESSION, + turn_id="turn-parked", + ) + delivery = _RecordingDelivery() + dao = _FakeCommandsDAO() + svc = _service( + lock_engine, + dao=dao, + streams=_FakeStreamsService(_stream("turn-parked", None)), + delivery=delivery, + ) + + admission = await svc.request_cancel( + project_id=_PROJECT, user_id=_USER, session_id=_SESSION + ) + + assert admission.accepted is False + assert admission.execution_id is None + assert admission.command.outcome == SessionCommandOutcome.not_running + assert delivery.delivered == [] + assert ( + await get_alive_owner( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION + ) + == "turn-parked" + ) + assert ( + await get_running_owner( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION + ) + is None + ) + assert not await is_turn_superseded( + lock_engine, + project_id=str(_PROJECT), + session_id=_SESSION, + turn_id="turn-parked", + ) + + +@pytest.mark.asyncio +async def test_a_named_stop_reaches_a_parked_approval(lock_engine): + """The Stop the browser actually sends, on the session state Stop exists to reach. + + A parked approval has released `running` and still holds `alive` under the same turn id. + The browser always sends `expected_execution_id`, because it knows the id it streamed. If + the expectation is compared against `running` alone it is None here, so the named Stop is + refused with a conflict while the identical Stop without an expectation is accepted — the + guard firing on the one case it exists to allow, and the gate left pending. + """ + await acquire_alive( + lock_engine, + project_id=str(_PROJECT), + session_id=_SESSION, + turn_id="turn-parked", + ) + delivery = _RecordingDelivery() + svc = _service( + lock_engine, + streams=_FakeStreamsService(_stream("turn-parked", None)), + delivery=delivery, + ) + + admission = await svc.request_cancel( + project_id=_PROJECT, + user_id=_USER, + session_id=_SESSION, + expected_execution_id="turn-parked", + ) + + assert admission.accepted is True + assert admission.execution_id == "turn-parked" + assert len(delivery.delivered) == 1 + + +@pytest.mark.asyncio +async def test_a_named_stop_on_a_parked_session_still_refuses_a_different_turn( + lock_engine, +): + """The guard must keep working on the fallback, not merely stop firing. + + A user looking at a turn that finished, on a session now parked under a NEWER turn, must + still be refused: the id they named is not the one that would be stopped. + """ + await acquire_alive( + lock_engine, + project_id=str(_PROJECT), + session_id=_SESSION, + turn_id="turn-new", + ) + dao = _FakeCommandsDAO() + delivery = _RecordingDelivery() + svc = _service( + lock_engine, + dao=dao, + streams=_FakeStreamsService(_stream("turn-new", None)), + delivery=delivery, + ) + + with pytest.raises(ExecutionExpectationFailed) as excinfo: + await svc.request_cancel( + project_id=_PROJECT, + user_id=_USER, + session_id=_SESSION, + expected_execution_id="turn-old", + ) + + assert excinfo.value.current == "turn-new" + assert dao.rows == [] + assert delivery.delivered == [] + + +@pytest.mark.asyncio +async def test_two_stops_in_a_row_collapse_onto_one_command(lock_engine): + await _run_turn(lock_engine, "turn-A") + dao = _FakeCommandsDAO() + svc = _service( + lock_engine, + dao=dao, + streams=_FakeStreamsService( + _stream("turn-A", datetime.now(timezone.utc) - timedelta(seconds=30)) + ), + ) + + first = await svc.request_cancel( + project_id=_PROJECT, user_id=_USER, session_id=_SESSION + ) + second = await svc.request_cancel( + project_id=_PROJECT, + user_id=_USER, + session_id=_SESSION, + idempotency_key="a-different-key", + ) + + assert len(dao.rows) == 1, "one intent, one command" + assert second.command.id == first.command.id + assert second.accepted is True + + +@pytest.mark.asyncio +async def test_reused_idempotency_key_replays_the_original_turn_without_redelivery( + lock_engine, +): + await _run_turn(lock_engine, "turn-A") + dao = _FakeCommandsDAO() + delivery = _RecordingDelivery() + streams = _FakeStreamsService( + _stream("turn-A", datetime.now(timezone.utc) - timedelta(seconds=30)) + ) + svc = _service( + lock_engine, + dao=dao, + streams=streams, + delivery=delivery, + ) + + first = await svc.request_cancel( + project_id=_PROJECT, + user_id=_USER, + session_id=_SESSION, + expected_execution_id="turn-A", + idempotency_key="same-request", + ) + dao.rows[0] = dao.rows[0].model_copy( + update={ + "state": SessionCommandState.applied, + "outcome": SessionCommandOutcome.stopped, + } + ) + await release_running( + lock_engine, + project_id=str(_PROJECT), + session_id=_SESSION, + turn_id="turn-A", + ) + await acquire_running( + lock_engine, + project_id=str(_PROJECT), + session_id=_SESSION, + turn_id="turn-B", + ) + streams.stream = _stream( + "turn-B", datetime.now(timezone.utc) - timedelta(seconds=5) + ) + + replay = await svc.request_cancel( + project_id=_PROJECT, + user_id=_USER, + session_id=_SESSION, + expected_execution_id="turn-A", + idempotency_key="same-request", + ) + + assert replay.command.id == first.command.id + assert replay.command.state == SessionCommandState.applied + assert replay.execution_id == "turn-A" + assert replay.accepted is True + assert len(delivery.delivered) == 1, "an idempotent replay must not target turn-B" + + +@pytest.mark.asyncio +async def test_reused_idempotency_key_rejects_a_different_expected_execution( + lock_engine, +): + await _run_turn(lock_engine, "turn-A") + dao = _FakeCommandsDAO() + delivery = _RecordingDelivery() + svc = _service( + lock_engine, + dao=dao, + streams=_FakeStreamsService( + _stream("turn-A", datetime.now(timezone.utc) - timedelta(seconds=30)) + ), + delivery=delivery, + ) + await svc.request_cancel( + project_id=_PROJECT, + user_id=_USER, + session_id=_SESSION, + expected_execution_id="turn-A", + idempotency_key="same-key-different-request", + ) + + with pytest.raises(SessionCommandIdempotencyConflict): + await svc.request_cancel( + project_id=_PROJECT, + user_id=_USER, + session_id=_SESSION, + expected_execution_id="turn-B", + idempotency_key="same-key-different-request", + ) + + assert len(dao.rows) == 1 + assert len(delivery.delivered) == 1 + + +@pytest.mark.asyncio +async def test_a_reachable_runner_that_does_not_hold_the_session_settles_at_once( + lock_engine, +): + await _run_turn(lock_engine, "turn-A") + dao = _FakeCommandsDAO() + streams = _FakeStreamsService( + _stream("turn-A", datetime.now(timezone.utc) - timedelta(seconds=30)) + ) + # A row that has not beaten for a long time: the session really did end, so `not_running` + # is the honest answer rather than the wrong-replica failure. + streams.stream.updated_at = datetime.now(timezone.utc) - timedelta(minutes=30) + interactions = _FakeInteractionsService() + svc = _service( + lock_engine, + dao=dao, + streams=streams, + interactions=interactions, + delivery=_RecordingDelivery(status="not_held"), + ) + + admission = await svc.request_cancel( + project_id=_PROJECT, user_id=_USER, session_id=_SESSION + ) + + assert admission.accepted is True, "the caller still gets a durable command" + assert dao.rows[0].state == SessionCommandState.obsolete + assert dao.rows[0].outcome == SessionCommandOutcome.not_running + assert interactions.cancelled == ["turn-A"] + assert streams.ended == [_SESSION] + + +@pytest.mark.asyncio +async def test_not_held_on_a_beating_session_is_reported_as_lost_not_finished( + lock_engine, +): + # The wrong-replica failure. The user must be told the Stop failed, never that the work had + # already finished. `_run_turn` holds `running`, which is the discriminator: an execution is + # being run somewhere, and it is not by the process we called. + await _run_turn(lock_engine, "turn-A") + dao = _FakeCommandsDAO() + streams = _FakeStreamsService( + _stream("turn-A", datetime.now(timezone.utc) - timedelta(seconds=30)) + ) + svc = _service( + lock_engine, + dao=dao, + streams=streams, + delivery=_RecordingDelivery(status="not_held"), + ) + + await svc.request_cancel(project_id=_PROJECT, user_id=_USER, session_id=_SESSION) + + assert dao.rows[0].outcome == SessionCommandOutcome.lost + + +@pytest.mark.asyncio +async def test_not_held_on_a_turn_that_just_ended_is_not_running_not_lost(lock_engine): + """The everyday late Stop: the answer landed, the user pressed Stop a moment after. + + The turn released `running` and left `alive` and a fresh heartbeat behind it, exactly as a + RUNNING turn would, so a beating-row test calls this a failed Stop and tells the user their + Stop was lost. Nothing was lost: the work finished. `running` is what separates the two, + because a session nobody is executing has no `running` owner at all. + """ + # `alive` only, which is what a turn leaves when it ends. + await acquire_alive( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION, turn_id="turn-A" + ) + dao = _FakeCommandsDAO() + streams = _FakeStreamsService( + _stream("turn-A", datetime.now(timezone.utc) - timedelta(seconds=30)) + ) + # Beating, and recently: the turn ended seconds ago, not half an hour ago. + streams.stream.updated_at = datetime.now(timezone.utc) + svc = _service( + lock_engine, + dao=dao, + streams=streams, + delivery=_RecordingDelivery(status="not_held"), + ) + + await svc.request_cancel(project_id=_PROJECT, user_id=_USER, session_id=_SESSION) + + assert dao.rows[0].state == SessionCommandState.obsolete + assert dao.rows[0].outcome == SessionCommandOutcome.not_running + + +@pytest.mark.asyncio +async def test_an_unreachable_runner_leaves_the_command_open(lock_engine): + await _run_turn(lock_engine, "turn-A") + dao = _FakeCommandsDAO() + svc = _service( + lock_engine, + dao=dao, + streams=_FakeStreamsService( + _stream("turn-A", datetime.now(timezone.utc) - timedelta(seconds=30)) + ), + delivery=_RecordingDelivery(status="unreachable"), + ) + + admission = await svc.request_cancel( + project_id=_PROJECT, user_id=_USER, session_id=_SESSION + ) + + # Admission still succeeded. The command is durable, so a later delivery or the settlement + # sweep gives the user a terminal state instead of a Stop that vanished. + assert admission.accepted is True + assert dao.rows[0].state == SessionCommandState.pending + + +@pytest.mark.asyncio +async def test_settlement_releases_running_and_leaves_alive_alone(lock_engine): + await _run_turn(lock_engine, "turn-A") + dao = _FakeCommandsDAO() + streams = _FakeStreamsService( + _stream("turn-A", datetime.now(timezone.utc) - timedelta(seconds=30)) + ) + interactions = _FakeInteractionsService() + svc = _service(lock_engine, dao=dao, streams=streams, interactions=interactions) + + admission = await svc.request_cancel( + project_id=_PROJECT, user_id=_USER, session_id=_SESSION + ) + await svc.report_outcome( + command_id=admission.command.id, + replica_id="runner-1", + result="applied", + execution_id="turn-A", + execution_state="stopped", + ) + + assert dao.rows[0].state == SessionCommandState.applied + assert dao.rows[0].outcome == SessionCommandOutcome.stopped + assert ( + await get_running_owner( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION + ) + is None + ), "running is released under an owner check" + # THE assertion that pins warm resume. Force-deleting `alive` is what makes today's cancel + # read as a session teardown; Stop must leave the session as a finished turn leaves it. + assert ( + await get_alive_owner( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION + ) + == "turn-A" + ) + assert interactions.cancelled == ["turn-A"] + assert interactions.command_ids == [admission.command.id] + assert streams.ended == [_SESSION] + + +@pytest.mark.asyncio +async def test_settlement_writes_the_row_as_alive_and_not_running(lock_engine): + """The ROW, not only Redis — the row is the only thing the product's liveness polls read. + + Redis is already right the moment settlement returns, and the test above pins that. The row + is a separate write, and nothing else performs it: settlement tombstones the execution first, + so the runner's own final `is_running=false` heartbeat is refused before it reaches the + heartbeat's mirror write. Left unwritten, the row says `is_running: true` until the orphan + sweep collapses it minutes later, and the tab that pressed Stop shows its own session as + running somewhere else for that whole time. + """ + await _run_turn(lock_engine, "turn-A") + streams = _FakeStreamsService( + _stream("turn-A", datetime.now(timezone.utc) - timedelta(seconds=30)) + ) + svc = _service(lock_engine, streams=streams) + + admission = await svc.request_cancel( + project_id=_PROJECT, user_id=_USER, session_id=_SESSION + ) + await svc.report_outcome( + command_id=admission.command.id, + replica_id="runner-1", + result="applied", + execution_id="turn-A", + execution_state="stopped", + ) + + # Written once, and written AFTER `running` was released — a mirror taken before the release + # would have recorded `is_running: True` and been exactly the bug. + assert streams.mirrored == [ + {"is_alive": True, "is_running": False, "is_attached": False} + ] + # And the mirror is the state a normally finished turn leaves behind, which is what makes + # the session read as resumable rather than as torn down. + assert streams.mirrored[-1]["is_alive"] is True + + +@pytest.mark.asyncio +async def test_a_settlement_that_stops_nothing_does_not_touch_the_row(lock_engine): + """`not_running` changes no lock, so it must not write the row either. + + An obsolete Stop lands here: the turn it named had already finished, a NEWER turn may hold + the nest, and a mirror write from this path would be a write the settlement has no business + making. The row is left to the live turn's own heartbeats. + """ + dao = _FakeCommandsDAO() + streams = _FakeStreamsService(None) + svc = _service(lock_engine, dao=dao, streams=streams) + + # Nothing running and nothing parked: admission settles the command at insert. + admission = await svc.request_cancel( + project_id=_PROJECT, user_id=_USER, session_id=_SESSION + ) + + assert admission.accepted is False + assert dao.rows[0].outcome == SessionCommandOutcome.not_running + assert streams.mirrored == [] + + +@pytest.mark.asyncio +async def test_an_outcome_that_beats_the_claim_still_settles(lock_engine): + """The race the runner wins on a fast abort, driven at the exact instant it happens. + + Admission inserts the command `pending`, hands it to the runner, and writes `claimed` only + after the runner answers. A runner that aborts inside that window reports its outcome while + the row still says `pending`. Guarded on `claimed` alone that report was refused with a + conflict, the command sat open, and the sweep later recorded a Stop that actually worked as + lost — with the user watching "stopping" for the whole sweep window. + + The delivery double below reports from inside `deliver`, which is precisely where the real + runner's report lands relative to the claim. + """ + await _run_turn(lock_engine, "turn-A") + dao = _FakeCommandsDAO() + holder: Dict[str, SessionCommandsService] = {} + + class _ReportsBeforeTheClaimCommits: + def __init__(self) -> None: + self.delivered: List[SessionCommand] = [] + self.state_at_report: Optional[SessionCommandState] = None + + async def deliver(self, *, command): + self.delivered.append(command) + # The window. Nothing has written `claimed` yet, and the runner is already done. + self.state_at_report = dao.rows[0].state + await holder["svc"].report_outcome( + command_id=command.id, + replica_id="runner-1", + result="applied", + execution_id="turn-A", + execution_state="stopped", + ) + return DeliveryReceipt(status="accepted", replica_id="runner-1") + + async def acknowledge(self, *, command_id, replica_id): + return None + + delivery = _ReportsBeforeTheClaimCommits() + svc = _service( + lock_engine, + dao=dao, + streams=_FakeStreamsService( + _stream("turn-A", datetime.now(timezone.utc) - timedelta(seconds=5)) + ), + delivery=delivery, + ) + holder["svc"] = svc + + await svc.request_cancel(project_id=_PROJECT, user_id=_USER, session_id=_SESSION) + + assert delivery.state_at_report == SessionCommandState.pending, ( + "the test is only meaningful if the report really did beat the claim" + ) + assert dao.rows[0].state == SessionCommandState.applied + assert dao.rows[0].outcome == SessionCommandOutcome.stopped + # And the claim that arrives afterwards must not resurrect a settled command. + assert dao.rows[0].state == SessionCommandState.applied + + +@pytest.mark.asyncio +async def test_an_outcome_from_a_replica_that_does_not_hold_the_claim_is_refused( + lock_engine, +): + """Widening the guard to `pending` must not weaken it for a row that IS claimed. + + A claimed row names its holder, and only that holder may write the outcome. The null + `claimed_by` this change now admits exists solely for the unclaimed row. + """ + await _run_turn(lock_engine, "turn-A") + dao = _FakeCommandsDAO() + svc = _service( + lock_engine, + dao=dao, + streams=_FakeStreamsService( + _stream("turn-A", datetime.now(timezone.utc) - timedelta(seconds=5)) + ), + ) + + admission = await svc.request_cancel( + project_id=_PROJECT, user_id=_USER, session_id=_SESSION + ) + assert dao.rows[0].state == SessionCommandState.claimed + + from oss.src.core.sessions.commands.types import SessionCommandNotClaimable + + with pytest.raises(SessionCommandNotClaimable): + await svc.report_outcome( + command_id=admission.command.id, + replica_id="a-different-replica", + result="applied", + execution_id="turn-A", + execution_state="stopped", + ) + + assert dao.rows[0].state == SessionCommandState.claimed + + +@pytest.mark.asyncio +async def test_a_second_outcome_report_changes_nothing(lock_engine): + await _run_turn(lock_engine, "turn-A") + dao = _FakeCommandsDAO() + interactions = _FakeInteractionsService() + svc = _service( + lock_engine, + dao=dao, + streams=_FakeStreamsService( + _stream("turn-A", datetime.now(timezone.utc) - timedelta(seconds=30)) + ), + interactions=interactions, + ) + + admission = await svc.request_cancel( + project_id=_PROJECT, user_id=_USER, session_id=_SESSION + ) + await svc.report_outcome( + command_id=admission.command.id, + replica_id="runner-1", + result="applied", + execution_id="turn-A", + execution_state="stopped", + ) + from oss.src.core.sessions.commands.types import SessionCommandNotClaimable + + with pytest.raises(SessionCommandNotClaimable): + await svc.report_outcome( + command_id=admission.command.id, + replica_id="runner-1", + result="applied", + execution_id="turn-A", + execution_state="stopped", + ) + + assert interactions.cancelled == ["turn-A"], "the side effects run exactly once" + + +@pytest.mark.asyncio +async def test_runner_outcome_settles_the_execution_authority(lock_engine): + await _run_turn(lock_engine, "turn-A") + executions = _FakeExecutionsDAO() + interactions = _FakeInteractionsService() + svc = _service( + lock_engine, + streams=_FakeStreamsService( + _stream("turn-A", datetime.now(timezone.utc) - timedelta(seconds=30)) + ), + interactions=interactions, + executions=executions, + ) + + admission = await svc.request_cancel( + project_id=_PROJECT, user_id=_USER, session_id=_SESSION + ) + await svc.report_outcome( + command_id=admission.command.id, + replica_id="runner-1", + result="applied", + execution_id="turn-A", + execution_state="stopped", + ) + + winner = executions.rows[(_SESSION, "turn-A")] + assert winner.terminal_outcome == "stopped" + assert winner.settled_by == "runner" + assert interactions.published_cancelled == [_SESSION] + + +@pytest.mark.asyncio +async def test_atomic_settlement_does_not_publish_when_no_gate_was_cancelled( + lock_engine, +): + await _run_turn(lock_engine, "turn-A") + interactions = _FakeInteractionsService(cancelled_count=0) + svc = _service( + lock_engine, + streams=_FakeStreamsService( + _stream("turn-A", datetime.now(timezone.utc) - timedelta(seconds=30)) + ), + interactions=interactions, + executions=_FakeExecutionsDAO(), + ) + + admission = await svc.request_cancel( + project_id=_PROJECT, user_id=_USER, session_id=_SESSION + ) + await svc.report_outcome( + command_id=admission.command.id, + replica_id="runner-1", + result="applied", + execution_id="turn-A", + execution_state="stopped", + ) + + assert interactions.cancelled == ["turn-A"] + assert interactions.published_cancelled == [] + + +@pytest.mark.asyncio +async def test_watchdog_cannot_replace_the_runners_terminal_outcome(lock_engine): + executions = _FakeExecutionsDAO() + svc = _service(lock_engine, executions=executions) + first = await executions.settle( + project_id=_PROJECT, + session_id=_SESSION, + execution_id="turn-A", + terminal_outcome="stopped", + settled_by="runner", + ) + assert first.won is True + + won = await svc.settle_execution_lost( + project_id=_PROJECT, + session_id=_SESSION, + execution_id="turn-A", + settled_at=datetime.now(timezone.utc), + ) + + assert won is False + assert executions.rows[(_SESSION, "turn-A")].terminal_outcome == "stopped" + + +@pytest.mark.asyncio +async def test_next_sweep_repairs_a_post_commit_redis_failure(lock_engine, monkeypatch): + await _run_turn(lock_engine, "turn-A") + dao = _FakeCommandsDAO() + executions = _FakeExecutionsDAO() + svc = _service( + lock_engine, + dao=dao, + streams=_FakeStreamsService( + _stream("turn-A", datetime.now(timezone.utc) - timedelta(seconds=30)) + ), + executions=executions, + ) + admission = await svc.request_cancel( + project_id=_PROJECT, user_id=_USER, session_id=_SESSION + ) + reconcile = AsyncMock(side_effect=RuntimeError("injected after commit")) + monkeypatch.setattr(commands_service_module, "reconcile_stopped_turn", reconcile) + + with pytest.raises(RuntimeError, match="injected after commit"): + await svc.report_outcome( + command_id=admission.command.id, + replica_id="runner-1", + result="applied", + execution_id="turn-A", + execution_state="stopped", + ) + + assert dao.rows[0].state == SessionCommandState.applied + assert executions.rows[(_SESSION, "turn-A")].redis_reconciled_at is None + + reconcile.side_effect = None + repaired = await _repair_terminal_redis(svc) + + assert repaired == 1 + assert executions.rows[(_SESSION, "turn-A")].redis_reconciled_at is not None + + +@pytest.mark.asyncio +async def test_successful_redis_projection_is_not_offered_for_repair(lock_engine): + await _run_turn(lock_engine, "turn-A") + executions = _FakeExecutionsDAO() + svc = _service( + lock_engine, + streams=_FakeStreamsService( + _stream("turn-A", datetime.now(timezone.utc) - timedelta(seconds=30)) + ), + executions=executions, + ) + admission = await svc.request_cancel( + project_id=_PROJECT, + user_id=_USER, + session_id=_SESSION, + ) + + await svc.report_outcome( + command_id=admission.command.id, + replica_id="runner-1", + result="applied", + execution_id="turn-A", + execution_state="stopped", + ) + + assert executions.rows[(_SESSION, "turn-A")].redis_reconciled_at is not None + assert await svc.repair_terminal_redis() == 0 + + +def _abandoned_command(*, claim_count: int = 1) -> SessionCommand: + return SessionCommand( + id=uuid.uuid7(), + project_id=_PROJECT, + session_id=_SESSION, + kind="cancel", + target_turn_id="turn-A", + state=SessionCommandState.pending, + claim_count=claim_count, + created_at=datetime.now(timezone.utc) - timedelta(minutes=5), + ) + + +@pytest.mark.asyncio +async def test_a_pending_command_is_redelivered_while_the_session_beats(lock_engine): + command = _abandoned_command() + dao = _FakeCommandsDAO() + dao.rows = [command] + dao.abandoned = [command] + delivery = _RecordingDelivery() + svc = _service( + lock_engine, + dao=dao, + streams=_FakeStreamsService(_stream("turn-A", datetime.now(timezone.utc))), + delivery=delivery, + ) + + settled = await svc.settle_abandoned_commands(now=datetime.now(timezone.utc)) + + assert settled == 0 + assert [row.id for row in delivery.delivered] == [command.id] + assert dao.rows[0].claim_count == command.claim_count + 1 + + +@pytest.mark.asyncio +async def test_a_pending_command_is_settled_lost_when_the_runner_is_gone(lock_engine): + command = _abandoned_command() + dao = _FakeCommandsDAO() + dao.rows = [command] + dao.abandoned = [command] + delivery = _RecordingDelivery() + executions = _FakeExecutionsDAO() + svc = _service( + lock_engine, + dao=dao, + streams=_FakeStreamsService( + _stream( + "turn-A", + datetime.now(timezone.utc) - timedelta(minutes=5), + ).model_copy( + update={"updated_at": datetime.now(timezone.utc) - timedelta(minutes=5)} + ) + ), + delivery=delivery, + executions=executions, + ) + + settled = await svc.settle_abandoned_commands(now=datetime.now(timezone.utc)) + + assert settled == 1 + assert delivery.delivered == [] + assert dao.rows[0].state == SessionCommandState.obsolete + assert dao.rows[0].outcome == SessionCommandOutcome.lost + winner = executions.rows[(_SESSION, "turn-A")] + assert winner.terminal_outcome == "lost" + assert winner.settled_by == "watchdog" + + with pytest.raises(SessionCommandNotClaimable): + await svc.report_outcome( + command_id=command.id, + replica_id="runner-1", + result="applied", + execution_id="turn-A", + execution_state="stopped", + ) + + assert dao.rows[0].state == SessionCommandState.obsolete + assert dao.rows[0].outcome == SessionCommandOutcome.lost + assert executions.rows[(_SESSION, "turn-A")] == winner + + +@pytest.mark.asyncio +async def test_redelivery_stops_at_the_configured_maximum(lock_engine, monkeypatch): + maximum = 2 + monkeypatch.setattr(env.agenta.sessions.commands, "max_deliveries", maximum) + command = _abandoned_command(claim_count=maximum) + dao = _FakeCommandsDAO() + dao.rows = [command] + dao.abandoned = [command] + delivery = _RecordingDelivery() + svc = _service( + lock_engine, + dao=dao, + streams=_FakeStreamsService(_stream("turn-A", datetime.now(timezone.utc))), + delivery=delivery, + ) + + settled = await svc.settle_abandoned_commands(now=datetime.now(timezone.utc)) + + assert settled == 1 + assert delivery.delivered == [] + assert dao.rows[0].outcome == SessionCommandOutcome.lost + + +@pytest.mark.asyncio +async def test_a_superseded_report_leaves_the_newer_turns_locks_alone(lock_engine): + await _run_turn(lock_engine, "turn-A") + dao = _FakeCommandsDAO() + interactions = _FakeInteractionsService() + svc = _service( + lock_engine, + dao=dao, + streams=_FakeStreamsService( + _stream("turn-A", datetime.now(timezone.utc) - timedelta(seconds=30)) + ), + interactions=interactions, + ) + + admission = await svc.request_cancel( + project_id=_PROJECT, user_id=_USER, session_id=_SESSION + ) + await svc.report_outcome( + command_id=admission.command.id, + replica_id="runner-1", + result="obsolete", + execution_id="turn-A", + execution_state="superseded_by_newer_turn", + ) + + assert dao.rows[0].outcome == SessionCommandOutcome.superseded_by_newer_turn + # Nothing was stopped, so nothing is released and no gate is cancelled. + assert ( + await get_running_owner( + lock_engine, project_id=str(_PROJECT), session_id=_SESSION + ) + == "turn-A" + ) + assert interactions.cancelled == [] diff --git a/api/oss/tests/pytest/unit/sessions/test_session_cancel_feature_flag.py b/api/oss/tests/pytest/unit/sessions/test_session_cancel_feature_flag.py new file mode 100644 index 00000000000..163794b204a --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_session_cancel_feature_flag.py @@ -0,0 +1,136 @@ +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import UUID + +import pytest +from fastapi import HTTPException + +from oss.src.apis.fastapi.sessions import router as router_module +from oss.src.apis.fastapi.sessions.models import SessionCancelRequest +from oss.src.apis.fastapi.sessions.router import SessionControlRouter +from oss.src.core.sessions.commands.dtos import SessionCommandState +from oss.src.core.sessions.streams.dtos import CommandMode, SessionStreamCommandResponse +from oss.src.utils.env import env +from oss.src.utils.env import _parse_sessions_late_output +from oss.src.utils.env import _parse_sessions_watchdog_stale_heartbeat_seconds + + +_PROJECT = UUID("00000000-0000-0000-0000-0000000000aa") +_USER = UUID("00000000-0000-0000-0000-0000000000bb") + + +def test_unknown_late_output_policy_falls_back_to_quarantine(monkeypatch): + monkeypatch.setenv("AGENTA_SESSIONS_LATE_OUTPUT", "typo") + + with pytest.warns(UserWarning, match="behaving as 'quarantine'"): + value = _parse_sessions_late_output() + + assert value == "quarantine" + + +@pytest.mark.parametrize( + ("durable_stop", "expected"), + [(None, 90), ("", 90), ("false", 300), ("true", 90)], +) +def test_watchdog_default_respects_durable_stop_setting( + monkeypatch, durable_stop, expected +): + if durable_stop is None: + monkeypatch.delenv("AGENTA_SESSIONS_DURABLE_STOP", raising=False) + else: + monkeypatch.setenv("AGENTA_SESSIONS_DURABLE_STOP", durable_stop) + monkeypatch.delenv( + "AGENTA_SESSIONS_WATCHDOG_STALE_HEARTBEAT_SECONDS", raising=False + ) + + assert _parse_sessions_watchdog_stale_heartbeat_seconds() == expected + + +def _request(): + return SimpleNamespace( + state=SimpleNamespace(project_id=_PROJECT, user_id=_USER), + headers={}, + ) + + +async def test_cancel_route_uses_legacy_path_when_durable_stop_is_off(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "durable_stop", False) + monkeypatch.setattr( + router_module, "check_action_access", AsyncMock(return_value=True) + ) + service = SimpleNamespace( + request_cancel_legacy=AsyncMock( + return_value=SessionStreamCommandResponse( + mode=CommandMode.cancel, + session_id="session-1", + turn_id="turn-1", + detached=True, + ) + ), + request_cancel=AsyncMock(), + ) + + response = await SessionControlRouter( + commands_service=service + ).cancel_session_execution( + _request(), + "session-1", + SessionCancelRequest(expected_execution_id="turn-1"), + ) + + service.request_cancel_legacy.assert_awaited_once_with( + project_id=_PROJECT, + user_id=_USER, + session_id="session-1", + expected_execution_id="turn-1", + ) + service.request_cancel.assert_not_awaited() + assert response.status_code == 200 + assert json.loads(response.body) == { + "mode": "cancel", + "session_id": "session-1", + "turn_id": "turn-1", + "watcher_id": None, + "detached": True, + "cancelled_turn_ids": [], + } + + +async def test_cancel_route_uses_durable_path_when_flag_is_on(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + monkeypatch.setattr( + router_module, "check_action_access", AsyncMock(return_value=True) + ) + command = SimpleNamespace( + id=UUID("00000000-0000-0000-0000-0000000000cc"), + state=SessionCommandState.pending, + ) + service = SimpleNamespace( + request_cancel_legacy=AsyncMock(), + request_cancel=AsyncMock( + return_value=SimpleNamespace( + command=command, + execution_id="turn-1", + accepted=True, + ) + ), + ) + + response = await SessionControlRouter( + commands_service=service + ).cancel_session_execution(_request(), "session-1") + + service.request_cancel.assert_awaited_once() + service.request_cancel_legacy.assert_not_awaited() + assert response.status_code == 202 + + +def test_runner_token_rejects_non_ascii_credentials_as_unauthorized(monkeypatch): + monkeypatch.setattr(env.runner, "token", "shared-secret") + request = SimpleNamespace(headers={"X-Agenta-Runner-Token": "nøt-the-token"}) + + with pytest.raises(HTTPException) as exc_info: + router_module._assert_runner_token(request) + + assert exc_info.value.status_code == 401 diff --git a/api/oss/tests/pytest/unit/sessions/test_session_commands_dao.py b/api/oss/tests/pytest/unit/sessions/test_session_commands_dao.py new file mode 100644 index 00000000000..acca6be0006 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_session_commands_dao.py @@ -0,0 +1,861 @@ +"""The compare-and-set rules that make a command safe under concurrency. + +These run against a real Postgres, because what is being tested IS the database's behaviour: +a unique constraint, a partial index's predicate, `FOR UPDATE SKIP LOCKED`, and an `UPDATE ... +WHERE RETURNING *` that must be won by exactly one caller. + +The rule every one of them protects: one execution reaches exactly one terminal outcome, +written by exactly one writer. +""" + +import asyncio +import uuid +from datetime import datetime, timedelta, timezone + +import pytest +from sqlalchemy import text + +from oss.src.core.sessions.commands.dtos import ( + SessionCommandCreate, + SessionCommandKind, + SessionCommandOutcome, + SessionCommandSettle, + SessionCommandState, +) +from oss.src.core.sessions.commands.interfaces import SessionScope +from oss.src.core.sessions.commands.service import SessionCommandsService +from oss.src.core.sessions.interactions.service import SessionInteractionsService +from oss.src.core.sessions.streams.service import SessionStreamsService +from oss.src.dbs.postgres.sessions.commands.dao import SessionCommandsDAO +from oss.src.dbs.postgres.sessions.executions.dao import SessionExecutionsDAO +from oss.src.dbs.postgres.sessions.interactions.dao import SessionInteractionsDAO +from oss.src.dbs.postgres.sessions.streams.dao import SessionStreamsDAO +import oss.src.dbs.postgres.shared.engine as engine_module +from oss.src.dbs.postgres.shared.engine import get_transactions_engine +import oss.src.models.db_models # noqa: F401 + + +pytestmark = pytest.mark.integration + + +@pytest.fixture(autouse=True) +async def _fresh_engine_per_test(): + if engine_module._transactions_engine is not None: + await engine_module._transactions_engine.close() + engine_module._transactions_engine = None + yield + if engine_module._transactions_engine is not None: + await engine_module._transactions_engine.close() + engine_module._transactions_engine = None + + +@pytest.fixture +async def command_scope(): + engine = get_transactions_engine() + user_id = uuid.uuid4() + organization_id = uuid.uuid4() + workspace_id = uuid.uuid4() + project_id = uuid.uuid4() + session_id = f"cmd-dao-{project_id.hex[:12]}" + + async with engine.session() as session: + await session.execute( + text( + "INSERT INTO users (id, uid, username, email) " + "VALUES (:id, :uid, :username, :email)" + ), + { + "id": user_id, + "uid": str(user_id), + "username": "command-dao-test", + "email": f"command-dao-{user_id.hex[:8]}@example.com", + }, + ) + await session.execute( + text( + "INSERT INTO organizations (id, name, owner_id) " + "VALUES (:id, :name, :owner_id)" + ), + { + "id": organization_id, + "name": "command-dao-test-org", + "owner_id": user_id, + }, + ) + await session.execute( + text( + "INSERT INTO workspaces (id, name, organization_id) " + "VALUES (:id, :name, :organization_id)" + ), + { + "id": workspace_id, + "name": "command-dao-test-workspace", + "organization_id": organization_id, + }, + ) + await session.execute( + text( + "INSERT INTO projects " + "(id, project_name, workspace_id, organization_id) " + "VALUES (:id, :project_name, :workspace_id, :organization_id)" + ), + { + "id": project_id, + "project_name": "command-dao-test-project", + "workspace_id": workspace_id, + "organization_id": organization_id, + }, + ) + # The session row the command's `stopping_turn_id` is stamped on. + await session.execute( + text( + "INSERT INTO session_streams (id, project_id, session_id, turn_id) " + "VALUES (:id, :project_id, :session_id, :turn_id)" + ), + { + "id": uuid.uuid4(), + "project_id": project_id, + "session_id": session_id, + "turn_id": "turn-A", + }, + ) + await session.commit() + + yield { + "engine": engine, + "project_id": project_id, + "user_id": user_id, + "session_id": session_id, + } + + +def _create(scope, **overrides) -> SessionCommandCreate: + payload = dict( + project_id=scope["project_id"], + session_id=scope["session_id"], + kind=SessionCommandKind.cancel, + target_turn_id="turn-A", + state=SessionCommandState.pending, + created_at=datetime.now(timezone.utc), + ) + payload.update(overrides) + return SessionCommandCreate(**payload) + + +async def _stopping_turn_id(scope) -> str: + async with scope["engine"].session() as session: + result = await session.execute( + text( + "SELECT stopping_turn_id FROM session_streams " + "WHERE project_id = :project_id AND session_id = :session_id" + ), + {"project_id": scope["project_id"], "session_id": scope["session_id"]}, + ) + return result.scalar() + + +async def test_the_command_and_the_stopping_marker_are_written_together(command_scope): + dao = SessionCommandsDAO(engine=command_scope["engine"]) + + command = await dao.create_command( + user_id=command_scope["user_id"], + command=_create(command_scope), + stopping_turn_id="turn-A", + ) + + assert command.state == SessionCommandState.pending + # A session that renders as plainly running while a command exists to stop it is a session + # nothing later reconciles, so the two writes share one transaction. + assert await _stopping_turn_id(command_scope) == "turn-A" + + +async def test_a_repeated_idempotency_key_returns_the_first_row(command_scope): + dao = SessionCommandsDAO(engine=command_scope["engine"]) + + first = await dao.create_command( + user_id=command_scope["user_id"], + command=_create(command_scope, idempotency_key="retry-me"), + ) + second = await dao.create_command( + user_id=command_scope["user_id"], + command=_create(command_scope, idempotency_key="retry-me"), + ) + + assert second.id == first.id + assert ( + await dao.count_open( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + ) + == 1 + ) + + +async def test_two_open_commands_for_one_execution_collapse_to_one(command_scope): + # Two Stops for the same execution are one intent, even with no idempotency key and even + # when admission's own read cannot see the other because it has not committed yet. The + # database refuses the second insert and the DAO answers with the command that exists. + dao = SessionCommandsDAO(engine=command_scope["engine"]) + + first = await dao.create_command( + user_id=command_scope["user_id"], command=_create(command_scope) + ) + second = await dao.create_command( + user_id=command_scope["user_id"], command=_create(command_scope) + ) + + assert second.id == first.id + assert ( + await dao.count_open( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + ) + == 1 + ) + + +async def test_two_concurrent_admissions_still_yield_one_command(command_scope): + # The race the unique index exists for: both inserts run before either commits. + dao = SessionCommandsDAO(engine=command_scope["engine"]) + + first, second = await asyncio.wait_for( + asyncio.gather( + dao.create_command( + user_id=command_scope["user_id"], command=_create(command_scope) + ), + dao.create_command( + user_id=command_scope["user_id"], command=_create(command_scope) + ), + return_exceptions=True, + ), + timeout=30, + ) + + ids = {r.id for r in (first, second) if not isinstance(r, Exception)} + assert len(ids) == 1, f"expected one command, got {first!r} and {second!r}" + + +async def test_a_settled_command_does_not_block_a_new_one(command_scope): + # The unique index is partial on the OPEN states, so once a Stop has settled the next Stop + # against the same execution is a fresh command, not a constraint violation. + dao = SessionCommandsDAO(engine=command_scope["engine"]) + first = await dao.create_command( + user_id=command_scope["user_id"], command=_create(command_scope) + ) + await dao.settle_command( + settle=SessionCommandSettle( + project_id=command_scope["project_id"], + command_id=first.id, + state=SessionCommandState.applied, + outcome=SessionCommandOutcome.stopped, + expected_states=[SessionCommandState.pending], + replica_id=None, + ) + ) + + second = await dao.create_command( + user_id=command_scope["user_id"], command=_create(command_scope) + ) + + assert second.id != first.id + + +async def test_the_open_command_read_finds_only_the_same_target(command_scope): + dao = SessionCommandsDAO(engine=command_scope["engine"]) + await dao.create_command( + user_id=command_scope["user_id"], + command=_create(command_scope, target_turn_id="turn-A"), + ) + + same = await dao.fetch_open_command( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + kind=SessionCommandKind.cancel, + target_turn_id="turn-A", + ) + other = await dao.fetch_open_command( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + kind=SessionCommandKind.cancel, + target_turn_id="turn-B", + ) + + assert same is not None + assert other is None, "a different execution is a different intent" + + +async def test_a_settled_command_is_no_longer_open(command_scope): + dao = SessionCommandsDAO(engine=command_scope["engine"]) + command = await dao.create_command( + user_id=command_scope["user_id"], + command=_create( + command_scope, + state=SessionCommandState.obsolete, + outcome=SessionCommandOutcome.not_running, + settled_at=datetime.now(timezone.utc), + ), + ) + + assert command.state == SessionCommandState.obsolete + assert ( + await dao.fetch_open_command( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + kind=SessionCommandKind.cancel, + target_turn_id="turn-A", + ) + is None + ) + + +async def test_two_concurrent_claims_of_one_command_yield_exactly_one_winner( + command_scope, +): + dao = SessionCommandsDAO(engine=command_scope["engine"]) + await dao.create_command( + user_id=command_scope["user_id"], command=_create(command_scope) + ) + scopes = [ + SessionScope( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + ) + ] + + # Bounded: both calls contend for the same row on separate pooled connections, so a + # regression that drops SKIP LOCKED would hang the run rather than fail it. + first, second = await asyncio.wait_for( + asyncio.gather( + dao.claim_commands( + sessions=scopes, replica_id="replica-1", lease_seconds=90, limit=10 + ), + dao.claim_commands( + sessions=scopes, replica_id="replica-2", lease_seconds=90, limit=10 + ), + ), + timeout=30, + ) + + assert len(first) + len(second) == 1, ( + "a command is delivered to one replica, not two" + ) + + +async def test_a_claim_ignores_sessions_the_caller_did_not_declare(command_scope): + dao = SessionCommandsDAO(engine=command_scope["engine"]) + await dao.create_command( + user_id=command_scope["user_id"], command=_create(command_scope) + ) + + claimed = await dao.claim_commands( + sessions=[ + SessionScope( + project_id=command_scope["project_id"], session_id="a-different-session" + ) + ], + replica_id="replica-1", + lease_seconds=90, + limit=10, + ) + + assert claimed == [] + + +async def test_the_claim_records_the_lease_and_counts_the_delivery(command_scope): + dao = SessionCommandsDAO(engine=command_scope["engine"]) + command = await dao.create_command( + user_id=command_scope["user_id"], command=_create(command_scope) + ) + attempted = await dao.record_delivery_attempt( + project_id=command_scope["project_id"], + command_id=command.id, + now=datetime.now(timezone.utc), + max_deliveries=3, + ) + assert attempted is not None + + claimed = await dao.claim_for_delivery( + project_id=command_scope["project_id"], + command_id=command.id, + replica_id="replica-1", + lease_seconds=90, + ) + + assert claimed is not None + assert claimed.state == SessionCommandState.claimed + assert claimed.claimed_by == "replica-1" + assert claimed.claim_count == 1 + assert claimed.claim_expires_at is not None + assert claimed.claim_expires_at > datetime.now(timezone.utc) + timedelta(seconds=60) + + +async def test_a_second_delivery_claim_finds_nothing_to_take(command_scope): + dao = SessionCommandsDAO(engine=command_scope["engine"]) + command = await dao.create_command( + user_id=command_scope["user_id"], command=_create(command_scope) + ) + await dao.claim_for_delivery( + project_id=command_scope["project_id"], + command_id=command.id, + replica_id="replica-1", + lease_seconds=90, + ) + + again = await dao.claim_for_delivery( + project_id=command_scope["project_id"], + command_id=command.id, + replica_id="replica-2", + lease_seconds=90, + ) + + assert again is None + + +async def test_only_the_replica_holding_the_claim_may_settle(command_scope): + dao = SessionCommandsDAO(engine=command_scope["engine"]) + command = await dao.create_command( + user_id=command_scope["user_id"], command=_create(command_scope) + ) + await dao.claim_for_delivery( + project_id=command_scope["project_id"], + command_id=command.id, + replica_id="replica-1", + lease_seconds=90, + ) + + wrong = await dao.settle_command( + settle=SessionCommandSettle( + project_id=command_scope["project_id"], + command_id=command.id, + state=SessionCommandState.applied, + outcome=SessionCommandOutcome.stopped, + replica_id="replica-2", + ) + ) + + assert wrong is None + stored = await dao.fetch_command(command_id=command.id) + assert stored.state == SessionCommandState.claimed, "the stored state is unchanged" + + +async def test_settling_an_already_terminal_command_changes_nothing(command_scope): + dao = SessionCommandsDAO(engine=command_scope["engine"]) + command = await dao.create_command( + user_id=command_scope["user_id"], command=_create(command_scope) + ) + await dao.claim_for_delivery( + project_id=command_scope["project_id"], + command_id=command.id, + replica_id="replica-1", + lease_seconds=90, + ) + settled = await dao.settle_command( + settle=SessionCommandSettle( + project_id=command_scope["project_id"], + command_id=command.id, + state=SessionCommandState.applied, + outcome=SessionCommandOutcome.stopped, + replica_id="replica-1", + ) + ) + assert settled is not None + + repeat = await dao.settle_command( + settle=SessionCommandSettle( + project_id=command_scope["project_id"], + command_id=command.id, + state=SessionCommandState.obsolete, + outcome=SessionCommandOutcome.failed, + replica_id="replica-1", + ) + ) + + assert repeat is None, "one execution, one terminal outcome, one writer" + stored = await dao.fetch_command(command_id=command.id) + assert stored.outcome == SessionCommandOutcome.stopped + + +async def test_the_api_can_settle_a_pending_command_nobody_took(command_scope): + # The `not_held` case: a reachable runner said it does not hold the session, so there is no + # claim to guard on and the API settles it itself. + dao = SessionCommandsDAO(engine=command_scope["engine"]) + command = await dao.create_command( + user_id=command_scope["user_id"], command=_create(command_scope) + ) + + settled = await dao.settle_command( + settle=SessionCommandSettle( + project_id=command_scope["project_id"], + command_id=command.id, + state=SessionCommandState.obsolete, + outcome=SessionCommandOutcome.not_running, + expected_states=[SessionCommandState.pending], + replica_id=None, + ) + ) + + assert settled is not None + assert settled.outcome == SessionCommandOutcome.not_running + + +async def test_the_runner_can_find_a_command_without_a_project_id(command_scope): + # The runner reports an outcome with the command id alone; it holds no project credential. + dao = SessionCommandsDAO(engine=command_scope["engine"]) + command = await dao.create_command( + user_id=command_scope["user_id"], command=_create(command_scope) + ) + + found = await dao.fetch_command(command_id=command.id) + + assert found is not None + assert found.project_id == command_scope["project_id"] + + +async def test_clearing_the_stopping_marker_is_scoped_to_the_turn_it_set(command_scope): + dao = SessionCommandsDAO(engine=command_scope["engine"]) + await dao.create_command( + user_id=command_scope["user_id"], + command=_create(command_scope), + stopping_turn_id="turn-A", + ) + + # A settlement for an OLDER turn must not clear a newer Stop's marker. + await dao.clear_stopping_turn( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + turn_id="turn-older", + ) + assert await _stopping_turn_id(command_scope) == "turn-A" + + await dao.clear_stopping_turn( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + turn_id="turn-A", + ) + assert await _stopping_turn_id(command_scope) is None + + +async def test_expire_claims_returns_only_leases_that_have_passed(command_scope): + dao = SessionCommandsDAO(engine=command_scope["engine"]) + fresh = await dao.create_command( + user_id=command_scope["user_id"], command=_create(command_scope) + ) + await dao.claim_for_delivery( + project_id=command_scope["project_id"], + command_id=fresh.id, + replica_id="replica-1", + lease_seconds=90, + ) + + # The sweep is deliberately NOT project-scoped: it settles every abandoned claim in the + # deployment, so assert on this command's presence rather than on the whole result. + now = datetime.now(timezone.utc) + assert fresh.id not in { + row.id for row in await dao.expire_claims(now=now, max_deliveries=3) + }, "a lease that has not passed is not swept" + # An hour later the same lease has passed, and the settlement sweep sees it. + later = await dao.expire_claims(now=now + timedelta(hours=1), max_deliveries=3) + assert fresh.id in {row.id for row in later} + + +async def test_old_pending_commands_are_returned_for_redelivery(command_scope): + dao = SessionCommandsDAO(engine=command_scope["engine"]) + now = datetime.now(timezone.utc) + command = await dao.create_command( + user_id=command_scope["user_id"], + command=_create(command_scope, created_at=now - timedelta(minutes=5)), + ) + + rows = await dao.expire_claims( + now=now, + max_deliveries=3, + pending_before=now - timedelta(seconds=90), + ) + + assert command.id in {row.id for row in rows} + + +async def test_delivery_attempts_are_bounded_in_the_database(command_scope): + dao = SessionCommandsDAO(engine=command_scope["engine"]) + command = await dao.create_command( + user_id=command_scope["user_id"], command=_create(command_scope) + ) + now = datetime.now(timezone.utc) + + first = await dao.record_delivery_attempt( + project_id=command_scope["project_id"], + command_id=command.id, + now=now, + max_deliveries=1, + ) + second = await dao.record_delivery_attempt( + project_id=command_scope["project_id"], + command_id=command.id, + now=now + timedelta(seconds=1), + max_deliveries=1, + ) + + assert first is not None + assert first.claim_count == 1 + assert second is None + + +async def test_runner_and_watchdog_have_one_terminal_winner(command_scope): + dao = SessionExecutionsDAO(engine=command_scope["engine"]) + + runner, watchdog = await asyncio.gather( + dao.settle( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id="turn-A", + terminal_outcome="stopped", + settled_by="runner", + ), + dao.settle( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id="turn-A", + terminal_outcome="lost", + settled_by="watchdog", + ), + ) + + assert sum(result.won for result in (runner, watchdog)) == 1 + assert runner.settlement == watchdog.settlement + + +async def test_repeating_the_same_execution_settlement_reports_only_the_insert_as_winner( + command_scope, +): + dao = SessionExecutionsDAO(engine=command_scope["engine"]) + + first = await dao.settle( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id="turn-A", + terminal_outcome="lost", + settled_by="watchdog", + ) + repeated = await dao.settle( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id="turn-A", + terminal_outcome="lost", + settled_by="watchdog", + ) + + assert first.won is True + assert repeated.won is False + assert repeated.settlement == first.settlement + + +async def test_execution_ending_marker_is_one_way(command_scope): + dao = SessionExecutionsDAO(engine=command_scope["engine"]) + await dao.settle( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id="turn-A", + terminal_outcome="stopped", + settled_by="runner", + ) + written_at = datetime.now(timezone.utc) + + await dao.mark_endings_written( + project_id=command_scope["project_id"], + keys=[(command_scope["session_id"], "turn-A")], + written_at=written_at, + ) + await dao.mark_endings_written( + project_id=command_scope["project_id"], + keys=[(command_scope["session_id"], "turn-A")], + written_at=written_at + timedelta(seconds=1), + ) + + stored = await dao.query_settled( + project_id=command_scope["project_id"], + keys=[(command_scope["session_id"], "turn-A")], + ) + assert ( + stored[(command_scope["session_id"], "turn-A")].ending_written_at == written_at + ) + + +async def test_terminal_core_facts_commit_in_one_transaction(command_scope): + commands = SessionCommandsDAO(engine=command_scope["engine"]) + executions = SessionExecutionsDAO(engine=command_scope["engine"]) + streams = SessionStreamsDAO(engine=command_scope["engine"]) + interactions = SessionInteractionsDAO(engine=command_scope["engine"]) + command = await commands.create_command( + user_id=command_scope["user_id"], + command=_create(command_scope), + stopping_turn_id="turn-A", + ) + await commands.record_delivery_attempt( + project_id=command_scope["project_id"], + command_id=command.id, + now=datetime.now(timezone.utc), + max_deliveries=3, + ) + await commands.claim_for_delivery( + project_id=command_scope["project_id"], + command_id=command.id, + replica_id="runner-1", + lease_seconds=90, + ) + interaction_id = uuid.uuid4() + async with command_scope["engine"].session() as session: + await session.execute( + text( + "UPDATE session_streams SET flags = " + '\'{"is_alive": true, "is_running": true, ' + '"is_attached": true}\'::jsonb ' + "WHERE project_id = :project_id AND session_id = :session_id" + ), + { + "project_id": command_scope["project_id"], + "session_id": command_scope["session_id"], + }, + ) + await session.execute( + text( + "INSERT INTO session_interactions " + "(project_id, id, session_id, turn_id, token, kind, status) " + "VALUES (:project_id, :id, :session_id, 'turn-A', " + "'token-A', 'user_approval', 'pending')" + ), + { + "project_id": command_scope["project_id"], + "id": interaction_id, + "session_id": command_scope["session_id"], + }, + ) + + transition = SessionCommandSettle( + project_id=command_scope["project_id"], + command_id=command.id, + state=SessionCommandState.applied, + outcome=SessionCommandOutcome.stopped, + expected_states=[SessionCommandState.claimed], + replica_id="runner-1", + ) + async with commands.transaction() as transaction: + settled = await commands.settle_command( + settle=transition, + transaction=transaction, + ) + execution = await executions.settle( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id="turn-A", + terminal_outcome="stopped", + settled_by="runner", + transaction=transaction, + ) + await streams.settle_command( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + turn_id="turn-A", + mirror_stopped=True, + transaction=transaction, + ) + await interactions.cancel_session_pending( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + only_turn_id="turn-A", + transaction=transaction, + ) + + assert settled is not None + assert execution.won is True + async with command_scope["engine"].session() as session: + row = ( + await session.execute( + text( + "SELECT c.state, c.outcome, s.stopping_turn_id, " + "s.flags->>'is_running', s.flags->>'is_attached', i.status, " + "e.terminal_outcome " + "FROM session_commands c " + "JOIN session_streams s ON s.project_id = c.project_id " + "AND s.session_id = c.session_id " + "JOIN session_interactions i ON i.project_id = c.project_id " + "AND i.session_id = c.session_id " + "JOIN session_executions e ON e.project_id = c.project_id " + "AND e.session_id = c.session_id " + "AND e.execution_id = c.target_turn_id " + "WHERE c.project_id = :project_id AND c.id = :command_id" + ), + { + "project_id": command_scope["project_id"], + "command_id": command.id, + }, + ) + ).one() + assert tuple(row) == ( + "applied", + "stopped", + None, + "false", + "true", + "cancelled", + "stopped", + ) + + +async def test_execution_conflict_rolls_back_the_command_transition(command_scope): + commands = SessionCommandsDAO(engine=command_scope["engine"]) + executions = SessionExecutionsDAO(engine=command_scope["engine"]) + streams = SessionStreamsDAO(engine=command_scope["engine"]) + interactions = SessionInteractionsDAO(engine=command_scope["engine"]) + command = await commands.create_command( + user_id=command_scope["user_id"], + command=_create(command_scope), + stopping_turn_id="turn-A", + ) + await commands.record_delivery_attempt( + project_id=command_scope["project_id"], + command_id=command.id, + now=datetime.now(timezone.utc), + max_deliveries=3, + ) + await commands.claim_for_delivery( + project_id=command_scope["project_id"], + command_id=command.id, + replica_id="runner-1", + lease_seconds=90, + ) + await executions.settle( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id="turn-A", + terminal_outcome="lost", + settled_by="watchdog", + ) + + service = SessionCommandsService( + commands_dao=commands, + streams_service=SessionStreamsService( + streams_dao=streams, + lock_engine=None, + ), + interactions_service=SessionInteractionsService( + interactions_dao=interactions, + ), + lock_engine=None, + delivery=None, + executions_dao=executions, + ) + settled = await service.settle( + command_id=command.id, + project_id=command_scope["project_id"], + replica_id="runner-1", + expected_states=[SessionCommandState.claimed], + state=SessionCommandState.applied, + outcome=SessionCommandOutcome.stopped, + execution_id="turn-A", + ) + + assert settled is None + stored = await commands.fetch_command(command_id=command.id) + assert stored is not None + assert stored.state == SessionCommandState.claimed + assert stored.outcome is None diff --git a/api/oss/tests/pytest/unit/sessions/test_session_snapshot.py b/api/oss/tests/pytest/unit/sessions/test_session_snapshot.py new file mode 100644 index 00000000000..3e10f1ee8cb --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_session_snapshot.py @@ -0,0 +1,110 @@ +from unittest.mock import AsyncMock, patch +from uuid import uuid4 + +import pytest +from fastapi import FastAPI, Request + +from oss.src.apis.fastapi.sessions.router import SessionsRootRouter +from oss.src.core.sessions.records.dtos import SessionRecordsReadState +from oss.src.core.sessions.streams.dtos import SessionStream +from oss.src.utils.env import env + + +def _request(project_id, user_id) -> Request: + request = Request( + { + "type": "http", + "method": "GET", + "path": "/sessions/session-1", + "headers": [], + "app": FastAPI(), + } + ) + request.state.project_id = str(project_id) + request.state.user_id = str(user_id) + return request + + +@pytest.mark.asyncio +async def test_snapshot_groups_session_execution_pending_and_read_watermark(): + project_id = uuid4() + stream = SessionStream( + id=uuid4(), project_id=project_id, session_id="session-1", name="Session" + ) + streams = AsyncMock() + streams.fetch.return_value = stream + records = AsyncMock() + records.get_read_state.return_value = SessionRecordsReadState( + latest_sequence=7, + history_complete=True, + ) + interactions = AsyncMock() + interactions.query_interactions.return_value = [] + turns = AsyncMock() + turns.latest_turn.return_value = None + router = SessionsRootRouter( + sessions_service=AsyncMock(), + streams_service=streams, + records_service=records, + interactions_service=interactions, + turns_service=turns, + ) + + with ( + patch.object(env.sessions, "shared_reader", True), + patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ), + ): + snapshot = await router.get_session_snapshot( + request=_request(project_id, uuid4()), + session_id="session-1", + ) + + assert snapshot.session.session_id == "session-1" + assert snapshot.execution is None + assert snapshot.pending.inputs == [] + assert snapshot.pending.interactions == [] + assert snapshot.read.latest_sequence == 7 + assert snapshot.read.history_complete is True + + +@pytest.mark.asyncio +async def test_snapshot_forces_incomplete_when_stream_marker_is_present(): + project_id = uuid4() + stream = SessionStream(id=uuid4(), project_id=project_id, session_id="session-1") + object.__setattr__(stream, "history_incomplete", True) + streams = AsyncMock() + streams.fetch.return_value = stream + records = AsyncMock() + records.get_read_state.return_value = SessionRecordsReadState( + latest_sequence=2, + history_complete=True, + ) + interactions = AsyncMock() + interactions.query_interactions.return_value = [] + turns = AsyncMock() + turns.latest_turn.return_value = None + router = SessionsRootRouter( + sessions_service=AsyncMock(), + streams_service=streams, + records_service=records, + interactions_service=interactions, + turns_service=turns, + ) + + with ( + patch.object(env.sessions, "shared_reader", True), + patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ), + ): + snapshot = await router.get_session_snapshot( + request=_request(project_id, uuid4()), session_id="session-1" + ) + + assert snapshot.read.history_complete is False diff --git a/api/oss/tests/pytest/unit/sessions/test_watch_interactions_publish.py b/api/oss/tests/pytest/unit/sessions/test_watch_interactions_publish.py index 9835cb98452..790190c38a1 100644 --- a/api/oss/tests/pytest/unit/sessions/test_watch_interactions_publish.py +++ b/api/oss/tests/pytest/unit/sessions/test_watch_interactions_publish.py @@ -115,7 +115,12 @@ async def test_failed_transition_publishes_nothing(): @pytest.mark.asyncio async def test_cancel_sweep_publishes_resolved_only_when_it_cancelled(): dao = AsyncMock() - dao.cancel_session_pending = AsyncMock(return_value=2) + dao.cancel_session_pending = AsyncMock( + return_value=[ + _interaction("sess-1"), + _interaction("sess-1").model_copy(update={"token": "tok-2"}), + ] + ) svc, publisher = _service(dao) cancelled = await svc.cancel_session_pending( @@ -125,7 +130,7 @@ async def test_cancel_sweep_publishes_resolved_only_when_it_cancelled(): assert publisher.interaction_calls == [(str(_PROJECT), "sess-1", "resolved")] # No-op sweep: nothing was pending, nothing changed, nothing to notify. - dao.cancel_session_pending = AsyncMock(return_value=0) + dao.cancel_session_pending = AsyncMock(return_value=[]) publisher.interaction_calls.clear() await svc.cancel_session_pending(project_id=_PROJECT, session_id="sess-1") assert publisher.interaction_calls == [] diff --git a/api/oss/tests/pytest/unit/sessions/test_watch_lifecycle_publish.py b/api/oss/tests/pytest/unit/sessions/test_watch_lifecycle_publish.py index 1859c655f30..00603bf3464 100644 --- a/api/oss/tests/pytest/unit/sessions/test_watch_lifecycle_publish.py +++ b/api/oss/tests/pytest/unit/sessions/test_watch_lifecycle_publish.py @@ -139,6 +139,16 @@ async def test_cancel_publishes_lifecycle_ended(lock_engine): svc, publisher = _service(lock_engine) session_id = _session_id() + await svc.command( + project_id=_PROJECT, + user_id=_USER, + request=SessionStreamCommandRequest( + session_id=session_id, + data=WorkflowServiceRequestData(inputs={"messages": ["hi"]}), + ), + ) + publisher.lifecycle_calls.clear() + await svc.command( project_id=_PROJECT, user_id=_USER, diff --git a/api/oss/tests/pytest/unit/sessions/test_watch_publish.py b/api/oss/tests/pytest/unit/sessions/test_watch_publish.py index ff0de2f57bd..31717408ae0 100644 --- a/api/oss/tests/pytest/unit/sessions/test_watch_publish.py +++ b/api/oss/tests/pytest/unit/sessions/test_watch_publish.py @@ -136,11 +136,11 @@ async def test_worker_skips_publish_when_append_fails(): assert total_appended == 0 assert publisher.calls == [] - # `process_batch` acknowledges at parse time, before the append, so a failed append is still - # acked and dropped by the shared consumer loop. That predates this change and is shared by - # every worker on `BaseStreamConsumer`; the relay tee neither causes it nor repairs it. This - # assertion pins the tee's scope, not an endorsement of the acknowledgement rule. - assert len(processed_ids) == 1 + # A failed append acknowledges nothing, so the record stays in the Redis pending list and + # the reclaim pass writes it later. `process_batch` used to acknowledge at parse time, + # before the append, which made every Postgres failure permanent record loss (#5496). + # `test_records_worker_durability.py` pins that rule; this line pins the tee's scope. + assert processed_ids == [] @pytest.mark.asyncio diff --git a/api/oss/tests/pytest/unit/sessions/test_watch_session_list_publish.py b/api/oss/tests/pytest/unit/sessions/test_watch_session_list_publish.py new file mode 100644 index 00000000000..0ac118c8e42 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_watch_session_list_publish.py @@ -0,0 +1,258 @@ +"""M3 live relay — the project-channel events a session LIST revalidates on. + +`session-changed` on the project channel is the only signal an open session list gets: the +lists are cached with a stale time and no refetch interval, and `lifecycle` rides the +per-session channel, which no list subscribes to. So every transition that changes WHICH rows +a list shows has to publish here, or that list stays wrong until the tab reloads. + +The transitions: the row is created (a session sent from another tab, or minted by the +runner's first beat), it is archived or hard-deleted (leaves the list), and it is unarchived +or re-nested from a killed tombstone (returns to it). + +`lifecycle` deliberately stays off this channel. It fires twice per TURN, and a project-wide +invalidation at that rate would refetch every open list on every turn boundary. +""" + +from typing import Optional +from unittest.mock import AsyncMock, patch +from uuid import UUID, uuid4 + +import pytest +import pytest_asyncio + +from agenta.sdk.models.workflows import WorkflowServiceRequestData + +from oss.src.core.sessions.streams.dtos import ( + SessionHeartbeatRequest, + SessionStream, + SessionStreamCommandRequest, +) +from oss.src.core.sessions.streams.service import SessionStreamsService + +from unit.sessions.test_project_scoped_locks import _FakeRedis + + +_PROJECT = uuid4() +_USER = uuid4() + + +class _RecordingPublisher: + """Records both families so a test can assert one fired and the other did not.""" + + def __init__(self, *, fail: bool = False): + self.changed_calls: list[tuple[str, str, str]] = [] + self.lifecycle_calls: list[tuple[str, str, str]] = [] + self.fail = fail + + async def lifecycle(self, *, project_id: str, session_id: str, state: str) -> None: + self.lifecycle_calls.append((project_id, session_id, state)) + + async def changed(self, *, project_id: str, entity: str, id: str) -> None: + if self.fail: + raise RuntimeError("relay down") + self.changed_calls.append((project_id, entity, id)) + + +class _FakeStreamsDAO: + def __init__(self, existing: Optional[SessionStream] = None): + self.row = existing + + async def get_by_session_id(self, *, project_id: UUID, session_id: str): + return self.row + + async def create(self, *, project_id, user_id, stream): + self.row = SessionStream( + id=uuid4(), + project_id=project_id, + session_id=stream.session_id, + flags=stream.flags, + turn_id=stream.turn_id, + ) + return self.row + + async def update(self, *, project_id, user_id, session_id, stream): + prior = self.row + self.row = SessionStream( + id=prior.id if prior else uuid4(), + project_id=project_id, + session_id=session_id, + flags=stream.flags + if stream.flags is not None + else (prior.flags if prior else None), + turn_id=stream.turn_id + if stream.turn_id is not None + else (prior.turn_id if prior else None), + ) + return self.row + + async def delete_by_session_id(self, *, project_id, session_id): + return True + + +@pytest_asyncio.fixture +async def lock_engine(): + from oss.src.dbs.redis.shared.engine import LockEngine + + eng = LockEngine() + with patch.object(eng, "_client", return_value=_FakeRedis()): + yield eng + + +def _service(lock_engine, *, dao=None, fail=False): + publisher = _RecordingPublisher(fail=fail) + service = SessionStreamsService( + streams_dao=dao if dao is not None else _FakeStreamsDAO(), + lock_engine=lock_engine, + watch_publisher=publisher, + ) + return service, publisher + + +def _session_id() -> str: + return f"session_{uuid4().hex[:12]}" + + +async def _send(svc, session_id: str, *, force: bool = False): + return await svc.command( + project_id=_PROJECT, + user_id=_USER, + request=SessionStreamCommandRequest( + session_id=session_id, + data=WorkflowServiceRequestData(inputs={"messages": ["hi"]}), + force=force, + ), + ) + + +@pytest.mark.asyncio +async def test_send_on_a_new_session_publishes_session_changed(lock_engine): + """The F11 case: a session started in one tab never reached another tab's open list.""" + svc, publisher = _service(lock_engine) + session_id = _session_id() + + await _send(svc, session_id) + + assert publisher.changed_calls == [(str(_PROJECT), "session", session_id)] + + +@pytest.mark.asyncio +async def test_further_turns_on_the_same_session_do_not_republish(lock_engine): + """Row membership only changes once. A per-turn publish would invalidate every open list + on every send.""" + svc, publisher = _service(lock_engine) + session_id = _session_id() + + await _send(svc, session_id) + # The turn from the first send is still alive, so further turns are steers. + await _send(svc, session_id, force=True) + await _send(svc, session_id, force=True) + + assert publisher.changed_calls == [(str(_PROJECT), "session", session_id)] + # The turn events still fire each time; they just stay off the project channel. + assert len(publisher.lifecycle_calls) > 1 + + +@pytest.mark.asyncio +async def test_runner_first_heartbeat_publishes_session_changed(lock_engine): + """A trigger/cron session is minted by the runner's beat, never by `_start_turn`.""" + svc, publisher = _service(lock_engine) + session_id = _session_id() + + await svc.heartbeat( + project_id=_PROJECT, + request=SessionHeartbeatRequest( + session_id=session_id, + replica_id="replica-a", + turn_id="turn-1", + is_running=True, + ), + ) + + assert publisher.changed_calls == [(str(_PROJECT), "session", session_id)] + + +@pytest.mark.asyncio +async def test_subsequent_heartbeats_do_not_republish(lock_engine): + """The runner beats every 30s per session; only the first one creates the row.""" + svc, publisher = _service(lock_engine) + session_id = _session_id() + + for _ in range(3): + await svc.heartbeat( + project_id=_PROJECT, + request=SessionHeartbeatRequest( + session_id=session_id, + replica_id="replica-a", + turn_id="turn-1", + is_running=True, + ), + ) + + assert publisher.changed_calls == [(str(_PROJECT), "session", session_id)] + + +@pytest.mark.asyncio +async def test_archive_publishes_session_changed(lock_engine): + dao = AsyncMock() + dao.set_archived_by_session_id.return_value = object() + svc, publisher = _service(lock_engine, dao=dao) + + await svc.archive(project_id=_PROJECT, user_id=_USER, session_id="session-1") + + assert publisher.changed_calls == [(str(_PROJECT), "session", "session-1")] + + +@pytest.mark.asyncio +async def test_archive_that_matched_nothing_does_not_publish(lock_engine): + dao = AsyncMock() + dao.set_archived_by_session_id.return_value = None + svc, publisher = _service(lock_engine, dao=dao) + + await svc.archive(project_id=_PROJECT, user_id=_USER, session_id="session-1") + + assert publisher.changed_calls == [] + + +@pytest.mark.asyncio +async def test_unarchive_publishes_session_changed(lock_engine): + dao = AsyncMock() + dao.clear_archived_by_session_id.return_value = object() + svc, publisher = _service(lock_engine, dao=dao) + + await svc.unarchive(project_id=_PROJECT, user_id=_USER, session_id="session-1") + + assert publisher.changed_calls == [(str(_PROJECT), "session", "session-1")] + + +@pytest.mark.asyncio +async def test_hard_delete_publishes_session_changed(lock_engine): + dao = AsyncMock() + dao.hard_delete_by_session_id.return_value = True + svc, publisher = _service(lock_engine, dao=dao) + + await svc.hard_delete(project_id=_PROJECT, session_id="session-1") + + assert publisher.changed_calls == [(str(_PROJECT), "session", "session-1")] + + +@pytest.mark.asyncio +async def test_hard_delete_that_matched_nothing_does_not_publish(lock_engine): + dao = AsyncMock() + dao.hard_delete_by_session_id.return_value = False + svc, publisher = _service(lock_engine, dao=dao) + + await svc.hard_delete(project_id=_PROJECT, session_id="session-1") + + assert publisher.changed_calls == [] + + +@pytest.mark.asyncio +async def test_a_broken_relay_never_fails_the_write(lock_engine): + """Same contract the other publish points hold: the DB write is already committed.""" + svc, publisher = _service(lock_engine, fail=True) + session_id = _session_id() + + result = await _send(svc, session_id) + + assert result is not None + assert publisher.changed_calls == [] diff --git a/api/oss/tests/pytest/unit/sessions/test_watchdog_collapse_persistence.py b/api/oss/tests/pytest/unit/sessions/test_watchdog_collapse_persistence.py new file mode 100644 index 00000000000..ea48e847019 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_watchdog_collapse_persistence.py @@ -0,0 +1,588 @@ +"""The watchdog's collapse must PERSIST against a real Postgres, in the same pass that +settles the command. + +Finding 7 (run 2d, session 6721d762): the sweep logged the collapse, but the row still read +is_running true afterwards with no other writer. Cause: the collapse mutated ORM row objects +(`row.flags = ...`), but those objects had been detached from the task-scoped session by the +nested `engine.session()` calls the pass makes (the records lookup, the command settlement) -- +each opens the SAME current-task-scoped session and closes it in its `finally`. A detached +object's mutation is tracked by no session, so `session.commit()` never emits the flags +UPDATE, while the command settle's Core UPDATE (stopping_turn_id) still lands. A unit test +with fakes cannot catch this: it needs the real async_scoped_session + close semantics, so +this test drives a real Postgres. + +It replays the real pass end to end with the real DAOs on a FRESH, isolated database (created +per test on the same server, dropped after), so the global sweep sees only the seeded row and +nothing is polluted. It seeds one alive+running stream naming a turn, one pending Stop for it, +and a stale heartbeat; runs one real sweep pass; then reads the row back through a fresh +session and asserts the collapse persisted, the execution was settled lost, and the command +went obsolete/lost. + +Only the SERVER in POSTGRES_URI_CORE is used. The database named in that URI is never written: +the fixture creates its own and drops it. Point it at any reachable core Postgres, for example + cd api && POSTGRES_URI_CORE=postgresql+asyncpg://username:password@localhost:5432/agenta_oss_core \ + uv run --no-sync pytest oss/tests/pytest/unit/sessions/test_watchdog_collapse_persistence.py -q +""" + +import asyncio +import uuid +from datetime import datetime, timezone, timedelta +from urllib.parse import urlparse, urlunparse + +import asyncpg +import pytest +from sqlalchemy import event, text +from sqlalchemy.ext.asyncio import create_async_engine + +import oss.src.models.db_models # noqa: F401 (register auth/org tables on Base) + +# Register the session tables on the shared Base so create_all builds them. +from oss.src.dbs.postgres.sessions.streams.dbes import SessionStreamDBE # noqa: F401 +from oss.src.dbs.postgres.sessions.executions.dbes import SessionExecutionDBE # noqa: F401 +from oss.src.dbs.postgres.sessions.commands.dbes import SessionCommandDBE # noqa: F401 +from oss.src.dbs.postgres.sessions.records.dbes import RecordDBE # noqa: F401 +from oss.src.dbs.postgres.sessions.interactions.dbes import ( # noqa: F401 + SessionInteractionDBE, +) +from oss.src.dbs.postgres.shared.base import Base + +from oss.src.core.sessions.streams.dtos import ( + SessionStreamEdit, + SessionStreamFlags, +) +from oss.src.utils.env import env +from oss.src.dbs.postgres.shared.engine import TransactionsEngine +from oss.src.dbs.postgres.sessions.streams.dao import SessionStreamsDAO +from oss.src.dbs.postgres.sessions.commands.dao import SessionCommandsDAO +from oss.src.dbs.postgres.sessions.executions.dao import SessionExecutionsDAO +from oss.src.dbs.postgres.sessions.interactions.dao import SessionInteractionsDAO +from oss.src.dbs.postgres.sessions.records.dao import RecordsDAO +from oss.src.core.sessions.streams.service import SessionStreamsService +from oss.src.core.sessions.interactions.service import SessionInteractionsService +from oss.src.core.sessions.commands.service import SessionCommandsService +from oss.src.core.sessions.records.service import RecordsService +from oss.src.dbs.http.sessions.control_delivery_direct import DirectControlDelivery +from oss.src.tasks.asyncio.sessions import orphan_sweep + +pytestmark = pytest.mark.integration + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +class _FakeLock: + """In-memory Redis stand-in — the DB persistence is what this test is about.""" + + def __init__(self): + self._s = {} + + async def get(self, k): + return self._s.get(k) + + async def set(self, k, v, nx=False, ex=None): + if nx and k in self._s: + return None + self._s[k] = v + return True + + async def delete(self, k): + self._s.pop(k, None) + return 1 + + async def expire(self, k, ttl): + return True + + async def eval(self, script, numkeys, *keys_and_args): + def decode(value): + return value.decode() if isinstance(value, bytes) else str(value) + + keys = [decode(value) for value in keys_and_args[:numkeys]] + argv = [decode(value) for value in keys_and_args[numkeys:]] + if "AGENTA_WATCHDOG_RELEASE_TURN" in script: + alive, running, owner, superseded = keys + expected_turn, expected_owner, _ttl = argv + alive_value = decode(self._s[alive]) if alive in self._s else "" + running_value = decode(self._s[running]) if running in self._s else "" + owner_value = decode(self._s[owner]) if owner in self._s else "" + released_alive = int(bool(expected_turn) and alive_value == expected_turn) + released_running = int( + bool(expected_turn) and running_value == expected_turn + ) + if released_alive: + self._s.pop(alive, None) + if released_running: + self._s.pop(running, None) + foreign_turn = (alive_value and alive_value != expected_turn) or ( + running_value and running_value != expected_turn + ) + released_owner = int( + bool(expected_owner) + and owner_value == expected_owner + and not foreign_turn + ) + if released_owner: + self._s.pop(owner, None) + if expected_turn: + self._s[superseded] = b"1" + return [released_alive, released_running, released_owner] + + k = keys[0] + v = argv[0] + cur = self._s.get(k) + if isinstance(cur, bytes): + cur = cur.decode() + if len(argv) > 1: + from oss.src.dbs.redis.sessions.contract import owner_replica_id + + if cur is None or owner_replica_id(cur) == owner_replica_id(v): + self._s[k] = v.encode() + return v.encode() + return cur.encode() if cur else None + if cur == v: + self._s.pop(k, None) + return 1 + return 0 + + +async def _noop_publish(*, project_id, record_event): + return False + + +def _admin_dsn() -> str: + parsed = urlparse(env.postgres.uri_core) + # asyncpg DSN (no +asyncpg driver tag), connect to the maintenance db. + return urlunparse(("postgresql", parsed.netloc, "/postgres", "", "", "")) + + +def _sqlalchemy_url_for(db_name: str) -> str: + parsed = urlparse(env.postgres.uri_core) + return urlunparse(("postgresql+asyncpg", parsed.netloc, f"/{db_name}", "", "", "")) + + +@pytest.fixture +async def wd_engine(monkeypatch): + """A TransactionsEngine bound to a fresh, isolated database with the full schema.""" + db_name = f"agenta_wd_rca_{uuid.uuid4().hex[:12]}" + admin = await asyncpg.connect(dsn=_admin_dsn()) + await admin.execute(f'CREATE DATABASE "{db_name}"') + await admin.close() + + seed = await asyncpg.connect(dsn=_admin_dsn().replace("/postgres", f"/{db_name}")) + for ext in ("pgcrypto", "ltree"): + await seed.execute(f'CREATE EXTENSION IF NOT EXISTS "{ext}"') + await seed.close() + + # Only the tables this pass touches; the full metadata carries unrelated tables with + # foreign keys to modules we do not import here. + needed = [ + Base.metadata.tables[name] + for name in ( + "users", + "organizations", + "workspaces", + "projects", + "session_streams", + "session_executions", + "session_commands", + "records", + "session_interactions", + ) + ] + schema_engine = create_async_engine(_sqlalchemy_url_for(db_name)) + async with schema_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all, tables=needed) + await schema_engine.dispose() + + # Point the real TransactionsEngine at the fresh DB so its exact async_scoped_session + + # close semantics (the trigger for the detach bug) are what runs. + monkeypatch.setattr(env.postgres, "uri_core", _sqlalchemy_url_for(db_name)) + engine = TransactionsEngine() + try: + engine._wd_db_name = db_name + yield engine + finally: + await engine.close() + admin = await asyncpg.connect(dsn=_admin_dsn()) + await admin.execute( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity " + "WHERE datname=$1 AND pid<>pg_backend_pid()", + db_name, + ) + await admin.execute(f'DROP DATABASE IF EXISTS "{db_name}"') + await admin.close() + + +async def _seed_tenant(s): + """One user, organization, workspace and project. Returns the project id.""" + project_id = uuid.uuid4() + uid, org, ws = uuid.uuid4(), uuid.uuid4(), uuid.uuid4() + await s.execute( + text("INSERT INTO users (id, uid, username, email) VALUES (:i,:u,:n,:e)"), + {"i": uid, "u": str(uid), "n": "wd", "e": f"wd-{uid.hex[:8]}@e.com"}, + ) + await s.execute( + text("INSERT INTO organizations (id, name, owner_id) VALUES (:i,:n,:o)"), + {"i": org, "n": "wd", "o": uid}, + ) + await s.execute( + text("INSERT INTO workspaces (id, name, organization_id) VALUES (:i,:n,:o)"), + {"i": ws, "n": "wd", "o": org}, + ) + await s.execute( + text( + "INSERT INTO projects (id, project_name, organization_id, workspace_id) " + "VALUES (:i,:n,:o,:w)" + ), + {"i": project_id, "n": "wd", "o": org, "w": ws}, + ) + return project_id + + +async def _seed_scenario(engine, *, session_id, turn_id): + stale = datetime.now(timezone.utc) - timedelta(hours=1) + async with engine.session() as s: + project_id = await _seed_tenant(s) + await s.execute( + text( + "INSERT INTO session_streams " + "(id, project_id, session_id, turn_id, flags, stopping_turn_id, created_at, updated_at) " + "VALUES (:i,:p,:s,:t, CAST(:f AS JSONB), :st, :c, :u)" + ), + { + "i": uuid.uuid4(), + "p": project_id, + "s": session_id, + "t": turn_id, + "f": '{"is_alive": true, "is_running": true, "is_attached": false}', + "st": turn_id, + "c": stale, + "u": stale, + }, + ) + await s.execute( + text( + "INSERT INTO session_commands " + "(id, project_id, session_id, kind, target_turn_id, state, claim_count, created_at) " + "VALUES (:i,:p,:s,'cancel',:t,'pending',0,:c)" + ), + { + "i": uuid.uuid4(), + "p": project_id, + "s": session_id, + "t": turn_id, + "c": stale, + }, + ) + await s.commit() + return project_id + + +async def _seed_lost_execution_scenario(engine, *, session_id, turn_id): + """A row the ORPHAN query never returns, whose turn is owed an ending. + + The stream row beats normally (a fresh `updated_at`), so it is not stale and is not + collapsed. Its turn is already settled `lost` with no ending written, which is what puts it + in `newly_lost`: the branch that clears `is_running` and keeps `is_alive`. + """ + fresh = datetime.now(timezone.utc) + stale = fresh - timedelta(hours=1) + async with engine.session() as s: + project_id = await _seed_tenant(s) + await s.execute( + text( + "INSERT INTO session_streams " + "(id, project_id, session_id, turn_id, flags, created_at, updated_at) " + "VALUES (:i,:p,:s,:t, CAST(:f AS JSONB), :c, :u)" + ), + { + "i": uuid.uuid4(), + "p": project_id, + "s": session_id, + "t": turn_id, + "f": '{"is_alive": true, "is_running": true, "is_attached": true}', + "c": fresh, + "u": fresh, + }, + ) + await s.execute( + text( + "INSERT INTO session_executions " + "(project_id, session_id, execution_id, terminal_outcome, settled_by, settled_at) " + "VALUES (:p,:s,:t,'lost','watchdog',:a)" + ), + {"p": project_id, "s": session_id, "t": turn_id, "a": stale}, + ) + await s.commit() + return project_id + + +def _build_services(engine): + lock = _FakeLock() + streams_service = SessionStreamsService( + streams_dao=SessionStreamsDAO(engine), lock_engine=lock + ) + interactions_service = SessionInteractionsService( + interactions_dao=SessionInteractionsDAO(engine) + ) + executions_dao = SessionExecutionsDAO(engine) + commands_service = SessionCommandsService( + commands_dao=SessionCommandsDAO(engine), + streams_service=streams_service, + interactions_service=interactions_service, + lock_engine=lock, + delivery=DirectControlDelivery(), + executions_dao=executions_dao, + ) + records_service = RecordsService(RecordsDAO(engine), executions_dao) + return lock, records_service, commands_service + + +@pytest.mark.anyio +async def test_a_lost_pass_persists_the_collapse_against_real_postgres( + anyio_backend, wd_engine, monkeypatch +): + monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + + session_id = "wd-" + uuid.uuid4().hex[:12] + turn_id = str(uuid.uuid4()) + await _seed_scenario(wd_engine, session_id=session_id, turn_id=turn_id) + + lock, records_service, commands_service = _build_services(wd_engine) + await orphan_sweep.run_orphan_sweep( + wd_engine, + lock, + records_service=records_service, + watch_publisher=None, + commands_service=commands_service, + publish=_noop_publish, + ) + + # Read back through a FRESH session so the assertions see committed DB state, not any + # in-memory ORM object the pass held. + async with wd_engine.session() as s: + flags, stopping = ( + await s.execute( + text( + "SELECT flags, stopping_turn_id FROM session_streams WHERE session_id=:s" + ), + {"s": session_id}, + ) + ).one() + ex = ( + await s.execute( + text( + "SELECT terminal_outcome, settled_by FROM session_executions " + "WHERE session_id=:s AND execution_id=:t" + ), + {"s": session_id, "t": turn_id}, + ) + ).one_or_none() + cmd = ( + await s.execute( + text( + "SELECT state, outcome FROM session_commands " + "WHERE session_id=:s AND target_turn_id=:t" + ), + {"s": session_id, "t": turn_id}, + ) + ).one() + + # The collapse persisted: this is the finding-7 assertion. + assert flags["is_alive"] is False + assert flags["is_running"] is False + assert stopping is None + # The execution reached its durable terminal outcome, settled by the watchdog. + assert ex is not None + assert ex[0] == "lost" + assert ex[1] == "watchdog" + # The Stop command was settled, not left pending. + assert cmd[0] == "obsolete" + assert cmd[1] == "lost" + + +@pytest.mark.anyio +async def test_b_lost_turn_clear_persists_after_a_nested_session_close( + anyio_backend, wd_engine, monkeypatch +): + """The `newly_lost` is_running clear survives a nested session between load and write. + + Same failure mode as finding 7, one branch up. The owner lookup is patched to open an + `engine.session()`, whose `finally` closes the shared task-scoped session before settlement + and the lost-turn update. Core writes must still reopen that session and persist. + + This never failed in production: before the fix the write sat immediately after the load, + with nothing nested in between. The test pins the property rather than a past bug. Make the + write an ORM attribute assignment again and it fails on `is_running` still true. + """ + monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + + session_id = "wd-" + uuid.uuid4().hex[:12] + turn_id = str(uuid.uuid4()) + await _seed_lost_execution_scenario( + wd_engine, session_id=session_id, turn_id=turn_id + ) + + real_get_owner_value = orphan_sweep.get_owner_value + nested_sessions = [] + + async def _get_owner_value_through_a_nested_session(*args, **kwargs): + # Open and close the shared task-scoped session, exactly as a DAO call would. + async with wd_engine.session(): + nested_sessions.append(1) + return await real_get_owner_value(*args, **kwargs) + + monkeypatch.setattr( + orphan_sweep, + "get_owner_value", + _get_owner_value_through_a_nested_session, + ) + + lock, records_service, commands_service = _build_services(wd_engine) + await orphan_sweep.run_orphan_sweep( + wd_engine, + lock, + records_service=records_service, + watch_publisher=None, + commands_service=commands_service, + publish=_noop_publish, + ) + + # The pass must actually have reached the branch under test. + assert nested_sessions, "the lost-turn branch never ran, so nothing was proven" + + async with wd_engine.session() as s: + flags = ( + await s.execute( + text("SELECT flags FROM session_streams WHERE session_id=:s"), + {"s": session_id}, + ) + ).scalar_one() + + # is_running cleared and PERSISTED; is_alive kept, so the session stays resumable. + assert flags["is_running"] is False + assert flags["is_alive"] is True + + +@pytest.mark.anyio +async def test_c_heartbeat_blocked_on_sweep_cannot_revive_collapsed_row( + anyio_backend, wd_engine +): + """A heartbeat whose UPDATE snapshot predates the sweep commit must lose its CAS.""" + session_id = "wd-" + uuid.uuid4().hex[:12] + turn_id = str(uuid.uuid4()) + project_id = await _seed_scenario(wd_engine, session_id=session_id, turn_id=turn_id) + + parsed = urlparse(env.postgres.uri_core) + dsn = urlunparse( + ("postgresql", parsed.netloc, f"/{wd_engine._wd_db_name}", "", "", "") + ) + sweep = await asyncpg.connect(dsn=dsn) + observer = await asyncpg.connect(dsn=dsn) + sweep_transaction = sweep.transaction() + heartbeat = None + committed = False + heartbeat_rowcounts = [] + + def capture_heartbeat_rowcount( + _connection, + clauseelement, + _multiparams, + _params, + _execution_options, + result, + ): + if getattr(clauseelement, "is_update", False): + table = getattr(clauseelement, "table", None) + if table is not None and table.name == "session_streams": + heartbeat_rowcounts.append(result.rowcount) + + event.listen( + wd_engine._engine.sync_engine, "after_execute", capture_heartbeat_rowcount + ) + try: + await sweep_transaction.start() + await sweep.execute( + "UPDATE session_streams " + "SET flags=$1::jsonb, updated_at=NOW() " + "WHERE project_id=$2 AND session_id=$3", + '{"is_alive": false, "is_running": false, "is_attached": false}', + project_id, + session_id, + ) + await sweep.execute( + "INSERT INTO session_executions " + "(project_id, session_id, execution_id, terminal_outcome, settled_by, settled_at) " + "VALUES ($1,$2,$3,'lost','watchdog',NOW())", + project_id, + session_id, + turn_id, + ) + + heartbeat = asyncio.create_task( + SessionStreamsDAO(wd_engine).update( + project_id=project_id, + user_id=None, + session_id=session_id, + stream=SessionStreamEdit( + flags=SessionStreamFlags( + is_alive=True, is_running=True, is_attached=False + ), + turn_id=turn_id, + expected_turn_id=turn_id, + ), + ) + ) + + async def heartbeat_is_blocked_on_the_sweep(): + while True: + blocked = await observer.fetchval( + "SELECT EXISTS (" + "SELECT 1 FROM pg_stat_activity " + "WHERE datname=current_database() " + "AND wait_event_type='Lock' " + "AND query LIKE 'UPDATE session_streams%')" + ) + if blocked: + return + await asyncio.sleep(0.01) + + await asyncio.wait_for(heartbeat_is_blocked_on_the_sweep(), timeout=5) + await sweep_transaction.commit() + committed = True + heartbeat_result = await asyncio.wait_for(heartbeat, timeout=5) + finally: + event.remove( + wd_engine._engine.sync_engine, "after_execute", capture_heartbeat_rowcount + ) + if heartbeat is not None and not heartbeat.done(): + heartbeat.cancel() + await asyncio.gather(heartbeat, return_exceptions=True) + if not committed: + await sweep_transaction.rollback() + await observer.close() + await sweep.close() + + assert heartbeat_result is None + assert heartbeat_rowcounts == [0] + + async with wd_engine.session() as s: + flags, outcome = ( + await s.execute( + text( + "SELECT ss.flags, se.terminal_outcome " + "FROM session_streams ss JOIN session_executions se " + "ON se.project_id=ss.project_id AND se.session_id=ss.session_id " + "AND se.execution_id=ss.turn_id WHERE ss.session_id=:s" + ), + {"s": session_id}, + ) + ).one() + + assert outcome == "lost" + assert flags == { + "is_alive": False, + "is_running": False, + "is_attached": False, + } diff --git a/api/oss/tests/pytest/unit/sessions/test_watchdog_lifespan_wiring.py b/api/oss/tests/pytest/unit/sessions/test_watchdog_lifespan_wiring.py new file mode 100644 index 00000000000..aa0ee88ab77 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_watchdog_lifespan_wiring.py @@ -0,0 +1,50 @@ +import asyncio +import importlib +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.mark.asyncio +async def test_lifespan_wires_the_commands_service_into_the_watchdog(monkeypatch): + with patch("alembic.script.ScriptDirectory.from_config", return_value=object()): + routers = importlib.import_module("entrypoints.routers") + + transactions_engine = SimpleNamespace(close=AsyncMock()) + monkeypatch.setattr(routers, "_transactions_engine", transactions_engine) + monkeypatch.setattr( + routers, "_analytics_engine", SimpleNamespace(close=AsyncMock()) + ) + monkeypatch.setattr(routers, "_streams_engine", SimpleNamespace(close=AsyncMock())) + monkeypatch.setattr(routers, "_lock_engine", object()) + monkeypatch.setattr( + routers, + "_triggers_broker", + SimpleNamespace(startup=AsyncMock(), shutdown=AsyncMock()), + ) + monkeypatch.setattr(routers, "_composio_adapters", {}) + monkeypatch.setattr(routers, "_composio_connections_adapters", {}) + monkeypatch.setattr(routers, "_composio_triggers_adapters", {}) + monkeypatch.setattr(routers.env.store, "bucket", None) + monkeypatch.setattr(routers.env, "composio", SimpleNamespace(enabled=False)) + monkeypatch.setattr(routers, "check_for_new_core_migrations", AsyncMock()) + monkeypatch.setattr(routers, "check_for_new_tracing_migrations", AsyncMock()) + monkeypatch.setattr(routers, "warn_deprecated_env_vars", lambda: None) + monkeypatch.setattr(routers, "validate_required_env_vars", lambda: None) + monkeypatch.setattr(routers, "validate_platform_runtime_key", lambda: None) + + watchdog = AsyncMock() + monkeypatch.setattr(routers, "orphan_sweep_loop", watchdog) + monkeypatch.setattr(routers, "attachment_sweep_loop", AsyncMock()) + + async with routers.lifespan(): + await asyncio.sleep(0) + watchdog.assert_awaited_once_with( + transactions_engine, + routers._lock_engine, + records_service=routers.records_service, + watch_publisher=routers._sessions_watch_publisher, + commands_service=routers.session_commands_service, + ) + assert routers.session_commands_service is not None diff --git a/api/oss/tests/pytest/unit/sessions/test_worker_streams_startup.py b/api/oss/tests/pytest/unit/sessions/test_worker_streams_startup.py new file mode 100644 index 00000000000..63a7ace6223 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_worker_streams_startup.py @@ -0,0 +1,53 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from entrypoints import worker_streams + + +async def test_relay_initialization_failure_does_not_block_durable_consumers(): + records = SimpleNamespace( + stream_name="streams:records", + consumer_group="worker-records", + consumer_name="records-1", + create_consumer_group=AsyncMock(), + run=AsyncMock(), + ) + live_relay = SimpleNamespace( + stream_name="streams:session-live-frames", + consumer_group="worker-session-live-relay", + consumer_name="relay-1", + create_consumer_group=AsyncMock( + side_effect=RuntimeError("relay XGROUP failed") + ), + run=AsyncMock(), + ) + + with ( + patch.object(worker_streams, "_selected_streams", return_value=["records"]), + patch.object(worker_streams, "warn_deprecated_env_vars"), + patch.object(worker_streams, "validate_required_env_vars"), + patch.object(worker_streams, "is_ee", return_value=False), + patch.object(worker_streams.Redis, "from_url", return_value=AsyncMock()), + patch.object( + worker_streams, + "_build_records_worker", + new=AsyncMock(return_value=records), + ), + patch.object( + worker_streams, + "_build_live_relay_worker", + new=AsyncMock(return_value=live_relay), + ), + patch.object( + worker_streams, + "prune_idle_consumers", + new=AsyncMock(return_value=[]), + ), + patch.object(worker_streams.env.sessions, "shared_reader", True), + ): + assert await worker_streams.main_async() == 0 + + records.create_consumer_group.assert_awaited_once() + records.run.assert_awaited_once() + live_relay.create_consumer_group.assert_awaited_once() + live_relay.run.assert_not_awaited() diff --git a/api/oss/tests/pytest/unit/sessions/test_wp5_dao_fanout.py b/api/oss/tests/pytest/unit/sessions/test_wp5_dao_fanout.py index 5f44f2b18f3..2b4a36438b6 100644 --- a/api/oss/tests/pytest/unit/sessions/test_wp5_dao_fanout.py +++ b/api/oss/tests/pytest/unit/sessions/test_wp5_dao_fanout.py @@ -226,6 +226,58 @@ async def test_interaction_transition_preserves_data_and_optionally_adds_resolut assert transitioned_without_resolution.data.resolution is None +async def test_cancel_pending_returns_exactly_the_rows_it_transitioned( + interactions_dao, project +): + project_id = project["project_id"] + session_id = f"interaction-cancel-returning-{uuid.uuid4().hex[:8]}" + + for token in ("pending-1", "pending-2", "already-answered"): + await interactions_dao.create_interaction( + project_id=project_id, + user_id=None, + interaction=SessionInteractionCreate( + project_id=project_id, + session_id=session_id, + turn_id="turn-1", + token=token, + kind=SessionInteractionKind.user_approval, + ), + ) + + await interactions_dao.transition_interaction( + transition=SessionInteractionTransition( + project_id=project_id, + session_id=session_id, + token="already-answered", + status=SessionInteractionStatus.responded, + ) + ) + + cancelled = await interactions_dao.cancel_session_pending( + project_id=project_id, + session_id=session_id, + only_turn_id="turn-1", + ) + + assert {interaction.token for interaction in cancelled} == { + "pending-1", + "pending-2", + } + assert all( + interaction.status == SessionInteractionStatus.cancelled + for interaction in cancelled + ) + assert ( + await interactions_dao.cancel_session_pending( + project_id=project_id, + session_id=session_id, + only_turn_id="turn-1", + ) + == [] + ) + + # --------------------------------------------------------------------------- # SessionInteractionsDAO.delete_by_session_id — new hard delete # --------------------------------------------------------------------------- diff --git a/api/oss/tests/pytest/unit/test_multilogger.py b/api/oss/tests/pytest/unit/test_multilogger.py new file mode 100644 index 00000000000..3228424c7da --- /dev/null +++ b/api/oss/tests/pytest/unit/test_multilogger.py @@ -0,0 +1,46 @@ +"""MultiLogger must expose `exception`, like the stdlib logger. + +The application logger returned by `get_module_logger` is a `MultiLogger`. It used to define +every level method except `exception`, so `log.exception(...)` -- the natural call inside an +`except` block -- raised AttributeError from inside the handler and took the caller down. The +execution watchdog died exactly this way. These tests hold the contract that closed that gap: +the method exists, it does not raise when called from an `except` block, and it forwards to +the wrapped logger's `error` with the active traceback. +""" + +from oss.src.utils.logging import MultiLogger, get_module_logger + + +class _Spy: + """A stand-in wrapped logger that records the `error` calls MultiLogger forwards to it.""" + + def __init__(self): + self.calls = [] + + def error(self, *args, **kwargs): + self.calls.append((args, kwargs)) + + +def test_multilogger_has_an_exception_method(): + assert hasattr(MultiLogger(), "exception") + + +def test_real_module_logger_exposes_exception(): + log = get_module_logger(__name__) + assert hasattr(log, "exception") + + +def test_exception_from_an_except_block_does_not_raise_and_logs_with_traceback(): + spy = _Spy() + log = MultiLogger(spy) + + try: + raise RuntimeError("boom") + except RuntimeError: + # Before the fix this raised AttributeError instead of logging. + log.exception("something failed") + + assert spy.calls, "exception() must forward to the wrapped logger's error()" + args, kwargs = spy.calls[0] + assert args[0] == "something failed" + assert kwargs.get("exc_info") is True diff --git a/api/oss/tests/pytest/unit/triggers/test_triggers_worker_lifecycle.py b/api/oss/tests/pytest/unit/triggers/test_triggers_worker_lifecycle.py index b0d8ae80c33..2aee790c054 100644 --- a/api/oss/tests/pytest/unit/triggers/test_triggers_worker_lifecycle.py +++ b/api/oss/tests/pytest/unit/triggers/test_triggers_worker_lifecycle.py @@ -102,6 +102,48 @@ async def test_schedule_task_skips_schedule_disabled_after_enqueue(): dispatcher.dispatch_schedule.assert_not_awaited() +async def test_schedule_task_skips_when_no_schedule_or_schedule_id_provided(): + worker, dao, dispatcher = _worker(resolved=None) + + await worker.dispatch_schedule( + project_id=str(uuid4()), + event_id="event-1", + event={}, + ) + + dao.fetch_schedule.assert_not_awaited() + dispatcher.dispatch_schedule.assert_not_awaited() + + +async def test_schedule_task_skips_malformed_project_id(): + schedule = _schedule() + worker, dao, dispatcher = _worker(resolved=schedule) + + await worker.dispatch_schedule( + project_id="not-a-uuid", + schedule_id=str(schedule.id), + event_id="event-1", + event={}, + ) + + dao.fetch_schedule.assert_not_awaited() + dispatcher.dispatch_schedule.assert_not_awaited() + + +async def test_schedule_task_skips_malformed_schedule_id(): + worker, dao, dispatcher = _worker(resolved=None) + + await worker.dispatch_schedule( + project_id=str(uuid4()), + schedule_id="not-a-uuid", + event_id="event-1", + event={}, + ) + + dao.fetch_schedule.assert_not_awaited() + dispatcher.dispatch_schedule.assert_not_awaited() + + async def test_subscription_task_skips_deleted_subscription_lookup(): worker, dao, dispatcher = _worker(resolved=None) dao.get_project_and_subscription_by_trigger_id = AsyncMock(return_value=None) diff --git a/api/oss/tests/pytest/unit/utils/test_caching.py b/api/oss/tests/pytest/unit/utils/test_caching.py new file mode 100644 index 00000000000..b41ba7c6ad9 --- /dev/null +++ b/api/oss/tests/pytest/unit/utils/test_caching.py @@ -0,0 +1,70 @@ +import fnmatch + +from oss.src.utils.caching import _pack + + +def test_pack_produces_expected_key_shape(): + packed = _pack( + namespace="check_action_access", + key={"permission": "run_service", "role": "member"}, + project_id="abc123", + user_id="9c0d1e2f3a4b", + ) + + assert packed == ( + "cache:p:abc123------:u:9c0d1e2f3a4b:check_action_access:" + "permission:run_service:role:member" + ) + + +def test_pack_sorts_dict_keys_for_deterministic_output(): + # Insertion order must not affect the packed key, or two callers building + # "the same" cache key from an unordered dict would silently miss. + key_a = {"permission": "run_service", "role": "member"} + key_b = {"role": "member", "permission": "run_service"} + + assert _pack(namespace="ns", key=key_a) == _pack(namespace="ns", key=key_b) + + +def test_pack_rejects_non_str_non_dict_key(): + import pytest + + with pytest.raises(TypeError): + _pack(namespace="ns", key=123) + + +def test_pack_scan_pattern_matches_key_written_with_user_id(): + # Regression test for the RBAC cache-invalidation bug: every role/membership + # change calls invalidate_cache(namespace=..., project_id=...) WITHOUT a + # user_id, relying on _pack(..., pattern=True) to emit a wildcard for the + # omitted user segment. Before the fix, an omitted user_id was padded to a + # literal "------------" segment even when pattern=True, so the scan + # pattern never matched keys written with a real user_id — invalidation + # was silently a no-op and stale permissions (positive and negative) were + # served for up to the full 5-minute cache TTL. + written = _pack( + namespace="check_action_access", + key={"permission": "run_service"}, + project_id="abc123", + user_id="9c0d1e2f3a4b", + ) + + scan = _pack( + namespace="check_action_access", + project_id="abc123", + pattern=True, + ) + + assert fnmatch.fnmatch(written, scan) + + +def test_pack_omitted_user_id_with_pattern_true_emits_wildcard(): + packed = _pack(namespace="check_action_access", project_id="abc123", pattern=True) + assert ":u:*:" in packed + + +def test_pack_omitted_user_id_with_pattern_false_still_pads_with_dashes(): + # Write-path behavior for a genuinely-omitted id must stay unchanged, + # only the pattern=True (invalidation scan) path should wildcard it. + packed = _pack(namespace="check_action_access", project_id="abc123", pattern=False) + assert ":u:------------:" in packed diff --git a/api/oss/tests/pytest/unit/vault/test_write_only_routes.py b/api/oss/tests/pytest/unit/vault/test_write_only_routes.py index 99868213752..ac8cd789373 100644 --- a/api/oss/tests/pytest/unit/vault/test_write_only_routes.py +++ b/api/oss/tests/pytest/unit/vault/test_write_only_routes.py @@ -448,3 +448,60 @@ def test_malformed_create_never_echoes_the_submitted_key(harness): assert response.status_code == 422 assert CANARY not in response.text + + +def test_write_only_custom_secret_keeps_default_environment_metadata(harness): + response = harness.post( + "/secrets/", + json={ + "header": {"name": "GitHub token"}, + "slug": "github-token", + "write_only": True, + "secret": { + "kind": "custom_secret", + "data": { + "secret": { + "format": "text", + "content": "github-secret-value", + "default_env_var": "GITHUB_TOKEN", + } + }, + }, + }, + ) + assert response.status_code == 200, response.text + body = response.json() + assert body["data"]["secret"]["default_env_var"] == "GITHUB_TOKEN" + assert "content" not in body["data"]["secret"] + + +def test_custom_secret_update_keeps_omitted_default_environment_metadata(harness): + created = harness.post( + "/secrets/", + json={ + "header": {"name": "GitHub token"}, + "slug": "github-token-update", + "write_only": True, + "secret": { + "kind": "custom_secret", + "data": { + "secret": { + "format": "text", + "content": "old", + "default_env_var": "GITHUB_TOKEN", + } + }, + }, + }, + ).json() + updated = harness.put( + f"/secrets/{created['id']}", + json={ + "secret": { + "kind": "custom_secret", + "data": {"secret": {"format": "text", "content": "new"}}, + } + }, + ) + assert updated.status_code == 200, updated.text + assert updated.json()["data"]["secret"]["default_env_var"] == "GITHUB_TOKEN" diff --git a/api/oss/tests/pytest/unit/workflows/test_change_set.py b/api/oss/tests/pytest/unit/workflows/test_change_set.py index 8375654dddd..423fb7c202e 100644 --- a/api/oss/tests/pytest/unit/workflows/test_change_set.py +++ b/api/oss/tests/pytest/unit/workflows/test_change_set.py @@ -2273,6 +2273,7 @@ def test_the_agent_may_write_its_own_subtree(self): ["parameters", "agent", "runner", "permissions", "default"], ["parameters", "agent", "sandbox", "kind"], ["parameters", "agent", "sandbox", "permissions"], + ["parameters", "agent", "sandbox", "credentials"], ], ) def test_platform_owned_targets_are_refused(self, target): diff --git a/api/oss/tests/pytest/unit/workflows/test_commit_wrapper.py b/api/oss/tests/pytest/unit/workflows/test_commit_wrapper.py index b9cf5ebed37..cfab435de67 100644 --- a/api/oss/tests/pytest/unit/workflows/test_commit_wrapper.py +++ b/api/oss/tests/pytest/unit/workflows/test_commit_wrapper.py @@ -439,6 +439,40 @@ def test_the_legacy_arm_refuses_a_sandbox_permissions_write(self, service): assert caught.value.reason == Reason.OUT_OF_SCOPE assert "sandbox.permissions" in caught.value.message + def test_the_ordered_arm_refuses_a_sandbox_credentials_write( + self, service, ordered_on + ): + from oss.src.core.workflows.change_set import AGENT_COMMIT_SCOPE + + commit = _commit( + operations=[ + { + "operation": "set", + "target": AGENT + ["sandbox", "credentials"], + "value": [], + } + ] + ) + + with pytest.raises(ChangeSetError) as caught: + _apply(service, {}, commit, scope_policy=AGENT_COMMIT_SCOPE) + + assert caught.value.reason == Reason.OUT_OF_SCOPE + assert "sandbox.credentials" in caught.value.message + + def test_the_legacy_arm_refuses_a_sandbox_credentials_write(self, service): + from oss.src.core.workflows.change_set import AGENT_COMMIT_SCOPE + + commit = _commit( + set={"parameters": {"agent": {"sandbox": {"credentials": []}}}} + ) + + with pytest.raises(ChangeSetError) as caught: + _apply(service, {}, commit, scope_policy=AGENT_COMMIT_SCOPE) + + assert caught.value.reason == Reason.OUT_OF_SCOPE + assert "sandbox.credentials" in caught.value.message + def test_it_refuses_a_write_outside_the_agent_subtree(self, service): from oss.src.core.workflows.change_set import AGENT_COMMIT_SCOPE diff --git a/api/oss/tests/pytest/unit/workflows/test_sandbox_credential_permissions.py b/api/oss/tests/pytest/unit/workflows/test_sandbox_credential_permissions.py new file mode 100644 index 00000000000..69298c53cde --- /dev/null +++ b/api/oss/tests/pytest/unit/workflows/test_sandbox_credential_permissions.py @@ -0,0 +1,171 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from oss.src.apis.fastapi.workflows import router as router_module +from oss.src.apis.fastapi.workflows.models import WorkflowVariantForkRequest +from oss.src.apis.fastapi.workflows.router import ( + _changes_sandbox_credentials, + _require_fork_secret_attachment_access, + _require_secret_attachment_access, +) +from oss.src.core.access.permissions.types import Permission +from oss.src.core.shared.dtos import Reference +from oss.src.core.workflows.dtos import WorkflowRevisionCommit, WorkflowVariantFork + + +def test_detects_credentials_in_full_agent_revision(): + assert _changes_sandbox_credentials( + {"data": {"parameters": {"agent": {"sandbox": {"credentials": []}}}}} + ) + + +def test_detects_credentials_in_schema_valid_ordered_delta(): + commit = WorkflowRevisionCommit.model_validate( + { + "workflow_variant_id": "00000000-0000-0000-0000-000000000001", + "base_revision_id": "00000000-0000-0000-0000-000000000002", + "delta": { + "operations": [ + { + "operation": "set", + "target": [ + "parameters", + "agent", + "sandbox", + "credentials", + ], + "value": [], + } + ] + }, + } + ) + assert _changes_sandbox_credentials(commit) + + +def test_detects_ordered_delta_that_replaces_credentials_parent(): + assert _changes_sandbox_credentials( + { + "delta": { + "operations": [ + { + "operation": "set", + "target": ["parameters", "agent", "sandbox"], + "value": {}, + } + ] + } + } + ) + + +def test_detects_credentials_in_legacy_delta_path(): + assert _changes_sandbox_credentials( + {"delta": {"set": {"parameters.agent.sandbox.credentials": []}}} + ) + + +def test_detects_credentials_in_legacy_remove(): + assert _changes_sandbox_credentials( + { + "delta": { + "remove": [ + "parameters.agent.instructions", + "parameters.agent.sandbox.credentials", + ] + } + } + ) + + +def test_ignores_unrelated_workflow_changes(): + assert not _changes_sandbox_credentials( + { + "delta": { + "operations": [ + { + "operation": "set", + "target": ["parameters", "agent", "instructions"], + "value": "updated", + } + ] + } + } + ) + + +def test_ignores_sandbox_credentials_mentioned_in_instruction_text(): + assert not _changes_sandbox_credentials( + { + "data": { + "parameters": { + "agent": { + "instructions": { + "agents_md": "Document sandbox.credentials without changing it." + } + } + } + } + } + ) + + +async def test_attachment_requires_edit_secret(monkeypatch): + check = AsyncMock(return_value=True) + monkeypatch.setattr(router_module, "check_action_access", check) + request = SimpleNamespace( + state=SimpleNamespace(user_id="user", project_id="project") + ) + await _require_secret_attachment_access( + request, {"parameters": {"agent": {"sandbox": {"credentials": []}}}} + ) + check.assert_awaited_once_with( + user_uid="user", project_id="project", permission=Permission.EDIT_SECRET + ) + + +async def test_unrelated_revision_does_not_require_edit_secret(monkeypatch): + check = AsyncMock() + monkeypatch.setattr(router_module, "check_action_access", check) + request = SimpleNamespace( + state=SimpleNamespace(user_id="user", project_id="project") + ) + await _require_secret_attachment_access( + request, {"parameters": {"agent": {"instructions": {"agents_md": "x"}}}} + ) + check.assert_not_awaited() + + +async def test_fork_of_credential_bearing_revision_requires_edit_secret(monkeypatch): + check = AsyncMock(return_value=False) + monkeypatch.setattr(router_module, "check_action_access", check) + service = SimpleNamespace( + fetch_workflow_revision=AsyncMock( + return_value={ + "data": {"parameters": {"agent": {"sandbox": {"credentials": []}}}} + } + ) + ) + request = SimpleNamespace( + state=SimpleNamespace( + user_id="user", project_id="00000000-0000-0000-0000-000000000001" + ) + ) + fork_request = WorkflowVariantForkRequest( + workflow_variant=WorkflowVariantFork(slug="forked"), + workflow_variant_ref=Reference(slug="source"), + ) + + try: + await _require_fork_secret_attachment_access(request, service, fork_request) + except Exception as exc: + assert exc is router_module.FORBIDDEN_EXCEPTION + else: + raise AssertionError("credential-bearing fork should require EDIT_SECRET") + + service.fetch_workflow_revision.assert_awaited_once() + check.assert_awaited_once_with( + user_uid="user", + project_id="00000000-0000-0000-0000-000000000001", + permission=Permission.EDIT_SECRET, + ) diff --git a/api/oss/tests/pytest/unit/workflows/test_static_catalog.py b/api/oss/tests/pytest/unit/workflows/test_static_catalog.py index 0816eede009..fd6965aa889 100644 --- a/api/oss/tests/pytest/unit/workflows/test_static_catalog.py +++ b/api/oss/tests/pytest/unit/workflows/test_static_catalog.py @@ -43,6 +43,7 @@ ) from oss.src.core.workflows.static_catalog import ( REQUEST_INPUT_TOOL_NAME, + REQUEST_SECRET_WORKFLOW_SLUG, REQUEST_INPUT_WORKFLOW_SLUG, STATIC_SLUG_PREFIX, StaticWorkflowCatalog, @@ -316,12 +317,23 @@ async def test_build_agent_skill_embed_resolves_through_static_catalog_without_d workflows_dao.fetch_artifact.assert_not_awaited() +@pytest.mark.parametrize( + ("workflow_slug", "expected_name", "expected_render"), + [ + ("__ag__request_connection", "request_connection", {"kind": "connect"}), + ("__ag__request_secret", "request_secret", {"kind": "secret"}), + ], +) @pytest.mark.asyncio -async def test_request_connection_tool_embed_resolves_to_client_tool_config_without_db(): - """The reserved request_connection workflow inlines a tool *config* (``type:"client"``), so the - embed + ``parameters.tool`` selector yields a value the SDK coerces to a ``ClientToolConfig``. - Regression: a spec-shaped ``kind:"client"`` value coerced to a builtin tool instead.""" - from agenta.sdk.agents.platform.workflow import REQUEST_CONNECTION_WORKFLOW_SLUG +async def test_platform_client_tool_embed_resolves_to_client_tool_config_without_db( + workflow_slug, expected_name, expected_render +): + """Reserved client workflows inline tool configs selected from ``parameters.tool``. + + This exercises catalog lookup, embed resolution, and SDK coercion for both platform client + tools. Omitting the selector instead supplies the whole revision data and produces the live + ``Unsupported tool configuration shape`` failure. + """ from agenta.sdk.agents.tools.compat import coerce_tool_config from agenta.sdk.agents.tools.models import ClientToolConfig @@ -344,11 +356,7 @@ async def test_request_connection_tool_embed_resolves_to_client_tool_config_with "tools": [ { "@ag.embed": { - "@ag.references": { - "workflow": { - "slug": REQUEST_CONNECTION_WORKFLOW_SLUG - } - }, + "@ag.references": {"workflow": {"slug": workflow_slug}}, "@ag.selector": {"path": "parameters.tool"}, } } @@ -368,11 +376,11 @@ async def test_request_connection_tool_embed_resolves_to_client_tool_config_with tool = resolved_revision.data.parameters["agent"]["tools"][0] assert tool["type"] == "client" - assert tool["name"] == "request_connection" + assert tool["name"] == expected_name # The resolved config must coerce to a client tool (not silently a builtin). coerced = coerce_tool_config(tool) assert isinstance(coerced, ClientToolConfig) - assert coerced.render == {"kind": "connect"} + assert coerced.render == expected_render assert resolution_info.embeds_resolved == 1 workflows_dao.fetch_revision.assert_not_awaited() workflows_dao.fetch_artifact.assert_not_awaited() @@ -1005,3 +1013,16 @@ def test_request_input_matches_golden_response_fixture(): assert golden["degradation_error_text"].startswith( "elicitation: unsupported payload — " ) + + +def test_request_secret_catalog_entry_shape(): + revision = StaticWorkflowCatalog().retrieve_revision( + slug=REQUEST_SECRET_WORKFLOW_SLUG + ) + assert revision is not None + tool = revision.data.parameters["tool"] + assert tool["type"] == "client" + assert tool["name"] == "request_secret" + assert tool["render"] == {"kind": "secret"} + assert tool["input_schema"]["required"] == ["name", "env_var", "reason"] + assert "value" not in tool["input_schema"]["properties"] diff --git a/api/pyproject.toml b/api/pyproject.toml index a11948e82a5..acb318e7cac 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "api" -version = "0.114.4" +version = "0.115.1" description = "Agenta API" requires-python = ">=3.11,<3.14" authors = [ diff --git a/api/uv.lock b/api/uv.lock index c5ac1c1b8fa..d38067daff9 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ [[package]] name = "agenta" -version = "0.114.4" +version = "0.115.1" source = { editable = "../sdks/python" } dependencies = [ { name = "agenta-client" }, @@ -72,7 +72,7 @@ dev = [ [[package]] name = "agenta-client" -version = "0.114.4" +version = "0.115.1" source = { editable = "../clients/python" } dependencies = [ { name = "httpx" }, @@ -276,7 +276,7 @@ wheels = [ [[package]] name = "api" -version = "0.114.4" +version = "0.115.1" source = { virtual = "." } dependencies = [ { name = "agenta" }, diff --git a/clients/python/pyproject.toml b/clients/python/pyproject.toml index bdd1988fe6c..6bd3db608f9 100644 --- a/clients/python/pyproject.toml +++ b/clients/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agenta-client" -version = "0.114.4" +version = "0.115.1" description = "Fern-generated Python client for the Agenta API." requires-python = ">=3.11,<3.14" authors = [ diff --git a/clients/python/uv.lock b/clients/python/uv.lock index 60045addc23..05acbe65210 100644 --- a/clients/python/uv.lock +++ b/clients/python/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.11, <3.14" [[package]] name = "agenta-client" -version = "0.114.4" +version = "0.115.1" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/docs/README.md b/docs/README.md index ac8170ef0b6..e138ee203f3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,13 +45,10 @@ The docs build to Cloudflare Workers Static Assets. Two workers serve the site: | `agenta-docs` | `wrangler.production.jsonc` | the production build, on its own `workers.dev` URL | | `agenta-docs-preview` | `wrangler.jsonc` | one version per pull request | -**`https://agenta.ai/docs` does not point at these workers yet.** That hostname -still goes through the older `new-docs-router` worker, which proxies to Vercel. -Deploying `agenta-docs` changes no live traffic. The cutover is a separate, -deliberate step: remove the `agenta.ai/docs*` route from `new-docs-router` and -add `agenta.ai/docs` and `agenta.ai/docs/*` to `agenta-docs`. The exact patterns -are commented in `wrangler.production.jsonc`. Rolling back means dropping those -two routes and restoring the old one, which takes about a minute. +`https://agenta.ai/docs` and all paths below it route directly to +`agenta-docs`. The retired `new-docs-router` worker no longer proxies these +requests to Vercel. The production routes are declared in +`wrangler.production.jsonc` so later deployments preserve the cutover. Two GitHub Actions workflows drive them: @@ -83,6 +80,12 @@ moves to it: node scripts/check-parity.mjs https://pr-1234-agenta-docs-preview..workers.dev ``` +`scripts/monitor-production.mjs` is the smaller production health check. GitHub +Actions runs it every 15 minutes against the sitemap and representative current, +versioned, security, and API-reference pages. It checks normal, Googlebot, and +Bingbot requests and rejects any Vercel challenge response or unexpectedly +cacheable error. + ## Changelog Guidelines When working on the changelog page, following specific formatting rules are important to ensure the page's layout remains intact. Failure to follow these guidelines may result in broken UI elements. diff --git a/docs/design/agent-custom-secrets/README.md b/docs/design/agent-custom-secrets/README.md new file mode 100644 index 00000000000..ff86b16ad38 --- /dev/null +++ b/docs/design/agent-custom-secrets/README.md @@ -0,0 +1,29 @@ +# Custom secrets for agent runs + +Agent variants can attach text secrets from the project vault to their sandbox as environment variables. An agent can also pause a conversation with `request_secret`, let the user configure the binding, and continue on the committed revision. + +## Reading order + +1. [Context](context.md): problem, intended outcome, and milestone boundary. +2. [Contracts](contracts.md): shipped configuration, runtime, and tool interfaces. +3. [Plan](plan.md): implementation sequence and interaction lifecycle. +4. [Simplification](simplification.md): V1 scope decisions and deferred V2 work. +5. [QA](qa.md): automated and runtime acceptance criteria. +6. [Browser checklist](qa-browser-checklist.md): release checks for the user-facing transaction. +7. [Status](status.md): implementation and validation state. + +## Shipped V1 + +V1 includes text custom secrets, environment-variable bindings, project-scoped resolution, runner injection and redaction, shared create and attach UI, an Advanced agent configuration section, and the `request_secret` conversation flow. Bindings persist on an ordinary agent variant revision. + +The vault owns secret content. The variant stores only the secret slug and environment binding. The run transport carries resolved values to the runner over the existing protected channel and registers them with existing diagnostic redaction. The [QA notes](qa.md) describe the limits of readable delivery and short-value redaction. + +V1 has no service presets, secret templates, session-only grants, delivery-mode picker, host allowlist, or advanced secret metadata. Host-restricted and opaque delivery remain V2 work. + +## Terms + +A **vault secret** is encrypted project data. A **binding** maps a saved secret to one environment-variable name. A **variant revision** is a saved version of the agent configuration. A **paused turn** waits for a user interaction before the harness continues. + +## Tracking + +[Issue #5703](https://github.com/Agenta-AI/agenta/issues/5703), also [AGE-4067](https://linear.app/agenta/issue/AGE-4067), tracks custom-secret delivery. The implementation builds on the shared platform-instruction path introduced by [PR #6365](https://github.com/Agenta-AI/agenta/pull/6365). diff --git a/docs/design/agent-custom-secrets/assets/advanced-demo.png b/docs/design/agent-custom-secrets/assets/advanced-demo.png new file mode 100644 index 00000000000..9cdc1c5a69c Binary files /dev/null and b/docs/design/agent-custom-secrets/assets/advanced-demo.png differ diff --git a/docs/design/agent-custom-secrets/assets/advanced-recovery-evidence.json b/docs/design/agent-custom-secrets/assets/advanced-recovery-evidence.json new file mode 100644 index 00000000000..2febf2aa1e1 --- /dev/null +++ b/docs/design/agent-custom-secrets/assets/advanced-recovery-evidence.json @@ -0,0 +1,10 @@ +{ + "commits": 2, + "dirtyGuidance": "Save or discard the current agent changes before changing secret attachments.", + "attachDisabled": true, + "editDisabled": true, + "removeDisabled": true, + "failedRemovalStayedOpen": true, + "retryClosed": true, + "failed": true +} \ No newline at end of file diff --git a/docs/design/agent-custom-secrets/assets/browser-resume-evidence.json b/docs/design/agent-custom-secrets/assets/browser-resume-evidence.json new file mode 100644 index 00000000000..4e197d4fe34 --- /dev/null +++ b/docs/design/agent-custom-secrets/assets/browser-resume-evidence.json @@ -0,0 +1,79 @@ +{ + "commits": 1, + "invokes": [ + { + "at": 1788619035581, + "session_id": "3b666c5c-a8e0-43a1-b0b6-4b441530864b", + "references": { + "application": { + "id": "01a071e5-0c43-7e71-951b-ded6d453b7e2", + "slug": "qa-secret-ui-8nj0m219" + }, + "application_variant": { + "id": "01a071e5-0c5f-78f3-8852-0d52fb0efdeb", + "slug": "qa-secret-ui-8nj0m219-default" + }, + "application_revision": { + "id": "01a07200-9347-7121-a6ea-d18b0d63856c", + "slug": "02f70d56d997", + "version": "1" + } + }, + "bindings": [ + { + "secret": { + "slug": "deployment-token" + }, + "binding": { + "type": "env", + "name": "DEPLOY_TOKEN" + } + } + ], + "containsDummy": false + }, + { + "at": 1788619047586, + "session_id": "3b666c5c-a8e0-43a1-b0b6-4b441530864b", + "references": { + "application": { + "id": "01a071e5-0c43-7e71-951b-ded6d453b7e2", + "slug": "qa-secret-ui-8nj0m219" + }, + "application_variant": { + "id": "01a071e5-0c5f-78f3-8852-0d52fb0efdeb", + "slug": "qa-secret-ui-8nj0m219-default" + }, + "application_revision": { + "id": "01a07200-9347-7121-a6ea-d18b0d63856c", + "slug": "02f70d56d997", + "version": "1" + } + }, + "bindings": [ + { + "secret": { + "slug": "deployment-token" + }, + "binding": { + "type": "env", + "name": "DEPLOY_TOKEN" + } + } + ], + "containsDummy": false + } + ], + "committedRevision": "01a07200-9347-7121-a6ea-d18b0d63856c", + "committedAt": 1788619035502, + "session": "3b666c5c-a8e0-43a1-b0b6-4b441530864b", + "expectedDigest": "59a49bc8941e471c53fefb693c6442208cc5375049f5abf24509297cc09e1bdf", + "store": { + "status": 200, + "content": "59a49bc8941e471c53fefb693c6442208cc5375049f5abf24509297cc09e1bdf" + }, + "digestMatches": true, + "sameSession": true, + "resumedWithCommittedRevision": true, + "notes": "The digest is SHA-256 of the explicitly approved fixed dummy. Vault-create response instrumentation did not match the canonical endpoint; no claim is made from those counters." +} diff --git a/docs/design/agent-custom-secrets/assets/request-create-demo.png b/docs/design/agent-custom-secrets/assets/request-create-demo.png new file mode 100644 index 00000000000..7355dbeac8a Binary files /dev/null and b/docs/design/agent-custom-secrets/assets/request-create-demo.png differ diff --git a/docs/design/agent-custom-secrets/assets/request-demo.png b/docs/design/agent-custom-secrets/assets/request-demo.png new file mode 100644 index 00000000000..22657d64c51 Binary files /dev/null and b/docs/design/agent-custom-secrets/assets/request-demo.png differ diff --git a/docs/design/agent-custom-secrets/assets/resume-demo.png b/docs/design/agent-custom-secrets/assets/resume-demo.png new file mode 100644 index 00000000000..dd44042414e Binary files /dev/null and b/docs/design/agent-custom-secrets/assets/resume-demo.png differ diff --git a/docs/design/agent-custom-secrets/assets/retry-demo.png b/docs/design/agent-custom-secrets/assets/retry-demo.png new file mode 100644 index 00000000000..b62356dc227 Binary files /dev/null and b/docs/design/agent-custom-secrets/assets/retry-demo.png differ diff --git a/docs/design/agent-custom-secrets/assets/retry-evidence.json b/docs/design/agent-custom-secrets/assets/retry-evidence.json new file mode 100644 index 00000000000..1bf8b136a27 --- /dev/null +++ b/docs/design/agent-custom-secrets/assets/retry-evidence.json @@ -0,0 +1,96 @@ +{ + "invokes": [ + { + "session_id": "33b88425-15b0-4371-a6ad-7cff451f6df9", + "references": { + "application": { + "id": "01a071e5-0c43-7e71-951b-ded6d453b7e2", + "slug": "qa-secret-ui-8nj0m219" + }, + "application_variant": { + "id": "01a071e5-0c5f-78f3-8852-0d52fb0efdeb", + "slug": "qa-secret-ui-8nj0m219-default" + }, + "application_revision": { + "id": "01a07200-9347-7121-a6ea-d18b0d63856c", + "slug": "02f70d56d997", + "version": "1" + } + }, + "bindings": [ + { + "secret": { + "slug": "deployment-token" + }, + "binding": { + "type": "env", + "name": "DEPLOY_TOKEN" + } + } + ] + }, + { + "session_id": "33b88425-15b0-4371-a6ad-7cff451f6df9", + "references": { + "application": { + "id": "01a071e5-0c43-7e71-951b-ded6d453b7e2", + "slug": "qa-secret-ui-8nj0m219" + }, + "application_variant": { + "id": "01a071e5-0c5f-78f3-8852-0d52fb0efdeb", + "slug": "qa-secret-ui-8nj0m219-default" + }, + "application_revision": { + "id": "01a07200-9347-7121-a6ea-d18b0d63856c", + "slug": "02f70d56d997", + "version": "1" + } + }, + "bindings": [ + { + "secret": { + "slug": "deployment-token" + }, + "binding": { + "type": "env", + "name": "DEPLOY_TOKEN" + } + } + ] + }, + { + "session_id": "33b88425-15b0-4371-a6ad-7cff451f6df9", + "references": { + "application": { + "id": "01a071e5-0c43-7e71-951b-ded6d453b7e2", + "slug": "qa-secret-ui-8nj0m219" + }, + "application_variant": { + "id": "01a071e5-0c5f-78f3-8852-0d52fb0efdeb", + "slug": "qa-secret-ui-8nj0m219-default" + }, + "application_revision": { + "id": "01a07200-9347-7121-a6ea-d18b0d63856c", + "slug": "02f70d56d997", + "version": "1" + } + }, + "bindings": [ + { + "secret": { + "slug": "deployment-token" + }, + "binding": { + "type": "env", + "name": "DEPLOY_TOKEN" + } + } + ] + } + ], + "vaultCreates": 0, + "commits": 0, + "retryVisible": true, + "repeatedRequestUsedSavedBinding": true, + "recovered": true +} diff --git a/docs/design/agent-custom-secrets/context.md b/docs/design/agent-custom-secrets/context.md new file mode 100644 index 00000000000..45a519e6cb2 --- /dev/null +++ b/docs/design/agent-custom-secrets/context.md @@ -0,0 +1,56 @@ +# Context and scope + +## Current behavior + +Settings stores text and flat JSON custom secrets with stable project slugs. HTTP MCP +configuration can reference a text secret, and its setup drawer can create one. These +features do not provide a general environment-variable attachment for shell commands +or skills. A user who saves a GitHub token still cannot make it available as +`GITHUB_TOKEN` to such an agent through a supported attachment flow. + +The runner already distinguishes credential values from normal configuration and +hides model and HTTP MCP credentials through Daytona Secrets. The missing custom +consumer must not repurpose those model or MCP fields. + +## Milestone one: internal readable delivery + +An authorized user selects or creates a text secret, assigns its environment-variable +name, and saves the binding on the agent. The backend resolves selected values and the +runner injects them into that agent's environment. An agent can request a missing +credential through a card that opens the same attachment flow. + +The user sees: "This secret is available to the agent's scripts and shell commands." +The value goes directly from the secret form to the vault API, never through chat or +the tool result. Shared platform guidance tells the model to use credentials without +inspecting or exposing their values. This is behavioral guidance, not host enforcement +or a claim that the process cannot read the value. + +Milestone one includes local and Daytona delivery, the existing supported Pi, Claude, +and Codex harnesses, save-and-resume correctness, removal and rotation behavior, +redaction, validation, and permission checks. Start rollout on an internal deployment. +Do not weaken existing model/MCP hiding or add an unrelated public rollout flag. + +The desktop agent editor and its chat request card are the first UI surfaces. Shared +components must support OSS and EE. If mobile can receive the new interaction, it must +render the same action flow or explicitly keep the tool unavailable there; a paused +card with no action is not supported. Headless runs can use preconfigured bindings but +must not advertise a browser-only setup tool without an interaction handler. + +## Milestone two: host-restricted delivery policy + +Add a vault-owned policy for readable use or hidden HTTP delivery with exact allowed +HTTPS hosts. An agent author cannot widen that policy. Daytona creates placeholders +for hidden credentials and substitutes values only at permitted destinations. Hidden +credentials must never fall back to readable delivery. Local execution must reject a +hidden-only binding with a clear explanation. + +Specify normalization, disallowed hosts and URL forms, policy-change reconciliation, +and hidden-delivery verification in this milestone. Preserve milestone-one bindings +as explicitly readable; do not silently change their delivery when upgrading. + +## Exclusions + +Neither milestone commits to JSON expansion, secret files, per-skill permissions, +a generic secret-manager plugin interface, or a new durable cleanup service. Durable +Daytona resource reconciliation remains [#6438](https://github.com/Agenta-AI/agenta/issues/6438). +The existing vault and agent revision stores are sufficient for milestone one. diff --git a/docs/design/agent-custom-secrets/contracts.md b/docs/design/agent-custom-secrets/contracts.md new file mode 100644 index 00000000000..62dd54bdc17 --- /dev/null +++ b/docs/design/agent-custom-secrets/contracts.md @@ -0,0 +1,97 @@ +# Shipped contracts + +## Vault metadata + +A text custom secret may carry one optional `default_env_var` setting: + +```json +{ + "name": "GitHub token", + "slug": "github-token", + "format": "text", + "settings": {"default_env_var": "GITHUB_TOKEN"} +} +``` + +The shared Secret form shows **Default environment variable** directly below **Value**. It is optional metadata, not an Advanced field or a secret template. Settings and `request_secret` use the same Secret form controller and vault mutation. Successful callbacks expose saved identity and metadata, never raw content. + +When choosing an attachment name, the request's `env_var` wins, followed by the selected secret's `default_env_var`, followed by a derived suggestion. A user edit becomes an attachment-only override and remains stable when the selected secret changes. + +## Variant configuration + +The authored reference lives under `parameters.agent.sandbox.credentials`: + +```json +{ + "agent": { + "sandbox": { + "credentials": [ + { + "secret": {"slug": "github-token"}, + "binding": {"type": "env", "name": "GITHUB_TOKEN"} + } + ] + } + } +} +``` + +`secret.slug` is project-scoped vault identity. `binding.name` is variant configuration. V1 accepts text custom secrets and `env` bindings only. Omitted credentials and an empty list have the same meaning. + +Attachment requires permission to edit the secret and the agent. Desktop and mobile derive `edit_secret` from the authenticated project permission boundary. The backend enforces the write and runtime resolution boundaries. + +Names use `^[A-Za-z_][A-Za-z0-9_]*$`. Resolution rejects missing, deleted, wrong-format, empty, duplicate, reserved, or colliding bindings. It resolves only slugs referenced by the current project and preserves nonempty text verbatim. + +## Run transport + +The SDK resolves authored references into `sandboxCredentials` before invoking the runner: + +```json +{ + "sandboxCredentials": [ + { + "binding": {"kind": "environment", "name": "GITHUB_TOKEN"}, + "value": "resolved-at-run-time" + } + ] +} +``` + +Values are runtime credential material. They participate in the credential epoch used for rotation and stale-session detection. Binding shape participates in configuration identity. The runner injects the materialized environment into local or Daytona execution and rebuilds an incompatible parked environment after rotation or removal. + +The request can contain plaintext inside the protected SDK-to-runner transport. Redaction covers request logging, errors, traces, and saved diagnostics. Neither authored configuration nor client-tool output contains the value. + +## `request_secret` + +The reserved client tool uses `client:tool:request_secret:v0`. All input fields are required and additional fields are rejected: + +```json +{ + "name": "GitHub token", + "env_var": "GITHUB_TOKEN", + "reason": "Authenticate the requested repository operation" +} +``` + +`name` and `reason` are display text. `env_var` is the requested binding suggestion. The authenticated paused interaction supplies project, agent, session, and tool-call identity. + +A successful transaction creates or selects a vault secret, commits the binding to a variant revision, adopts that revision in the host, then settles the tool: + +```json +{ + "status": "configured", + "secret": {"slug": "github-token"}, + "env_var": "GITHUB_TOKEN", + "revision_id": "019d952f-0000-0000-0000-000000000000" +} +``` + +Cancellation returns `{"status":"cancelled"}`. A malformed request cannot open configuration. A binding already present for the requested environment can continue without creating or attaching it again. If vault creation succeeds but attachment fails, retry reuses the saved slug and does not create a duplicate. + +The host adopts the committed revision before auto-resume. A resume failure remains a visible conversation error and can be retried without repeating secret creation. + +## Shared UI and guidance + +The Secret form controller is shared by Settings and `request_secret`. The attachment drawer is shared by the **Advanced** configuration flow and the request dock. It receives bindings, requested metadata, edit identity, permission state, and a `commitBinding` callback. Agent settings render the section inside the existing **Advanced** drawer. + +The shared platform instructions tell harnesses to use configured variables for authentication, never inspect or print their values, call `request_secret` when available, and never ask users to paste credentials into chat. `request_connection` remains for integration connections. diff --git a/docs/design/agent-custom-secrets/plan.md b/docs/design/agent-custom-secrets/plan.md new file mode 100644 index 00000000000..b98097f0d9e --- /dev/null +++ b/docs/design/agent-custom-secrets/plan.md @@ -0,0 +1,133 @@ +# Implementation plan + +## Milestone one: readable custom secrets and setup cards + +### Bind, resolve, and inject + +Add the contract in [contracts.md](contracts.md) to the agent template, schema inspection, +configuration validation, SDK parsing, and internal run wire. Resolve text values through +the existing project-scoped runtime vault path. Write-only values require the existing +runtime `secret-resolve` grant; do not relax public vault reads or put that grant in the +browser or sandbox. + +Add custom credentials to runner redaction and collision checks before environment +composition. Inject only selected values into the target environment. Support local +and Daytona without changing existing model/MCP credential policy or using a global +`os.environ` mutation in a shared worker. + +Include the shared platform instructions in this implementation, using #6365's module. +Keep stable behavior rules there. Obtain the current attached variable names from current +configuration metadata, rather than depending on a generated list that can become stale. + +### Add one attachment flow + +Reuse the existing text secret form and vault mutations in a shared attachment drawer. +The user selects an existing text secret or creates one, chooses the variable name, and +sees the readable-delivery explanation before saving. Settings and the agent request +card enter this same flow. Existing JSON vault storage stays supported elsewhere. + +The shared secret UI belongs in `@agenta/entity-ui`; vault and workflow data operations +belong in `@agenta/entities`. The playground owns the run target and resume orchestration. +Pass that capability into the shared UI rather than importing playground state from it. +Register the interaction in the existing shared client-tool registry and chat rendering. +Do not copy the connection dock's state machine or create another polling service. + +Save attachments as an ordinary agent revision through the existing commit operation +with `base_revision_id`. This publishes configuration on that agent variant, not a new +production deployment. The form must name the agent/variant being changed. It must not +silently include unrelated unsaved editor changes: require the user to save or discard +those edits through the existing editor flow before attachment. + +### Complete the request card and resume + +Example: an agent needs `GITHUB_TOKEN` while processing a repository request. + +1. The agent calls `request_secret` with a name, proposed variable, and reason. The + runner emits the existing client-tool interaction and ends the turn paused. +2. The host displays a pending card with Configure and Cancel actions. Configure opens + the attachment drawer for the originating agent/variant, even if the user switches + the editor selection. Navigation never changes which agent receives the binding. +3. The user chooses an existing secret or creates a text secret. A successful create + returns its reference. Clear raw form content after submission or dismissal; do not + store it in local storage, conversation state, analytics, or interaction records. +4. Save the binding through the existing revision commit with the revision the form read. + A concurrent edit produces the existing conflict response. Refresh and ask the user + to review the attachment again; do not overwrite newer configuration. +5. Read the committed revision into the host's run configuration. Only then settle the + original tool call once with `status: configured`, the reference, and revision ID. + Use the existing interaction identity and settlement mechanism for duplicate clicks + and multiple mounted views. The browser reports saved configuration, not runtime readiness. +6. The existing automatic-resume path submits the same conversation/session with the + committed revision. It must not reuse an old draft or a pinned previous revision. + The backend validates that revision in the authenticated project and resolves its + bindings again; it never trusts a value or arbitrary target from the tool result. +7. Before the next harness turn, reconcile the environment with the new binding set. + If applying credentials fails, end with a visible runtime error and keep the saved + configuration. Do not let the model continue under the old environment. +8. After successful application, deliver the pending tool result and continue the same + conversation. The model can use `GITHUB_TOKEN` without seeing its value in the result. + +No separate "apply secret" API or frontend polling loop is required. The existing next +run request is the runtime application boundary. If the host cannot submit the updated +revision, report that limitation instead of claiming the attachment is ready. + +### Recover interrupted setup + +| Interruption | Required behavior | +| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| User cancels before saving a binding | Settle cancellation once. No automatic repeated request for the same declined need. | +| Secret creation succeeds, binding commit fails | Keep the saved vault entry and show "Saved in vault; not attached". Retry attachment using its reference, without creating again. | +| Create response is lost | Check the intended unique slug in the current project before retrying. Let the user select the resulting entry; do not overwrite it automatically. | +| Binding commit response is lost | Read current revision. If the exact binding is present, use it; otherwise retry from the current revision with user confirmation on conflict. | +| Browser reloads before settlement | Recover the pending interaction from existing session state, read the saved binding, and offer Continue if configured. Never recreate the secret automatically. | +| Resume request fails after settlement | Keep the configured result and expose the existing run retry. Retry the same saved revision, without another create/attach transaction. | +| Runtime resolution or application fails | Stop before model execution. Show a safe error; retry or edit the saved binding. | + +The vault entry and agent revision are separate resources. Do not add a distributed +transaction or automatically delete a successfully created secret after an attachment +failure; another consumer may already use it. + +### Apply changes between turns + +Resolve configured values on each run boundary. Reuse the runner's existing credential +change tracking and session reconciliation. Add, replace, remove, and value rotation must +all take effect before the next execution. Removed or replaced values must not survive in +reused daemon or child-process environments. + +For milestone one, choose correctness over a new live-patching mechanism: use the existing +supported reopen/rebuild path when a process cannot update its environment. Preserve the +session ID, conversation, and durable files. Changes may restart running processes and +lose transient process state; show that consequence in configuration UI. Do not mutate +credentials in the middle of an active turn. A request already executing finishes under +its original binding set; this version promises updates at the next run boundary. + +Implementation must prove environment refresh and transcript continuity separately for +Pi, Claude, and Codex on local and Daytona. If a backend cannot satisfy both, block that +configuration explicitly rather than silently executing stale credentials. No new session +lifecycle engine is part of this plan. + +## Milestone two: host-restricted delivery + +Extend the vault with readable or hidden HTTP delivery policy and normalized exact HTTPS +hosts. Keep agent attachments as references plus bindings; policy remains on the secret. + +Add hidden custom credentials to the existing Daytona Secret allocation and cleanup +mechanism. Cover exact-host substitution, no plaintext fallback, local rejection of hidden +policy, and policy changes before execution. Carry forward #5703's host-validation and +rotation requirements. Existing readable attachments keep that meaning until an explicit +policy change. A policy change that makes a running configuration unsupported must fail +closed at the next run boundary. + +## Implementation order and review + +1. Finish and land #6365, or implement on a child branch based on its reviewed head. +2. Add the milestone-one bindings, validation, resolution, runner handling, and shared + guidance together with contract and runtime tests. +3. Add the shared drawer, persisted attachment flow, and request tool. Validate the full + interrupted setup and resume cases before enabling the tool. +4. Run [QA](qa.md) against the complete feature on an internal deployment. +5. Design the exact vault policy changes for milestone two after the readable flow works. + +Milestone one is implemented and validated in this PR. See [README](README.md) for the +shipped behavior and [qa.md](qa.md) for the validation record. Milestone two, the vault +policy for readable secrets, is not implemented, so this PR must not close #5703. diff --git a/docs/design/agent-custom-secrets/qa-browser-checklist.md b/docs/design/agent-custom-secrets/qa-browser-checklist.md new file mode 100644 index 00000000000..55c4a92b62e --- /dev/null +++ b/docs/design/agent-custom-secrets/qa-browser-checklist.md @@ -0,0 +1,35 @@ +# Browser release checklist + +Run these checks against the real application with one editable project and one account that lacks secret-edit permission. Use a unique test secret and delete it after validation. + +## Manual attachment + +- Open an agent variant, expand **Advanced**, and attach an existing text secret. +- Confirm the environment suggestion uses the secret's default. Change it and confirm the override remains attachment-only. +- Save and refresh. Confirm the binding and vault display name remain visible. +- Edit the attachment, then remove it. Confirm removal keeps the vault secret and the next run no longer receives the variable. + +## Requested attachment + +- Start a conversation that calls `request_secret` with a unique environment name. +- Confirm the dock shows the requested name and reason. Open **Configure** and choose an existing secret. +- Repeat with **Create new**. Confirm **Default environment variable** appears directly below **Value** and begins with the requested environment name. +- Complete setup. Confirm the committed revision is adopted before the tool settles and the same conversation resumes automatically. +- Refresh after settlement. Confirm no pending card returns and no duplicate binding is created. + +## Recovery + +- Change an Advanced policy without saving. Confirm Attach, Edit, and Remove disable with save/discard guidance and no revision is committed. Discard the draft and confirm the actions return. +- Force attachment removal to fail. Confirm the dialog stays open with an error and retry succeeds without deleting the vault entry. + +- Cancel before saving. Confirm no vault secret or binding is created and the tool settles as cancelled. +- Force attachment commit to fail after vault creation. Retry and confirm the saved slug is reused without another vault create. +- Force resume to fail after a successful attachment. Confirm the error callout survives a transcript refresh. Retry the conversation and confirm it uses the adopted revision without recreating the secret; if the turn requests setup again, Continue reuses the saved binding. +- Change the agent revision while the drawer is open. Confirm the conflict is visible and retry does not overwrite unrelated configuration. + +## Permissions + +- With secret-edit and agent-edit permission, confirm create, attach, edit, and remove are enabled on desktop and mobile. +- Remove secret-edit permission. Confirm Advanced attachments are read-only and the request card cannot open configuration. +- Remove agent-edit permission while retaining secret-edit. Confirm vault access does not permit a variant write. +- During capability loading or a permission-check failure, confirm both hosts fail closed. diff --git a/docs/design/agent-custom-secrets/qa-browser-evidence.md b/docs/design/agent-custom-secrets/qa-browser-evidence.md new file mode 100644 index 00000000000..d7ff86fa65b --- /dev/null +++ b/docs/design/agent-custom-secrets/qa-browser-evidence.md @@ -0,0 +1,41 @@ +# Custom-secret browser evidence + +Tested on the local application at port 8980 with a disposable workflow and a separate Playwright browser context. The fixture used the fixed value `QA_PLACEHOLDER_NOT_A_REAL_CREDENTIAL`. Browser logs and screenshots excluded request bodies and secret content. + +## Requested setup and runtime verification + +The real desktop conversation called `request_secret`, proposed `DEPLOY_TOKEN`, and opened the shared form with that default directly below Value. Cancelling the setup and selecting Not now settled once; the agent did not repeat the request. On explicit retry, creating and attaching the approved dummy saved revision v1 and adopted it before the resume request. The same session continued, approved a Python command, and wrote a SHA-256 file whose content matched the dummy value. Browser run requests contained references rather than the dummy value. + +See [reference-only wire evidence](assets/browser-resume-evidence.json), [request form](assets/request-create-demo.png), and [resumed conversation](assets/resume-demo.png). The happy-path vault-response counter did not observe the canonical endpoint and is excluded from the evidence. + +## Advanced checks passed + +- The Advanced drawer shows one **Custom secrets** heading after the duplicate inner heading was removed. +- Create shows **Default environment variable** directly below **Value**. +- A saved default of `QA_DEFAULT_TOKEN` becomes the initial attachment name. +- Changing the attachment name to `QA_ATTACHMENT_TOKEN` commits the override without changing the saved default metadata. +- Create and attach issued one vault create and one revision commit. +- The host adopted the attached revision, changed the displayed variant from v0 to v1, and closed Advanced. +- Refresh showed one persisted attachment with its environment name and vault display name. No secret content appeared. +- Editing the binding to `QA_EDITED_TOKEN` issued one revision commit, adopted v2, and closed Advanced. +- Removal stated that the secret stays in the project vault, issued one revision commit, adopted v3, closed Advanced, and returned to the empty state. + +The screenshot at [assets/advanced-demo.png](assets/advanced-demo.png) shows the persisted attachment before removal and contains no secret content. + +## Partial-save recovery passed + +A real Advanced create/attach flow saved one fixed dummy vault entry, intercepted the first revision commit with HTTP 409, retained the selected saved secret, and succeeded on retry. The browser observed one vault create and two commit requests. The host adopted v4 and closed Advanced after the successful retry. + +## Advanced dirty and removal recovery passed + +Changing the local Advanced policy without saving showed save/discard guidance and disabled Attach, Edit, and Remove. No revision commit occurred. Reload discarded that temporary draft. An injected HTTP 409 during attachment removal kept the confirmation open with an inline error; retry succeeded, adopted v5, and retained the vault entry. See [recovery evidence](assets/advanced-recovery-evidence.json). + +## Failed-resume recovery passed + +An injected HTTP 503 after Continue left the saved attachment intact. The error callout survived transcript hydration and offered Try again. The existing retry confirmation restarted the turn using the same revision and session. Because this fixture explicitly instructed the model to request the secret again, it repeated the card; Continue reused the saved binding and the agent answered “Recovery complete.” Across failure, retry, and continuation, the browser observed zero vault creates and zero revision commits. See [request evidence](assets/retry-evidence.json) and [real-app capture](assets/retry-demo.png). + +The application currently renders overlapping copies of its global confirmation dialog. QA clicked the topmost visible button with ordinary pointer input; ARIA-role selectors could not address that copy. This existing confirmation-renderer issue is separate from credential setup. + +## Cleanup + +Both Advanced QA attachments were removed from the disposable variant. The vault test entries remain, including `qa-advanced-knog6u`; the separate request-flow fixture retains its dummy attachment for review. Automatic approval review rejected deletion of the identified Advanced vault entry. No real credentials were used. diff --git a/docs/design/agent-custom-secrets/qa.md b/docs/design/agent-custom-secrets/qa.md new file mode 100644 index 00000000000..da44a5f47cc --- /dev/null +++ b/docs/design/agent-custom-secrets/qa.md @@ -0,0 +1,90 @@ +# Acceptance checks + +These checks define the release target. Validation is partial and uses dummy credentials without +printing secret values. + +## Validation snapshot + +- The real desktop flow passed request, cancellation, create, attach, revision adoption, resume in + the same session, and a SHA-256 side effect using a fixed dummy value. Cancellation settled once + and did not repeat the request. See [browser evidence](qa-browser-evidence.md). +- The Advanced flow passed create with a default, attachment override, edit, removal, refresh, and + revision adoption. Its forced partial-save retry passed with one vault create across two commit attempts. The dirty-draft guard and failed-removal retry also passed in the browser. +- Failed-resume recovery passed after an injected HTTP 503. The ordinary turn retry reused the + saved revision; a repeated request used Continue without another vault create or revision commit. +- Local Pi passed the live S1 injection, rotation, removal, continuity, and no-plaintext checks. +- Daytona reached the live endpoint but could not run because the disposable project had no usable + OpenAI credential. This is not a passing Daytona result. +- The full Pi, Claude, and Codex matrix across local and Daytona has not run. + + +## Configuration and permissions + +- Select an existing text secret and create a new one through the same drawer. Both save + the expected binding on the named agent variant. JSON entries cannot be attached. +- Invalid, reserved, duplicate, or colliding variables fail clearly. Existing model/MCP + environment owners retain their values and policies. +- Missing, deleted, wrong-project, wrong-kind, empty, and unreadable secrets fail before + harness execution. API writes enforce the same permission rule as the editor. +- A moved `base_revision_id` produces a conflict without overwriting another edit. Retry + confirms the selected binding against the current revision. + +## Card completion and recovery + +- Request a secret, configure it, and resume the same conversation. The resumed request + uses the committed revision, and an authentication test proves the process received it. +- Cancel or decline the card. It settles once and the agent does not automatically repeat + the same request. Duplicate clicks and dock/inline renders do not double-settle. +- Reload after secret creation, after binding commit, and after settlement. Each case + resumes from the persisted vault/revision/interaction state without duplicate creation. +- Lose each save response and retry. Existing slug and exact binding checks recover the + completed write without overwriting unrelated state. +- Switch agents while the card is open. It still targets the originating agent/variant. +- Fail runtime resolution or application after successful configuration. No next model + turn executes with stale credentials; ordinary retry uses the already-saved binding. + +## Runtime and guidance + +Run the critical flow on Pi, Claude, and Codex with both local and Daytona environments. +For each, cover a fresh run, a previously warm conversation, value rotation, replacement, +and removal. Verify the conversation and durable files survive any reopen/rebuild, and +old values no longer exist in processes used for the next execution. + +Verify two independent runs never inherit each other's custom credential bindings. Local +execution keeps its existing host isolation limits; this test checks injection ownership, +not protection against deliberate host inspection. + +Verify the new guidance reaches fresh environments and environments upgraded before the +feature is enabled. Check presence in the delivered instruction channel, and separately +exercise a model request that would otherwise print credentials. Model behavior is useful +QA evidence but is not proof of a security boundary. + +Check traces, tool arguments/results, validation failures, analytics, saved diagnostics, +and browser persistence for raw values. Injection travels as credential material over the +protected internal transport; redact diagnostic copies of that request. + +Known-value redaction ignores values shorter than four characters to avoid replacing common +text throughout model output. Include a one-to-three-character dummy value in QA and record +that limitation rather than treating redaction as a complete confidentiality boundary. Readable +environment delivery also cannot prevent a sandbox process from transforming or encoding a +value before output. Guidance and redaction reduce accidental disclosure; host-restricted +delivery is the stronger boundary planned for milestone two. + +## Supported hosts and regressions + +Desktop OSS/EE can configure and fulfill the card. Mobile either fulfills the same flow or +does not advertise the tool. Headless clients can run saved bindings without becoming stuck +on an unsupported browser interaction. Regress existing MCP secret selection/creation, +write-only vault reads, connection cards, and permission approvals. + +Use the repository's SDK, service, runner, and frontend test suites for these behaviors, +then the agent release gate for live wire assertions. A green unit suite does not complete +the harness/backend matrix. + +## Milestone-two additions + +Verify hidden placeholders cannot authenticate at an unlisted host, can authenticate at +an exact permitted HTTPS host, and never fall back after policy or allocation failures. +Reject hidden policy locally. Check policy/value changes at the next run boundary and +cleanup using the existing Daytona resource lifecycle. An echo endpoint alone is not proof +of failed substitution because Daytona can scrub credential values in responses. diff --git a/docs/design/agent-custom-secrets/research.md b/docs/design/agent-custom-secrets/research.md new file mode 100644 index 00000000000..31e63c0eade --- /dev/null +++ b/docs/design/agent-custom-secrets/research.md @@ -0,0 +1,63 @@ +# Codebase research + +## Current user-facing behavior + +Custom-secret storage merged in [#4882](https://github.com/Agenta-AI/agenta/pull/4882). +HTTP MCP secret resolution merged in [#5296](https://github.com/Agenta-AI/agenta/pull/5296), +and inline secret creation merged in [#6143](https://github.com/Agenta-AI/agenta/pull/6143). +Old local-tools documents that call all custom secrets storage-only are historical. + +Daytona credential delivery merged in +[#5670](https://github.com/Agenta-AI/agenta/pull/5670) and became default-on in +[#5705](https://github.com/Agenta-AI/agenta/pull/5705). Those paths handle model and HTTP +MCP consumers. They do not implement general agent environment attachments. + +## Relevant implementation entry points + +Paths below were inspected on refreshed `origin/main`, except the platform-instruction +module, which was read from #6365's remote head. These are implementation locations, +not promises that the proposed fields already exist. + +| Responsibility | Existing path and finding | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Secret shape | `api/oss/src/core/secrets/dtos.py`: custom secret settings contain `format` and `content`; no delivery policy. | +| Vault access | `api/oss/src/apis/fastapi/vault/router.py`: project-scoped CRUD; `_for_caller` reveals write-only values only with `secret-resolve`. | +| Runtime grant | `api/oss/src/apis/fastapi/access/router.py`: `_run_credential_grants` requires platform-runtime proof or an existing verified grant. | +| Named resolution | `sdks/python/agenta/sdk/agents/platform/secrets.py`: explicit slug reads, text-only extraction; unresolved values currently return a partial map. New attachment caller must require every binding. | +| Agent template | `sdks/python/agenta/sdk/agents/dtos.py`: `AgentTemplate.from_params` parses `parameters.agent` and validates known shape. New nested credentials need schema/parser support. | +| Wire types | `sdks/python/agenta/sdk/agents/wire_models.py`, `utils/wire.py`, `services/runner/src/protocol.ts`: Python/TypeScript serialization boundary. | +| Daytona composition | `services/runner/src/engines/sandbox_agent/daytona-secret-plan.ts`: separate typed model/MCP consumers, collisions, and restricted local-use provider bindings. Arbitrary custom secrets cannot be inserted into those provider bindings. | +| Runtime identity | `services/runner/src/lifecycle/desired-state.ts` and `engines/sandbox_agent/session-identity.ts`: structure and credential material have separate tracking. Both identity views must stay consistent. | +| Existing secret form | `web/packages/agenta-entity-ui/src/secret/SecretForm/` and `CreateSecretDrawer.tsx`: reuse text creation and vault mutations. | +| Existing client interaction | `services/runner/src/engines/sandbox_agent/client-tools.ts`: common pause/correlation mechanism for browser-fulfilled tools. | +| Existing connection flow | `web/packages/agenta-entity-ui/src/clientTools/useConnectFlow.ts`: reference-only results, single settlement, explicit cancellation and failure. OAuth details do not belong in the custom-secret flow. | +| Tool catalog | `api/oss/src/core/workflows/static_catalog.py` and `sdks/python/agenta/sdk/agents/platform/workflow.py`: reserved platform client-tool definitions and resolution. | +| Host resume | `web/packages/agenta-playground/src/state/execution/agentApprovalResume.ts` and `agentRequest.ts`: client-tool resume eligibility and the run's configuration target. | +| Revision conflict protection | `api/oss/src/core/workflows/service.py`: ordered commits require `base_revision_id` and reject a moved variant head. Reuse this for attachment writes. | + +## Platform-instruction dependency + +[#6365](https://github.com/Agenta-AI/agenta/pull/6365) at +`ecb28ea14b3664f64da010948b8bf621db0fa0b9` introduces +`sdks/python/agenta/sdk/agents/platform_instructions.py`. Its base currently tells the +agent to use documented tools and never invent results. It has no secret-handling rule. + +Its runner composes the shared text before author text at environment build. Pi uses +append-system text; Claude and Codex use instruction files. Generated instructions stay +outside lifecycle identity, so warm environments retain their previous text. Its reported +live QA covers Pi; the full local/Daytona and harness matrix remains unverified. + +## Implementation checks still required + +Confirm the caller's runtime grant reaches custom-secret resolution in all hosted run +paths. Public standalone vault reads cannot reveal write-only values and must not be +weakened to make that case work. + +Trace the revision selected after client-tool settlement in desktop, EE, and mobile. +An existing `request_connection` reference-only result does not prove that a new agent +revision will be selected automatically. The feature needs an explicit host update. + +Measure the existing reopen/rebuild behavior for changed process credentials and restored +conversation history. A documented lifecycle hook is not proof that each harness reloads +its environment. These checks are implementation acceptance gates, not evidence collected +by this design PR. diff --git a/docs/design/agent-custom-secrets/simplification.md b/docs/design/agent-custom-secrets/simplification.md new file mode 100644 index 00000000000..699f8790f9c --- /dev/null +++ b/docs/design/agent-custom-secrets/simplification.md @@ -0,0 +1,29 @@ +# Simplification decisions + +V1 supports one task: attach a project text secret to an agent variant as an environment variable, including when an agent requests it during a conversation. + +## One binding model + +The variant stores `{secret.slug, binding:{type:"env",name}}` under `agent.sandbox.credentials`. Settings and paused conversations commit the same structure through ordinary revision semantics. There are no session grants, attachment tables, per-skill collections, or service presets. + +## One shared form and drawer + +The existing Secret form owns vault creation. Its only metadata extension is optional `default_env_var`, displayed directly below **Value**. The shared attachment drawer owns selection, creation, environment naming, edit identity, and retry state. Hosts own revision commit, adoption, settlement, and resume. + +This split prevents raw secret content from reaching host callbacks and lets a saved vault entry survive an attachment conflict. Retrying attachment reuses the slug rather than recreating the secret. + +## Existing lifecycle boundaries + +The SDK resolves references for each run and sends typed `sandboxCredentials` to the runner. The runner uses its existing environment composition, redaction, desired-state, and credential-epoch mechanisms. Rotation or removal invalidates stale parked state. No apply endpoint, readiness poll, browser-owned injection flag, or transaction service was added. + +## Separate client tools + +`request_secret` handles custom environment credentials. `request_connection` remains responsible for integration and OAuth connections. Both use the existing browser-fulfilled interaction lifecycle, but they do not share request schemas or domain-specific controllers. + +## Existing permissions + +V1 uses existing secret-edit, agent-edit, and run permissions. Desktop and mobile resolve the authenticated project's capability and fail closed while it is unknown. The API remains authoritative. No secret-use role or hardcoded role mapping was added. + +## Deferred V2 + +V2 may add host restrictions and opaque delivery. That work can introduce destination policy, allowlists, delivery modes, and Daytona-managed secret allocation after the readable flow has production evidence. V1 does not expose templates, advanced metadata, generic environment overrides, or live process patching. diff --git a/docs/design/agent-custom-secrets/status.md b/docs/design/agent-custom-secrets/status.md new file mode 100644 index 00000000000..d1ad2f57275 --- /dev/null +++ b/docs/design/agent-custom-secrets/status.md @@ -0,0 +1,50 @@ +# Status + +## Current phase + +Implementation and independent review are complete. Runtime, SDK, runner, shared entity, shared UI, desktop, and mobile paths are present in the isolated feature worktree. The real-application request, resume, and targeted recovery checks passed. The remaining runtime matrix is listed below. + +## Shipped decisions + +- Variant revisions own `agent.sandbox.credentials` references. +- The vault owns secret content and optional `default_env_var` metadata. +- The shared form places the optional default directly below **Value**. +- Request, secret default, and derived suggestion determine the initial binding in that order. User overrides apply only to the attachment. +- Settings and `request_secret` share the Secret form controller. Advanced configuration and the request dock share the attachment drawer. +- Secret attachments stay inside the existing **Advanced** agent configuration drawer. +- Save, adoption, settlement, and resume form one ordered host transaction. Retry after partial save reuses the saved vault entry. +- V1 uses existing secret-edit, agent-edit, and run permissions. It adds no role system. +- Runtime values travel only in typed `sandboxCredentials` and participate in existing redaction and credential lifecycle controls. + +## Validation evidence + +- Local Pi S1 passed injection, same-slug rotation, removal, continuity, and no-plaintext assertions. +- The real desktop request flow passed request, cancellation without repetition, dummy-secret save, + v1 commit, adoption before resume, same-session resume, and a matching Python SHA-256 file side + effect. The fixed value was a test sentinel, not a credential. +- The real Advanced flow passed create/default/override, refresh, edit, removal, revision + adoption, partial-save retry without another vault create, dirty-draft blocking, and failed-removal retry. See [browser evidence](qa-browser-evidence.md). +- Failed-resume recovery passed after an injected HTTP 503. Retry preserved the saved revision and session with no vault create or revision commit. +- The frontend transaction suite passes six tests for commit and adoption behavior. +- The real chat hook suite passes ten tests, including adopted-revision auto-resume. +- The production Storybook build contains 719 entries. Headless Chrome rendered the native request card, request-to-create drawer, requested `GITHUB_TOKEN` default, and preserved designer reference. +- The web lint run passed all 25 tasks. +- Entity transforms and UI attachment helpers passed their package suites. + +The Daytona live run is blocked because the environment has no usable OpenAI credential. This is an environment blocker, not a passing Daytona result. + +## Remaining release work + +- Run the remaining Pi, Claude, and Codex matrix across local and Daytona. Daytona currently needs + a usable model credential in the disposable project. + +Use [the browser checklist](qa-browser-checklist.md) for the remaining UI verification. + +## Storybook + +Native stories are published under: + +- `@agenta/entity-ui/Secret/AgentSecretAttachmentDrawer` +- `@agenta/entity-ui/Secret/SecretRequestDock` + +The original designer asset remains under `Design review/Agent custom secrets`. Storybook packages the reference through `.storybook` static directories, and its manager and preview heads preserve the cache-busting `index.json` behavior. diff --git a/docs/design/agent-workflows/documentation/adapters/agenta.md b/docs/design/agent-workflows/documentation/adapters/agenta.md index e8d669920ec..ab602ab3fcb 100644 --- a/docs/design/agent-workflows/documentation/adapters/agenta.md +++ b/docs/design/agent-workflows/documentation/adapters/agenta.md @@ -5,9 +5,6 @@ adapter](pi.md) and produces a Pi-shaped config, so it inherits everything Pi do tools, the system-prompt layers, tracing). What it adds is a fixed set of Agenta-shipped extras that the agent author cannot turn off: -- **Forced tools**: always unioned into the agent's resolved tools. At minimum `read` - (Pi only renders the skills section when `read` is enabled) and `bash` (so skills can run - their helper scripts). - **Forced skills**: Agenta-shipped Pi skills loaded on every run. - **A base AGENTS.md preamble**: the author's `instructions` are appended after it. - **A base persona**: forced onto Pi's `append_system`, with any author-supplied @@ -25,15 +22,14 @@ The forced *policy* lives in the SDK harness layer, in one editable module: `SessionConfig`, exactly where `PiHarness` and `ClaudeHarness` do their own translation. The forced skill *files* live with the runner that runs Pi, under -`services/agent/skills//` (each a directory with a `SKILL.md`). Skills are real files on +`services/runner/skills//` (each a directory with a `SKILL.md`). Skills are real files on disk because they reference relative scripts and assets, so they cannot ride the wire as text. The contract between the two halves is the skill **name**: `AGENTA_FORCED_SKILLS` lists names, and each must match a committed directory under the runner's skills root. Because the Agenta harness IS Pi, its tools are delivered the Pi-native way (through the -extension on the ACP path), never over MCP. There is no forced tool set any more: the runner -activates all seven Pi built-ins on every Pi run, so `read` and `bash` are there without anything -forcing them. +extension on the ACP path), never over MCP. The runner activates all seven Pi built-ins on every +Pi run, so `read` and `bash` are there without additional configuration. ## How a skill reaches the model @@ -47,7 +43,7 @@ runner lays the bundled directories into the Pi agent dir. 2. `runSandboxAgent` resolves each name against its bundled `skills/` root (`engines/skills.ts`, override with `AGENTA_AGENT_SKILLS_DIR`) and writes the directories into the Pi agent dir's `skills/` (user scope). -3. Pi loads them, and because the forced `read` tool is enabled, surfaces them in the system +3. Pi loads them, and because the native `read` tool is active, surfaces them in the system prompt. The model reads a skill's `SKILL.md` on demand (progressive disclosure). ## Two prompt layers, kept distinct @@ -86,7 +82,7 @@ removed after the run. A plain `pi_core` run is unchanged (it installs only the the shared agent dir). The base AGENTS.md preamble rides the wire as `agentsMd` (written into the session `cwd`), and -the forced `read` / `bash` tools are Pi defaults under pi-acp. The persona rides the wire as +the active `read` / `bash` built-ins are Pi defaults under pi-acp. The persona rides the wire as `appendSystemPrompt` and the engine writes it into the per-run Pi agent dir as `APPEND_SYSTEM.md` (`engines/sandbox_agent/pi-assets.ts`), so Pi loads it on the run. Daytona skill uploads are UTF-8 text only (`writeFsFile` takes a string body); binary skill assets are diff --git a/docs/design/agent-workflows/documentation/tools.md b/docs/design/agent-workflows/documentation/tools.md index a2857457e54..79dc0065e08 100644 --- a/docs/design/agent-workflows/documentation/tools.md +++ b/docs/design/agent-workflows/documentation/tools.md @@ -104,7 +104,7 @@ because it is the seam between the two lives of a tool: The resolved specs are also defined in `tools/models.py` (`CallbackToolSpec`, `CodeToolSpec`, `ClientToolSpec`), and the matching TypeScript shape is `ResolvedToolSpec` in -`services/agent/src/protocol.ts`. A run bundles them as a `ResolvedToolSet`: the list of specs +`services/runner/src/protocol.ts`. A run bundles them as a `ResolvedToolSet`: the list of specs and one `ToolCallback` (the endpoint callback tools post back to). ## How tools get resolved (the service side) @@ -189,13 +189,13 @@ is runner authorization policy, not a harness tool specification. The runner has to hand resolved tools to a harness, and harnesses do not accept tools the same way. The runner branches on a capability, `mcpTools`, not on the harness name (the branch is -`buildSessionMcpServers` in `services/agent/src/engines/sandbox_agent/mcp.ts`). A harness that +`buildSessionMcpServers` in `services/runner/src/engines/sandbox_agent/mcp.ts`). A harness that reports it can take tools over MCP gets them that way; a harness that cannot gets them natively. Today that splits cleanly into two paths. - **Pi takes native tools.** Pi has an extension API, so the runner registers each resolved spec as a Pi tool directly. The bundled Pi extension - (`services/agent/src/extensions/agenta.ts`) reads the public specs from + (`services/runner/src/extensions/agenta.ts`) reads the public specs from `AGENTA_TOOL_PUBLIC_SPECS` and registers them from inside Pi, then Pi runs the tool body the runner gives it. Pi gets no MCP server at all here: `buildSessionMcpServers` returns an empty list for Pi, so neither the synthetic `agenta-tools` server nor any user MCP server is @@ -225,7 +225,7 @@ natively. Today that splits cleanly into two paths. [runner-to-MCP interface page](../interfaces/cross-service/runner-to-mcp-server.md). Both paths funnel execution through one function, `runResolvedTool` in -`services/agent/src/tools/dispatch.ts`. It is the single place that branches on `kind`, so how +`services/runner/src/tools/dispatch.ts`. It is the single place that branches on `kind`, so how a tool type executes is defined once, not three times. ## Execution, type by type @@ -237,7 +237,7 @@ picks the tool and supplies the arguments, who actually runs it, and where? Execution is a callback. The harness selects the tool and supplies arguments, but the runner does not run the integration. The tool body POSTs the call to Agenta's `POST /tools/call` -(`services/agent/src/tools/callback.ts`, `callAgentaTool`), sending the `call_ref` slug and +(`services/runner/src/tools/callback.ts`, `callAgentaTool`), sending the `call_ref` slug and the model's arguments in an OpenAI-style envelope. The API re-resolves the connection, runs the Composio action through the provider adapter (`execute_tool` in `core/tools/service.py`), and returns the result, which the runner hands back to the model verbatim. @@ -317,7 +317,7 @@ new endpoint and no hidden logic. It resolves to a `CallbackToolSpec` carrying a descriptor (`{method, path, body?, context?, args_into?}`) instead of a `call_ref`, so the runner calls the endpoint directly with the run's caller credential. There is no `/tools/call` hop. The SSRF guard binds the call to the run's own Agenta origin and confines it to the API mount -(`directCallUrl` in `services/agent/src/tools/direct.ts`); the same dispatch handles the Daytona +(`directCallUrl` in `services/runner/src/tools/direct.ts`); the same dispatch handles the Daytona relay path. The runner needs no platform-specific code — it dispatches any `call` opaquely (the branch already exists for reference tools). @@ -333,7 +333,7 @@ direct call. ### Code tools: the runner runs them locally Execution is a local subprocess inside the runner. `runCodeTool` -(`services/agent/src/tools/code.ts`) writes the snippet to a temp file, spawns `python3` or +(`services/runner/src/tools/code.ts`) writes the snippet to a temp file, spawns `python3` or `node`, passes the model's arguments as JSON on stdin, and reads the JSON result from stdout. There is no callback. The code runs where the harness runs. @@ -347,7 +347,7 @@ Node as `main(inputs)`. A non-zero exit or a timeout becomes a tool error so the continues rather than crashing the run. The production image ships the interpreters: the runner Dockerfile installs `python3` -(`services/agent/docker/Dockerfile`), and `node` is already present. An earlier missing +(`services/runner/docker/Dockerfile.gh`), and `node` is already present. An earlier missing `python3` made Python code tools fail with `spawn python3 ENOENT`; that is fixed. One real constraint remains: the child only has the interpreter and the tool's own secrets, with no package-install step and no `NODE_PATH` to the runner's modules. So a code tool is limited to @@ -395,7 +395,7 @@ member of `RenderHint` that asks the frontend to draw the connect widget. ### Built-in tools: the harness runs them natively, gated through the same relay Execution is the harness's own. A built-in tool is just a name. The runner adds it to the -session's allowlist and Pi runs its own implementation of `read`, `write`, `web_search`, and so +session's allowlist and Pi runs its own implementation of `read`, `write`, `bash`, and so on. Nothing is resolved and nothing is delivered. Note that built-ins are a Pi concept here; they are not delivered to non-Pi harnesses over ACP, which bring their own native tool set. @@ -425,8 +425,10 @@ parity test pins that copy against the same fixture. Because activation is unconditional, the seven names are reserved. Pi registers custom tools in the same registry as its builtins, so a custom tool named `read` would replace the builtin `read` -silently. `ToolResolver` refuses such a config with `ReservedToolNameError`, and the extension -skips a colliding spec rather than registering it. +silently. The SDK's `ToolResolver` refuses such a declared custom tool with +`ReservedToolNameError`, and the extension skips a colliding spec rather than registering it. The +runner also folds colliding names into the built-in identity when matching permissions on an +unvalidated `/run` payload, so the same defense applies at the execution boundary. The wire's `tools` field is deprecated. A current runner ignores it. The SDK still fills it with all seven names so a runner from before this change — which read it as a grant list — activates @@ -679,18 +681,18 @@ never drift from the files that exist. The canonical playbook format lives in th | Discovery endpoint + reserved-handler dispatch | `api/oss/src/apis/fastapi/tools/router.py` (`/tools/discover`, `_call_reserved_agenta_tool`) | | Server-side platform-op handlers (reserved-ref registry, `test_run`) | `api/oss/src/core/tools/platform_handlers.py` | | Build-kit overlay defaults (`DEFAULT_BUILD_KIT_OPS` + skill/tool embeds) | `api/oss/src/apis/fastapi/applications/overlay.py` | -| Wire contract | `services/agent/src/protocol.ts`, `sdks/python/agenta/sdk/agents/utils/wire.py` | -| Tool-delivery fork (branch on `mcpTools`) | `services/agent/src/engines/sandbox_agent/mcp.ts` | -| Runtime dispatch (branch on `kind`) | `services/agent/src/tools/dispatch.ts` | -| Callback transport | `services/agent/src/tools/callback.ts` | -| Code execution | `services/agent/src/tools/code.ts` | +| Wire contract | `services/runner/src/protocol.ts`, `sdks/python/agenta/sdk/agents/utils/wire.py` | +| Tool-delivery fork (branch on `mcpTools`) | `services/runner/src/engines/sandbox_agent/mcp.ts` | +| Runtime dispatch (branch on `kind`) | `services/runner/src/tools/dispatch.ts` | +| Callback transport | `services/runner/src/tools/callback.ts` | +| Code execution | `services/runner/src/tools/code.ts` | | Daytona/non-Pi relay (runner-side loop) | `services/runner/src/tools/relay.ts` | | In-sandbox relay writer + wire protocol | `services/runner/src/tools/relay-client.ts`, `relay-protocol.ts` | | Relay wake sources (local `fs.watch`, Daytona watch exec) | `services/runner/src/tools/relay-watch.ts` | -| Pi native delivery | `services/agent/src/extensions/agenta.ts` | +| Pi native delivery | `services/runner/src/extensions/agenta.ts` | | `agenta-tools` channel for non-Pi harnesses (local loopback HTTP) | `services/runner/src/tools/mcp-bridge.ts`, `services/runner/src/tools/tool-mcp-http.ts` | | `agenta-tools` channel on Daytona (in-sandbox stdio shim: entrypoint, env contract, upload) | `services/runner/src/tools/tool-mcp-stdio.ts`, `services/runner/src/tools/tool-mcp-env.ts`, `services/runner/src/engines/sandbox_agent/tool-mcp-assets.ts` | -| Capability probe | `services/agent/src/engines/sandbox_agent/capabilities.ts` | +| Capability probe | `services/runner/src/engines/sandbox_agent/capabilities.ts` | | Permission decision (shared by both gates) | `services/runner/src/permission-plan.ts` | | ACP responder (`ApprovalResponder`) | `services/runner/src/responder.ts` | | Tool relay enforcement | `services/runner/src/tools/relay.ts` | diff --git a/docs/design/agent-workflows/interfaces/README.md b/docs/design/agent-workflows/interfaces/README.md index e944eaecdb3..945b01fe5d7 100644 --- a/docs/design/agent-workflows/interfaces/README.md +++ b/docs/design/agent-workflows/interfaces/README.md @@ -45,11 +45,11 @@ page. `Status` is read from each page's prose: **stable** (wired and unlikely to | [`/inspect`](public-edge/workflow-inspect.md) | public | `agent/schemas.py`, `agent/app.py` (builtin-URI binding), `models/workflows.py`, `decorators/routing.py` | stable | `unit/agents/test_dtos_agent_config.py`, `unit/agent/test_builtin_uri_binding.py` | | [`/messages`](public-edge/agent-messages.md) | public | `adapters/vercel/{routing,messages,stream}.py`, `agentRequest.ts` | evolving (create-or-resume not observable until storage lands) | `utils/test_messages_endpoint.py`, `unit/agents/test_ui_messages.py` | | [Agent config schema](public-edge/agent-config-schema.md) | public | `agent/schemas.py`, `sdk/utils/types.py`, `agents/dtos.py` (`HARNESS_IDENTITIES`), `sdk/agents/pi_builtins.py` (`PI_BUILTIN_TOOL_NAMES`) | stable | `unit/agents/test_dtos_agent_config.py`, `unit/agents/test_harness_identity.py`, `unit/agents/test_pi_builtins_parity.py` + `golden/pi_builtin_tools.json`, `services/oss/tests/pytest/unit/agent/test_default_agent_template.py` | -| [`/run`](cross-service/service-to-agent-runner.md) | cross-service (the spine) | `protocol.ts`, `utils/wire.py`, `utils/ts_runner.py`, `server.ts`/`cli.ts` | stable (pinned by golden) | `unit/agents/test_wire_contract.py` + `golden/`, `services/agent/tests/unit/wire-contract.test.ts` | -| [Runner to harness](cross-service/runner-to-harness.md) | cross-service (ACP) | `engines/sandbox_agent.ts` + `sandbox_agent/{run-plan,capabilities,permissions}.ts` | evolving | `services/agent/tests/unit/sandbox-agent-*.test.ts` | +| [`/run`](cross-service/service-to-agent-runner.md) | cross-service (the spine) | `protocol.ts`, `utils/wire.py`, `utils/ts_runner.py`, `server.ts`/`cli.ts` | stable (pinned by golden) | `unit/agents/test_wire_contract.py` + `golden/`, `services/runner/tests/unit/wire-contract.test.ts` | +| [Runner to harness](cross-service/runner-to-harness.md) | cross-service (ACP) | `engines/sandbox_agent.ts` + `sandbox_agent/{run-plan,capabilities,permissions}.ts` | evolving | `services/runner/tests/unit/sandbox-agent-*.test.ts` | | [Runner to MCP server](cross-service/runner-to-mcp-server.md) | cross-service | `agents/mcp/`, `engines/sandbox_agent/{mcp,tool-mcp-assets,relay-guard}.ts`, `tools/{mcp-bridge,tool-mcp-http,tool-mcp-stdio,tool-mcp-env,relay,relay-client,relay-protocol,relay-watch}.ts` | evolving (internal channel delivered locally over loopback HTTP and on Daytona via the in-sandbox stdio shim, `client` tools included — a client call parks via a paused relay answer; user stdio disabled) | `services/runner/tests/unit/{mcp-servers,session-mcp-layering,tool-mcp-assets,tool-mcp-stdio,tool-relay-guard}.test.ts` | -| [Runner to tool callback](cross-service/runner-to-tool-callback.md) | cross-service | `tools/{callback,dispatch,direct}.ts`, `apis/fastapi/tools/router.py` (`/tools/call`, `/tools/discover`, `_call_reserved_agenta_tool`), `core/tools/{discovery,service,platform_handlers}.py`, `agent/tools/resolver.py` | evolving (the `call` descriptor is wired and platform ops emit it; the legacy `tools.agenta.find_capabilities` route is deleted; reserved refs now dispatch server handlers, resolution flag-gated off until the runner half lands) | `services/agent/tests/unit/{code-tool,extension-tools}.test.ts`, `api unit/tools/{test_workflow_tool_call,test_discovery,test_platform_handlers}.py`, `unit/agents/platform/test_op_catalog.py` | -| [Service and runner trace export](cross-service/service-and-runner-trace-export.md) | cross-service | `agent/tracing.py`, `tracing/otel.ts`, `extensions/agenta.ts` | stable | `services/agent/tests/unit/` | +| [Runner to tool callback](cross-service/runner-to-tool-callback.md) | cross-service | `tools/{callback,dispatch,direct}.ts`, `apis/fastapi/tools/router.py` (`/tools/call`, `/tools/discover`, `_call_reserved_agenta_tool`), `core/tools/{discovery,service,platform_handlers}.py`, `agent/tools/resolver.py` | evolving (the `call` descriptor is wired and platform ops emit it; the legacy `tools.agenta.find_capabilities` route is deleted; reserved refs now dispatch server handlers, resolution flag-gated off until the runner half lands) | `services/runner/tests/unit/{code-tool,extension-tools}.test.ts`, `api unit/tools/{test_workflow_tool_call,test_discovery,test_platform_handlers}.py`, `unit/agents/platform/test_op_catalog.py` | +| [Service and runner trace export](cross-service/service-and-runner-trace-export.md) | cross-service | `agent/tracing.py`, `tracing/otel.ts`, `extensions/agenta.ts` | stable | `services/runner/tests/unit/` | | [Service to vault and tool providers](cross-service/service-to-vault-and-tool-providers.md) | cross-service (external) | `agent/app.py`, `platform/{resolve,connections}.py`, `agents/capabilities.py`, `tools/router.py` | stable | `unit/agents/connections/`, `unit/agents/platform/`, `unit/agents/tools/` | | [Agent service handler](in-service/agent-service-handler.md) | in-service | `services/oss/src/agent/app.py` | stable | `services/oss/tests/pytest/unit/agent/` | | [Neutral runtime DTOs](in-service/neutral-runtime-dtos.md) | in-service | `agents/dtos.py` | evolving | `unit/agents/test_dtos_*.py`, `test_harness_identity.py`, `test_agent_composition_seam.py` | @@ -60,11 +60,11 @@ page. `Status` is read from each page's prose: **stable** (wired and unlikely to | [Tool models and resolution](in-service/tool-models-and-resolution.md) | in-service | `agents/tools/{models,interfaces,resolver}.py`, `platform/{gateway,workflow,op_catalog,platform_tools}.py` | evolving | `unit/agents/tools/`, `unit/agents/platform/test_gateway_connection_resolve.py`, `unit/agents/platform/test_op_catalog.py` | | [MCP models and resolution](in-service/mcp-models-and-resolution.md) | in-service | `agents/mcp/{models,resolver,wire}.py` | evolving (stdio wired; remote deferred; resolution feature-gated) | `unit/agents/mcp/` | | [Model connection resolution](in-service/model-connection-resolution.md) | in-service | `agent/app.py`, `agents/connections/`, `platform/{resolve,connections}.py`, `agents/capabilities.py` | stable | `unit/agents/connections/` | -| [Runner engine internals](in-service/runner-engine-internals.md) | in-service (runner) | `server.ts`, `cli.ts`, `engines/sandbox_agent.ts` | stable | `services/agent/tests/unit/{server,cli}.test.ts` | -| [Permission responder](in-service/permission-responder.md) | in-service (runner) | `responder.ts`, `engines/sandbox_agent/permissions.ts` | stable | `services/agent/tests/unit/{responder,sandbox-agent-permissions}.test.ts` | -| [Sandbox permission](in-service/sandbox-permission.md) | in-service (runner) | `agents/dtos.py`, `protocol.ts`, `engines/sandbox_agent/{provider,run-plan}.ts` | evolving (network enforced on Daytona only; local rejected; filesystem nowhere) | `services/agent/tests/unit/{sandbox-agent-provider,sandbox-agent-run-plan}.test.ts` | +| [Runner engine internals](in-service/runner-engine-internals.md) | in-service (runner) | `server.ts`, `cli.ts`, `engines/sandbox_agent.ts` | stable | `services/runner/tests/unit/{server,cli}.test.ts` | +| [Permission responder](in-service/permission-responder.md) | in-service (runner) | `responder.ts`, `engines/sandbox_agent/permissions.ts` | stable | `services/runner/tests/unit/{responder,sandbox-agent-permissions}.test.ts` | +| [Sandbox permission](in-service/sandbox-permission.md) | in-service (runner) | `agents/dtos.py`, `protocol.ts`, `engines/sandbox_agent/{provider,run-plan}.ts` | evolving (network enforced on Daytona only; local rejected; filesystem nowhere) | `services/runner/tests/unit/{sandbox-agent-provider,sandbox-agent-run-plan}.test.ts` | -Paths are relative to the owner package (`sdks/python/agenta/sdk/`, `services/agent/src/`, +Paths are relative to the owner package (`sdks/python/agenta/sdk/`, `services/runner/src/`, `services/oss/src/`, `api/oss/src/`); test paths are relative to each package's pytest root unless prefixed. diff --git a/docs/design/agent-workflows/interfaces/in-service/harness-adapters.md b/docs/design/agent-workflows/interfaces/in-service/harness-adapters.md index 7a25c609de4..20b81f6f0d4 100644 --- a/docs/design/agent-workflows/interfaces/in-service/harness-adapters.md +++ b/docs/design/agent-workflows/interfaces/in-service/harness-adapters.md @@ -29,7 +29,7 @@ Each adapter implements `_to_harness_config(...)` and emits a different `/run` w inline skill packages on the wire like the others; the runner materializes them under `.claude/skills` in the session cwd, matching Claude's project-local skill layout. - **`AgentaHarness`** runs on the same Pi engine but forces Agenta's opinion: it composes the - base instructions over the author's, forces the Agenta tool set, and layers the Agenta + base instructions over the author's, forces the Agenta skills and persona, and layers the persona into `append_system`. - **`CodexHarness`** drives the `codex` ACP agent. It delivers custom tools over the internal `agenta-tools` MCP channel (like Claude) and renders `.codex/config.toml` (`codex_settings.py`). @@ -53,7 +53,7 @@ The wire shapes, side by side: | | Pi | Claude | Agenta | |---|---|---|---| -| built-in tools | yes | no | forced set | +| built-in tools | yes | no | yes | | custom tools | native | over MCP | native | | prompt overrides | `system`/`append_system` | none (reads `harness_kwargs`) | forced `append_system` + author `system` | | permission policy | carried, enforced by the relay | carried, enforced by settings + the responder | carried, enforced by the relay | diff --git a/docs/design/agent-workflows/interfaces/public-edge/agent-config-schema.md b/docs/design/agent-workflows/interfaces/public-edge/agent-config-schema.md index 2ea42a29b0e..9824eeff486 100644 --- a/docs/design/agent-workflows/interfaces/public-edge/agent-config-schema.md +++ b/docs/design/agent-workflows/interfaces/public-edge/agent-config-schema.md @@ -29,6 +29,9 @@ The fields and the full schema follow. | `sandbox_permission` | `SandboxPermission \| null` | `null` (form pre-fills one) | The declared network and filesystem boundary. See [Sandbox permission](../in-service/sandbox-permission.md). | | `skills` | `(SkillConfig \| EmbedRef)[]` | `[]` (the playground overlay embeds the `build-an-agent` playbook) | Inline SKILL.md packages, or `@ag.embed` references the backend inlines before the runner sees them. | +For legacy compatibility, a saved `builtin` entry in `tools` is accepted, ignored with a warning, +and rendered nowhere. Pi's built-ins are always active and are no longer configured in this field. + Note that `harness`, `sandbox`, and `permissions` are the run-selection fields. They live on `AgentConfig` itself, under `data.parameters.agent`, and the handler reads them in the one `AgentConfig.from_params(...)` parse along with the rest of the config. There is one agent diff --git a/docs/design/agent-workflows/projects/default-agent-builtins/addendum-always-active.md b/docs/design/agent-workflows/projects/default-agent-builtins/addendum-always-active.md index d9e6f513b3d..4513afbfe32 100644 --- a/docs/design/agent-workflows/projects/default-agent-builtins/addendum-always-active.md +++ b/docs/design/agent-workflows/projects/default-agent-builtins/addendum-always-active.md @@ -30,11 +30,12 @@ remember to carry the four entries along. The location was the problem, not the The runner activates all seven every time it starts Pi; Pi alone activates only four. Activation is unconditional platform behavior. Permission interception stays conditional on the policy. -2. Permission control for Pi happens only through the main policy +2. Permission control for the seven Pi built-ins happens only through the main policy (`runner.permissions.default`: `allow`, `allow_reads`, `ask`, `deny`) and the three editable - rule lists `harness.permissions.{allow, ask, deny}`. The default is unchanged: all three lists - empty, policy `allow_reads`, so `read`, `grep`, `find` and `ls` run unattended and `bash`, - `edit` and `write` show the approval card. + rule lists `harness.permissions.{allow, ask, deny}`. Custom tools may still carry an explicit + `permission` override. The default is unchanged: all three lists empty, policy `allow_reads`, + so `read`, `grep`, `find` and `ls` run unattended and `bash`, `edit` and `write` show the + approval card. 3. The Permissions drawer shows the three lists, editable. The author can pick any of the seven canonical names (`Read`, `Bash`, `Edit`, `Write`, `Grep`, `Find`, `Ls`) into allow, ask or deny, and pattern rules such as `Bash(npm run:*)` stay supported and visible. A grant made from diff --git a/docs/design/session-control-and-live-events/README.md b/docs/design/session-control-and-live-events/README.md new file mode 100644 index 00000000000..61f6cd0b024 --- /dev/null +++ b/docs/design/session-control-and-live-events/README.md @@ -0,0 +1,35 @@ +# Session control and live events + +> AGENT-GENERATED, low weight. Draft for discussion. Mahmoud makes final decisions. + +This folder holds the design work for session execution control, shared live output, +durable event replay, and durable commands. + +## Reading order + +1. [Context](context.md) explains the user problems and the current system boundary. +2. [Requirements](requirements.md) lists the open issues and draft system requirements. +3. [Decisions](decisions.md) separates confirmed decisions from proposals and open questions. +4. [Plan](plan.md) defines the design tracks and the order of discussion. +5. [Research](research.md) records verified repository findings and external dependency checks. +6. [Record properties](records-invariants.md) evaluates the existing record model before storage + options are compared. +7. [RFC](rfc.md) is the living architecture proposal. It remains incomplete until each track is discussed. +8. [Status](status.md) records current progress and the next discussion. +9. [Tonight handoff](tonight-handoff.md) contains independent spike and implementation briefs. + +## Terms under review + +- **Session:** One durable conversation and its workspace. +- **Execution:** One runner attempt that can start, pause, complete, fail, or be cancelled. +- **Conversation turn:** One user message and the resulting agent response. One conversation turn + can contain several executions when an approval pauses and resumes work. +- **Runner:** The service that starts a sandbox and drives the coding harness. +- **Harness:** The coding-agent program inside the sandbox, such as Pi or Claude Code. +- **Live frame:** A temporary output update, such as a text delta or tool progress update. +- **Durable event:** An append-only saved fact used for replay and recovery. +- **Command:** A saved request to send, cancel, approve, queue, or steer. +- **Lease:** Temporary proof that one runner owns a session or execution. + +The names are provisional. The contract discussion must settle how these terms map to the +existing `turn_id` and `turn_index` fields. diff --git a/docs/design/session-control-and-live-events/api-design.md b/docs/design/session-control-and-live-events/api-design.md new file mode 100644 index 00000000000..76e6fef6095 --- /dev/null +++ b/docs/design/session-control-and-live-events/api-design.md @@ -0,0 +1,469 @@ +# API design: the routes version one exposes + +> AGENT-GENERATED, low weight. Draft for discussion. Mahmoud makes final decisions. + +This file holds the route contracts considered for the durable-command work. Version one adds the +public Cancel route, the internal outcome route, and the runner's direct Cancel route; the +long-poll claim contract is explicitly deferred. Everything else in the RFC's public interface +section stays in [the RFC](rfc.md). + +The design behind these routes is in +[the durable command design](spike-b-durable-commands-design.md). Read that first for the state +machine, the lease, the settlement rule and the failure cases. + +Version one ships the direct-call adapter behind the replaceable control-delivery port. The two +long-poll routes remain future contracts; selecting `long_poll` currently fails startup rather +than silently choosing an unimplemented transport. + +Conventions taken from the existing code, not invented here: + +- Request and response models live in `api/oss/src/apis/fastapi/sessions/models.py`, are plain + Pydantic models, and set `model_config = ConfigDict(extra="forbid")` on new request bodies + (`SessionQueryRequest`, `models.py:59`). +- List responses carry `count` plus the list (`SessionsResponse`, `models.py:105`). +- Domain errors are typed exceptions in a `types.py`, mapped to status codes by one decorator on the + router (`_handle_session_exceptions`, `router.py:181`). +- Field names are `lower_snake_case`. Header names keep their standard spelling. The runner's own + HTTP surface uses `camelCase`, matching its existing `/kill` body + (`services/runner/src/server.ts:704`). + +--- + +## 1. Interface review + +Every field is classified before it is written down, as the `design-interfaces` skill requires. The +architecture review's section 4 fixed four of these shapes; where it did, that is noted. + +### Public Cancel request + +| Field | Concretely | Owner | Changes | Role | Placement | +|---|---|---|---|---|---| +| `session_id` | Which session to act on | Caller | Per call | routing | Path parameter, because it names the resource | +| `expected_execution_id` | The execution the caller believes is running | Caller | Per call | precondition | Body, flat | +| `Idempotency-Key` | Retry identity for this request | Caller | Per call | protocol context | Header | + +Three decisions fall out of that table. + +- **The public Cancel body stays flat.** The review examined this exact shape and ruled that it is + correct and should not change: `expected_execution_id` is per-call context named as the guard it + is, in the style of an HTTP `If-Match`. The grouping under `target` applies to the internal + envelope, where a resolved `target.turn_id` needs a home next to the asserted one. A public body + with one field does not. +- **`Idempotency-Key` stays a header** with its standard spelling. It describes the delivery of the + request, not the intent inside it. The stored column is `idempotency_key`, matching + `session_attachments.idempotency_key` (`api/oss/src/dbs/postgres/sessions/attachments/dbas.py:25`). +- **No `force` flag.** `force` on the current stream endpoint is what makes one route mean four + things (`api/oss/src/core/sessions/streams/service.py:7`). Cancel means cancel. + +The field stays optional, as decision D-010 requires, and first-party clients must always send it. +Today the desktop sends nothing (`web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts:505`, +verified), which is the third guard of the design document's section 4 left switched off. + +### Public Cancel response + +| Field | Concretely | Role | +|---|---|---| +| `command.id` | The durable command's id | identity, for the caller's own retries and logs | +| `command.state` | `pending` or `obsolete` at admission time | delivery | +| `execution.id` | The execution this Cancel targets, null when nothing ran | routing | +| `execution.state` | `stopping` or `idle` | result | + +`command` and `execution` are separate objects because they answer different questions and settle at +different times. A client drawing a button reads `execution`. A client retrying safely reads +`command.id`. This is decision D-016 expressed in the response shape. + +### The internal command envelope + +The review's corrected shape, adopted here: + +| Group | Fields | Role | +|---|---|---| +| top level | `id`, `project_id`, `session_id`, `kind`, `created_at` | identity, routing, metadata | +| `target` | `turn_id` (resolved at admission), `expected_turn_id` (as the caller sent it) | context | +| `input` | `text`, `attachments` | input data, absent for `cancel` | +| `policy` | `on_busy` | policy, absent for `cancel` | +| `delivery` | `claimed_by`, `claim_expires_at`, `attempt` | delivery bookkeeping | + +Four rules this applies. + +- **Delivery bookkeeping is grouped and never merged with the result.** That is decision D-016, and + it is easier to hold when the shapes are separate objects. +- **`replica_id` is not a top-level routing field.** It is delivery bookkeeping, it is logical rather + than an address, and it lives under `delivery` as `claimed_by`. +- **There is no `runner_url` field of any kind.** An address in a durable record is an + implementation detail with a longer lifetime than the thing it points at. +- **`input` is an object from the start**, not a bare `message` string. A turn already carries text + plus attachments (`services/runner/src/server.ts:565`), so a string could not grow into that + without a breaking change. `cancel` omits the group entirely rather than sending it empty. + +`created_at` is on the envelope because the runner needs it: it refuses to abort an execution that +started after the command was created. + +### Internal claim request + +| Field | Concretely | Owner | Role | +|---|---|---|---| +| `replica_id` | Which runner is asking, for `claimed_by` | Runner | delivery bookkeeping | +| `sessions` | The sessions this runner holds warm right now | Runner | routing | +| `wait_seconds` | How long the caller accepts being held | Runner | protocol context of this call | +| `limit` | How many commands to return at most | Runner | protocol context of this call | + +`sessions` is the routing input, not `replica_id`. The runner declares what it holds, so the API +never has to guess from an expiring Redis key, and a parked session keeps receiving commands after +its heartbeat stops. A claim is a query over durable state, never a cursor or a stream position. + +### Internal outcome request + +| Field | Concretely | Owner | Role | +|---|---|---|---| +| `replica_id` | Which runner is reporting | Runner | delivery bookkeeping, and the claim guard | +| `result` | The command's terminal state | Runner | delivery | +| `execution.id` | Which execution the runner acted on | Runner | routing | +| `execution.state` | What happened to it | Runner | result | +| `execution.error` | Why it failed, when it did | Runner | result | + +`execution.error` sits under `execution` because it explains one field of that object. + +--- + +## 2. Public: cancel the current execution + +```http +POST /sessions/{session_id}/cancel +Idempotency-Key: 0199a3f2-0000-7000-8000-000000000001 + +{ + "expected_execution_id": "0199a3f1-0000-7000-8000-00000000000a" +} +``` + +Permission: `Permission.RUN_SESSIONS`, the same permission the current cancel path checks +(`api/oss/src/apis/fastapi/sessions/router.py:377`). + +```python +class SessionCancelRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + # Optional stale-request guard (decision D-010). When present, the API cancels only this + # execution and rejects the request if another one is running. When absent, it cancels + # whichever execution is active when the request is applied. A person never types this; + # the browser fills it from the session snapshot, and a first-party client always sends it. + expected_execution_id: Optional[str] = None + + +class SessionCommandRef(BaseModel): + """The durable command an accepted request created. Identity and delivery state only. + A client must not infer execution state from it (decision D-016).""" + + id: UUID + state: Literal["pending", "claimed", "applied", "obsolete"] + + +class SessionExecutionRef(BaseModel): + """What the caller should render. `id` is null when the session was idle.""" + + id: Optional[str] = None + state: Literal["stopping", "idle"] + + +class SessionCancelResponse(BaseModel): + command: SessionCommandRef + execution: SessionExecutionRef +``` + +Responses: + +| Status | When | Body | +|---|---|---| +| 202 Accepted | An execution was running or parked. The command is durable and on its way | `command.state = "pending"`, `execution.state = "stopping"` | +| 200 OK | Nothing was running and no `expected_execution_id` was sent | `command.state = "obsolete"`, `execution.state = "idle"`, `execution.id = null` | +| 200 OK | The running execution started **after** this request arrived, and no `expected_execution_id` was sent | `command.state = "obsolete"`, `execution.state = "idle"`, `execution.id = null`. The newer execution is not touched. See the stale-Stop guard in section 4 of the design document | +| 409 Conflict | `expected_execution_id` does not name the running execution | `detail: {"message": ..., "current_execution_id": }` | +| 422 | The session id fails the allowlist (`SessionIdInvalid`) | `detail: ` | +| 403 | The caller lacks `RUN_SESSIONS` | `FORBIDDEN_EXCEPTION` | + +The two 200 cases are deliberately indistinguishable to the client. Both mean "there is nothing of +yours left to stop", and a client that needs to know which one it hit is reading the wrong signal: +it should read the session's execution state, not this response. The command row keeps the exact +reason in `outcome` for anyone debugging afterwards. + +202 and not 200 for the accepted case, because the work is not done when the response returns. The +caller learns the outcome from the session's own state, not from this response. **A delivery failure +does not change the status**: the command is inserted and committed before any adapter is called, so +an unreachable runner still yields 202 and the watchdog settles the command. + +Repeating the request with the same `Idempotency-Key` returns the same `command.id` and the same +status. Repeating it without a key also returns the same command while one is still open, because +admission collapses onto an open command for the same target execution. + +New domain exceptions in `api/oss/src/core/sessions/commands/types.py`, mapped by a +`_handle_command_exceptions()` decorator alongside the existing one: + +```python +class SessionCommandError(Exception): ... + +class ExecutionExpectationFailed(SessionCommandError): + """expected_execution_id does not name the running execution.""" + def __init__(self, session_id: str, expected: str, current: Optional[str]): ... +``` + +--- + +## 3. Deferred: claim commands (future long-poll adapter) + +```http +POST /sessions/control/commands/claim +X-Agenta-Runner-Token: + +{ + "replica_id": "runner-7f3c", + "sessions": [ + {"project_id": "1f0a4b2c-0000-4000-8000-000000000002", "session_id": "sess-42"} + ], + "wait_seconds": 25, + "limit": 10 +} +``` + +Not a product API. It is excluded from the public schema with `include_in_schema=False`, the +treatment the admin routers already get (`api/entrypoints/routers.py:1502`). + +Authentication is the shared runner token, not a user credential: the loop belongs to the process +and spans many projects, and a run's credential expires while the process keeps polling. The path +prefix `/sessions/control/` is added to `_PUBLIC_ENDPOINTS` (`api/oss/src/middlewares/auth.py:52`) +so the project-scoped middleware does not reject a request with no user credential, and the route +then compares the presented token to `env.runner.token` in constant time. If that setting is unset +the route answers 503 and serves nothing. Scope comes from the declared `(project_id, session_id)` +pairs and the rows themselves, never from a header. + +```python +class SessionScope(BaseModel): + model_config = ConfigDict(extra="forbid") + + project_id: UUID + session_id: SessionId + + +class SessionControlClaimRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + # Delivery bookkeeping: this becomes `claimed_by` so a settle can be matched to its claim. + # Not routing, and not an address. + replica_id: str = Field(min_length=1, max_length=128) + # The routing input: every session this runner holds warm right now, including sessions + # parked awaiting an approval. Most recently used first. + sessions: List[SessionScope] = Field(min_length=1, max_length=200) + # How long the API may hold this request. Clamped server-side to the configured hold. + wait_seconds: int = Field(default=25, ge=0, le=60) + limit: int = Field(default=10, ge=1, le=50) + + +class SessionCommandTarget(BaseModel): + # Resolved once at admission; the runner aborts only this execution. + turn_id: Optional[str] = None + # What the caller asserted, kept so a 409 stays explainable after the fact. + expected_turn_id: Optional[str] = None + + +class SessionCommandDelivery(BaseModel): + claimed_by: str + claim_expires_at: datetime + attempt: int + + +class SessionCommandEnvelope(BaseModel): + """One command as the runner receives it. Every transport delivers this same shape, + so the runner has one parser, one set of guards and one applier.""" + + id: UUID + project_id: UUID + session_id: str + kind: Literal["cancel"] + target: SessionCommandTarget + delivery: SessionCommandDelivery + # The runner refuses to abort an execution that started after this time. + created_at: datetime + # Absent for `cancel`. Present for the kinds that carry them, so a reader never has to + # interpret an empty object. + input: Optional[SessionCommandInput] = None + policy: Optional[SessionCommandPolicy] = None + + +class SessionControlClaimResponse(BaseModel): + count: int = 0 + commands: List[SessionCommandEnvelope] = Field(default_factory=list) +``` + +Responses: + +| Status | When | +|---|---| +| 200 OK | At least one command was claimed. The body is never an empty list | +| 204 No Content | The hold expired with nothing to deliver | +| 401 Unauthorized | The token is absent or wrong | +| 422 | `sessions` is empty or over the cap | +| 503 Service Unavailable | `AGENTA_RUNNER_TOKEN` is not configured on the API | + +204 rather than an empty 200 keeps the common case cheap and gives the runner an unambiguous "claim +again now" signal. + +--- + +## 4. Internal: report a command's outcome + +Used by **both** adapters. Settlement has one path on every transport. + +```http +POST /sessions/control/commands/{command_id}/outcome +X-Agenta-Runner-Token: + +{ + "replica_id": "runner-7f3c", + "result": "applied", + "execution": { + "id": "0199a3f1-0000-7000-8000-00000000000a", + "state": "stopped" + } +} +``` + +```python +class SessionExecutionOutcome(BaseModel): + model_config = ConfigDict(extra="forbid") + + # The execution the runner acted on. Null when it held none. + id: Optional[str] = None + # stopped: cancelled as asked. not_running: no such execution here. + # superseded_by_newer_turn: the held execution started after the command arrived. + # failed: the cancel itself failed. + state: Literal["stopped", "failed", "not_running", "superseded_by_newer_turn"] + # Short, human-readable, present only when `state` is "failed". + error: Optional[str] = Field(default=None, max_length=2000) + + +class SessionControlOutcomeRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + replica_id: str = Field(min_length=1, max_length=128) + # The command's terminal state. `applied` means the runner did the work; `obsolete` + # means there was nothing to do. + result: Literal["applied", "obsolete"] + execution: SessionExecutionOutcome + + +class SessionCommandSettlement(BaseModel): + id: UUID + state: Literal["applied", "obsolete"] + outcome: Literal["stopped", "not_running", "superseded_by_newer_turn", "failed", "lost"] + settled_at: datetime + + +class SessionControlOutcomeResponse(BaseModel): + command: SessionCommandSettlement +``` + +Responses: + +| Status | When | Body | +|---|---|---| +| 200 OK | The command was `claimed` by this replica and is now settled | The settlement | +| 409 Conflict | The claim expired, or another actor settled the command | The stored settlement, so the runner stops instead of retrying | +| 404 Not Found | No command with that id in any project | `detail` | +| 401, 503 | As for the claim route | | + +The API does the settlement side effects inside the same request: it clears +`session_streams.stopping_turn_id`, tombstones the stopped execution, releases the Redis `running` +key under an owner check, leaves `alive` to its own time to live, cancels that execution's pending +interactions, and publishes the existing `lifecycle: ended` watch notification. The full ordering is +in section 7 of the design document. + +--- + +## 5. Internal: the runner's cancel route (direct-call adapter) + +This is the runner's own HTTP surface, not the API's. It sits beside the existing `POST /kill` +(`services/runner/src/server.ts:704`, verified) and shares its token gate, its capped body reader and +its scoping rule. The API calls it the way `kill_runner_sandbox` already calls `/kill` +(`api/oss/src/core/sessions/streams/runner_client.py:30`, verified). + +```http +POST /cancel +Authorization: Bearer + +{ + "commandId": "0199a3f2-0000-7000-8000-000000000001", + "projectId": "1f0a4b2c-0000-4000-8000-000000000002", + "sessionId": "sess-42", + "targetTurnId": "0199a3f1-0000-7000-8000-00000000000a", + "createdAt": "2026-09-02T22:09:01Z" +} +``` + +`camelCase` because the runner's existing routes use it. `projectId` and `sessionId` are both +required, for the same reason `/kill` requires both: a pool key is always project-scoped, so a +single-tenant scope needs the pair. + +Responses: + +| Status | When | Meaning to the API adapter | +|---|---|---| +| 202 Accepted | The runner holds this session and accepted the command | `accepted`; the outcome will arrive on the outcome route | +| 404 Not Found | The runner does not hold this session | `not_held`; the service settles the command at once | +| 400 | `sessionId` or `projectId` missing | `unreachable`, and a bug to fix | +| 401 | Token mismatch | `unreachable`, and a deployment error to log loudly | + +**The response is an acknowledgement, not an outcome.** The runner reports what happened to the +execution through the outcome route in section 4, so both adapters settle through one path. + +**404 is ambiguous, and the API must disambiguate it.** `not_held` is the honest answer both when the +session really has ended and when the call reached the wrong replica. The API tells them apart with +data it already has: a `not_held` for a session whose row says `is_alive` with a heartbeat younger +than one interval is the wrong-replica failure. It is logged at error level, counted, and settled as +`lost` rather than `not_running`, so the user is told the Stop failed instead of being told the work +had already finished. Section 9 of the design document has the rule and the optional preventive +configuration check. + +**The runner resolves a parked session through the pool, not the execution registry.** A Stop against +a parked approval has no in-flight execution, so `/cancel` falls back to +`SessionPool.awaitingApproval(sessionId)` +(`services/runner/src/engines/sandbox_agent/session-pool.ts:117`, verified) before answering 404. + +--- + +## 6. One field added to an existing contract + +The heartbeat response grows one field. Nothing else about `POST /sessions/streams/heartbeat` +changes. + +```python +class SessionHeartbeatResult(BaseModel): + stream: Optional[SessionStream] = None + replica_id: str + is_current_turn: bool = True + # Commands for THIS session only, claimed by this beat under the same compare-and-set + # the claim route uses. Empty in the normal case. + commands: List[SessionCommandEnvelope] = Field(default_factory=list) +``` + +The field is additive and defaults to an empty list, so a runner build that does not know about it is +unaffected. + +This fallback reaches only a session with a live turn. The heartbeat stops when a turn ends or parks +(`services/runner/src/server.ts:618` and `services/runner/src/sessions/alive.ts:241`, verified), so +it is not the delivery path for a parked session and must not be relied on as one. + +--- + +## 7. What does not change in version one + +- `POST /sessions/streams/` keeps its current four-mode behavior until the last migration step, when + its cancel branch becomes a thin wrapper over the same command. See section 10 of the design + document. +- `DELETE /sessions/streams/` (kill) is untouched. Stop and Delete stay different operations + (decision D-008). +- `POST /sessions/interactions/{interaction_id}/respond` is untouched. Turning interaction responses + into commands is later work, and so is the `continuation` field the architecture review asks for on + its response. +- No new public read route. Clients keep using `GET /sessions/streams/` and the watch stream. +- Steer stays out. The `input` and `policy` groups are reserved in the envelope so it does not need a + breaking change later, but no route accepts them in version one. diff --git a/docs/design/session-control-and-live-events/context.md b/docs/design/session-control-and-live-events/context.md new file mode 100644 index 00000000000..84aa2fc1037 --- /dev/null +++ b/docs/design/session-control-and-live-events/context.md @@ -0,0 +1,64 @@ +# Context + +> AGENT-GENERATED, low weight. Draft for discussion. Mahmoud makes final decisions. + +## Current user experience + +The browser that sends a message owns the live invoke response. Other clients receive saved +record changes later. Stop uses the session control endpoint, but the runner learns about normal +cancellation through its next heartbeat. Session records use upserts and do not provide a durable +per-session replay cursor. + +These behaviors cause several visible problems: + +- Stop can update the browser while execution continues in the runner. +- A failed or missing terminal signal can leave a session shown as running. +- A second message can race with the active execution and break the session. +- Another browser cannot receive the same live text stream as the sender. +- A reconnecting browser cannot request all durable changes after a stable cursor. +- Approval, cancellation, and resume races can leave an interaction or session unusable. +- A re-sent record can change the apparent reading order because records are mutable upserts. + +## Design scope + +The final design must cover four independent paths: + +1. **Live output:** runner to API to every connected reader. +2. **Durable facts:** append-only session history with stable ordering and replay. +3. **Commands:** client to API to the execution owner, with durable admission where required. +4. **Ownership:** one active execution owner, renewed through a temporary lease. + +The read path and control path can progress in parallel. Stop is not blocked on the live relay. +The live relay is not blocked on the final Stop behavior. + +## Goals + +- Stop reaches active execution promptly and produces one terminal outcome. +- Normal Stop preserves the resumable session and sandbox when the harness supports this. +- Multiple clients receive live frames from the same execution. +- Refreshing or closing the sender does not stop execution. +- Clients can recover durable changes after a cursor. +- A second message has an explicit server-side delivery policy. +- Steer saves the new message before it interrupts current work. +- Approval state remains correct across pause, Stop, refresh, and resume. +- Records and events have stable ordering that retries cannot change. +- Runner failure eventually releases ownership and leaves a terminal durable outcome. + +## Non-goals for the first RFC pass + +- Selecting a new broker before current Redis options are evaluated. +- Storing every token permanently in Postgres. +- Replacing every frontend session view in the first implementation. +- Solving all harness limitations through one common behavior. +- Treating the current issue grouping as a confirmed roadmap priority. + +## Design process + +Each track will follow the same sequence: + +1. Review current behavior and linked failures. +2. Agree on the invariant and user-visible requirement. +3. Compare the high-level options. +4. Record the decision and rejected alternatives. +5. Add the approved design to the living RFC. +6. Define one live-stack test that proves the track. diff --git a/docs/design/session-control-and-live-events/contracts/events.md b/docs/design/session-control-and-live-events/contracts/events.md new file mode 100644 index 00000000000..9a418c2425f --- /dev/null +++ b/docs/design/session-control-and-live-events/contracts/events.md @@ -0,0 +1,179 @@ +# Session event contracts + +> **AGENT-GENERATED, low weight.** + +This file describes the shipped session event contracts. Milestone 1 shipped the disposable +live-frame relay. Milestone 2 shipped the durable-event contract: replay with sequences and +watermarks over `GET /sessions/{session_id}/events`, and browser fan-out to a second reader. + +## Shipped live-frame contract + +### Client behavior + +The initiating browser continues to render the invoke response. A second browser subscribes to the +session event route only when the session advertises `shared_reader` and the run belongs to another +browser. The global environment switch controls both the route and the advertised capability. + +The event route replays durable events after the client's `after` cursor, then sends a `ready` +event carrying the replay watermark, then follows live frames and durable events as they are +published. Live frames are unnamed SSE data events. The existing watch SSE continues to send +low-frequency notices such as `records-changed`; clients use those notices to reload completed +records. + +Each execution must start at `frame_index: 0`, and each later frame must increment the index by one. +The client ignores duplicate and older indices. If the first index is above zero or a later index +skips a value, the client clears and suppresses the preview tail and refreshes durable records. A +reconnect also clears the disposable preview and refreshes durable records because Redis Pub/Sub has +no replay. + +### Live-frame envelope + +Frames use the existing records ingest HTTP endpoint. The API validates the frame and publishes it +to the dedicated live-frame Redis Stream. + +```text +version: 1 +kind: frame +session_id +execution_id +frame_or_event_id +frame_index +entity_id +type +payload +created_at +``` + +- `session_id` reuses the current `sessionId`. +- `execution_id` reuses the current `turnId`. +- `frame_or_event_id` combines the execution ID and frame index. +- `entity_id` reuses the message ID or tool-call ID. +- `frame_index` starts at zero and increases by one within an execution. +- `created_at` is the producer timestamp in UTC. It does not define order. + +### Live-frame payloads + +The envelope wraps the current invoke vocabulary. It does not rename the content protocol. + +| Family | Shipped types and fields | +|---|---| +| Text | `text-start`, `text-delta.delta`, `text-end`; all reuse `id` | +| Reasoning | `reasoning-start`, `reasoning-delta.delta`, `reasoning-end`; all reuse `id` | +| Tools | `tool-input-start`, `tool-input-available`, `tool-output-available`, `tool-output-error`, `tool-output-denied`; all reuse `toolCallId` and current input or output fields | + +Repeated tool input snapshots keep one `toolCallId`, so the reducer updates one preview. + +### Storage and retention + +The runner publishes frames asynchronously through a bounded 256-frame buffer. Publication errors +and buffer overflow do not block the run. Frames reach the records ingest HTTP route, where the API +appends frame relay envelopes to `streams:session-live-frames`. The stream has a 15-minute age limit +and an approximate 100,000-entry count bound by default. Redis may temporarily retain more entries +because both count and age trimming use `MAXLEN ~` and `MINID ~`. Concurrent sessions share the +count bound because relay messages are disposable. + +The relay worker reads only the live relay stream. It discards expired envelopes, publishes accepted +frames and durable events to the project-and-session Pub/Sub channel, then acknowledges and deletes +them. The measured long case reached 3,161 frames and 201,056 SSE bytes in one turn. At the highest +measured average rate, the default 100,000-entry trim threshold represents about 22 minutes for one +active run, but the 15-minute age limit caps effective relay retention at 15 minutes. + +Only durable records enter `streams:records`. Publication preserves the existing approximate +100,000-entry retention bound. After the records worker commits those records, it projects durable +events and appends their relay envelopes to `streams:session-live-frames`. It then acknowledges and +deletes the durable-record entries. The live relay never reads `streams:records`. + +### Authorization and reader limits + +Frame ingress verifies `RUN_SESSIONS` access and the caller's current owner claim for the supplied +session and execution. The shared runner token alone cannot authorize a foreign frame. The API also +enforces the serialized frame-size limit before publishing. + +The event route requires `VIEW_SESSIONS` access for the current project and revalidates access during +the connection. Each reader has one bounded output queue. The API sends `relay-close` and ends a +connection when the reader falls behind, authorization is revoked, or the relay fails. The response +uses `Cache-Control: no-store` and disables proxy buffering. + +Logs contain identifiers and reason codes only. They do not contain message content, tool payloads, +or tokens. + +## Durable-event contract + +### Sender on the shared path + +For `x-ag-session-response: shared`, invoke emits one transient `data-session-accepted` event with +`{sessionId, turnId, executionId}`, and emits it only after the runner admits the turn. The same ID +serves as the turn and the execution. The sender consumes invoke only for this acceptance, protocol +lifecycle, and errors. It renders text, reasoning, and tool progress from the session event route. + +### Durable event envelope + +Temporary frames and durable records reuse the records ingest HTTP endpoint. The API appends frame +relay envelopes directly, while the records worker projects durable events only after their records +commit. Both paths call `_append_live_relay_message` to append relay envelopes to +`streams:session-live-frames`, where `kind` distinguishes the two versioned shapes. Only durable +records use the separate `streams:records` path. + +```text +version +kind: frame | event +session_id +execution_id +frame_or_event_id +entity_id +type +payload +created_at + +when kind = frame: + frame_index + +when kind = event: + sequence + watermark +``` + +- `sequence` is the database-assigned per-session record cursor. It can skip values because every + record receives a sequence while the relay exposes only the typed events. +- `watermark` is the session's latest committed record sequence when the event is published or + replayed. On a live event it is the highest sequence committed in the publishing batch. On the + replay's final `ready` event it is the session cursor after replay. + +Clients apply durable events whose `sequence` is greater than the last event they applied, and +discard duplicate or older events. They do not wait for a contiguous durable sequence. After +applying an event, they advance the event-deduplication cursor to `sequence` and track the greater +of `sequence` and `watermark` separately as the reconnect cursor. A replay's final `ready` event can +advance both cursors after every event through its watermark has been applied. + +### Durable event types + +The contract defines these event types and payloads: + +| Type | Typed payload | +|---|---| +| `execution.started` | `{started_at}` | +| `execution.stopped` | `{stopped_at, reason, command_id}` | +| `execution.failed` | `{failed_at, error: {code, message, retryable, details?}}` | +| `execution.lost` | `{lost_at, reason, history_complete: false}` | +| `message.completed` | `{message_id, role, content, finish_reason?}` | +| `tool.completed` | `{tool_call_id, name, input, output?, error?, status}` | +| `interaction.requested` | `{interaction_id, kind?}` | +| `interaction.responded` | `{interaction_id, kind?}` | + +The envelope carries session, execution, entity, sequence, and creation fields, so payloads do not +repeat them. The reducer ignores an unknown event type and continues from the next sequence. +Interaction events carry no answer data. Readers use them to refresh records and the current +interaction state. + +### Replay and live handoff + +The event endpoint subscribes to the wake-up source before its first history query. It queries +Postgres after the supplied sequence, sends rows in order, and queries again when a notification +arrives. Notifications carry no durable truth. + +Each replay is bounded by the current database watermark. Replayed events carry that watermark. The +replay's final `ready` event also carries it, including when no typed event follows the supplied +sequence. + +If a reader falls behind, the API closes the connection. The reader then reloads the durable +snapshot and resumes from its durable sequence. diff --git a/docs/design/session-control-and-live-events/decisions.md b/docs/design/session-control-and-live-events/decisions.md new file mode 100644 index 00000000000..75ecbed3ca3 --- /dev/null +++ b/docs/design/session-control-and-live-events/decisions.md @@ -0,0 +1,297 @@ +# Decisions + +> AGENT-GENERATED, low weight. Draft for discussion. Mahmoud makes final decisions. + +## Confirmed process decisions + +### D-001: Start from bugs and system requirements + +**Status:** Confirmed by Mahmoud on 2026-09-02. + +The design starts with the open issue inventory and the requirements the final system must +satisfy. Architecture options must link back to these requirements. + +### D-002: Discuss one track at a time + +**Status:** Confirmed by Mahmoud on 2026-09-02. + +For each track, first present the high-level design and important questions. Record the answers +and decisions in the RFC after discussion. + +### D-003: Keep the read path and control path independent + +**Status:** Confirmed direction on 2026-09-02. + +Shared reading and immediate control touch different directions and can progress in parallel: + +- Read path: runner to API to clients. +- Control path: client to API to runner. + +Stop must not wait for the live relay, replay, or sender-as-reader work to finish. + +### D-004: Preserve live token output in the target experience + +**Status:** Confirmed direction on 2026-09-02. + +Moving readers behind the API must not reduce the sender to paragraph-only updates. The final +system must deliver live frames to every connected reader. + +### D-005: Keep temporary frames separate from permanent facts + +**Status:** Confirmed direction on 2026-09-02. + +Live text fragments can have bounded retention. Completed messages, lifecycle facts, tools, and +interactions require durable recovery. The API accepts both through one HTTP ingest endpoint, then +publishes frames to `streams:session-live-frames` and durable records to `streams:records`. + +The live-frame stream applies a 15-minute age bound and a 100,000-frame count bound across the +deployment. Separate Redis Streams preserve the durable consumer's acknowledgement policy and +remove cross-consumer trim coordination. + +### D-006: Investigate sandbox-agent cancellation before selecting Stop semantics + +**Status:** Confirmed process decision on 2026-09-02. + +The Stop track starts with a focused sandbox-agent investigation. It must determine whether one +execution can be cancelled while the harness session and sandbox remain resumable. It must also +identify any required vendored patch and Daytona snapshot rebuild. This investigation can proceed +in parallel with the API control-path design. + +### D-007: Use five seconds as the provisional Stop delivery target + +**Status:** Provisional product direction from Mahmoud on 2026-09-02. + +Within five seconds of an accepted Stop request, the active execution must stop starting new model +requests and new tool actions. The exact deadline for terminating an already-running provider or +tool operation remains open until harness and tool cancellation capabilities are verified. + +### D-008: Separate Stop from Delete + +**Status:** Confirmed by Mahmoud on 2026-09-02. + +Stop preserves the session, its history, and its resumable sandbox state. Delete permanently +removes the session and its session-scoped resources. The public interface must not overload one +operation to mean both. + +### D-009: Let first-party and external clients use the same session API + +**Status:** Confirmed direction from Mahmoud on 2026-09-02. + +Desktop, mobile, integrations, and external API consumers should use the same public session +contract. Private API-to-runner delivery remains an implementation detail behind that contract. + +### D-010: Make the expected execution guard optional + +**Status:** Confirmed direction from Mahmoud on 2026-09-02. + +A Cancel request can include `expected_execution_id`. When supplied, the API cancels only that +execution and rejects a stale request. When omitted, the API cancels the session's current active +execution. + +### D-011: Keep queued inputs immutable + +**Status:** Confirmed by Mahmoud on 2026-09-02. + +Clients can view and remove a pending input. They cannot edit or reorder it. To change pending +content, a client removes the old input and submits a replacement. The API rejects removal after +the input has been promoted into active work. + +### D-012: Keep design discussions at the architectural level + +**Status:** Confirmed by Mahmoud on 2026-09-02. + +The discussion focuses on resource boundaries, execution ownership, event flow, recovery, and +user-visible behavior. Routine endpoint naming, status codes, defaults, and validation details use +established API conventions during RFC drafting unless they materially change those properties. + +### D-013: A successful submission means durable acceptance + +**Status:** Confirmed by Mahmoud on 2026-09-02. + +The API confirms a submitted input only after it has durably saved the input, its idempotency +identity, its session, and the intent to execute it. Acceptance does not wait for a runner to claim +the work, the harness to start, or the first output frame. If no runner is available, accepted work +remains queued rather than disappearing. + +### D-014: Do not preserve sender-only live visibility as a requirement + +**Status:** Confirmed by Mahmoud on 2026-09-02. + +The shared session stream is available to authorized session viewers. The design does not treat +raw live output as secret to the browser that started the execution. Existing configured +redaction and authorization behavior must be understood, but sender-only visibility is not a +target product rule. + +### D-015: Add the new session interface beside the current endpoints + +**Status:** Confirmed as a fair first draft by Mahmoud on 2026-09-02. + +The new snapshot and replayable event interface is introduced without changing the meaning of the +current stream and watch endpoints. Desktop and mobile migrate before obsolete endpoints are +deprecated. Final endpoint names remain open for a later interface review. + +### D-016: Separate command delivery state from execution state + +**Status:** Confirmed by Mahmoud on 2026-09-02. + +The internal command lifecycle starts with `pending`, `claimed`, `applied`, and `obsolete`. +Claims are temporary and can expire or retry. An execution terminal outcome is durable and cannot +change. Public clients follow execution states such as `running`, `stopping`, `stopped`, `failed`, +and `lost`; they do not infer execution state from internal delivery acknowledgements. + +Accepting Stop durably saves the command and moves the matching execution from `running` to +`stopping` in one transaction. A runner outcome settles both the execution and the command. A +watchdog settles an execution whose runner disappears, but its timeout remains open until the +sandbox cancellation spike. + +Postgres is the admission-state store for both the durable command row and the execution +projection. The API inserts the command and updates the matching execution in one Postgres +transaction. Redis ownership is not part of that transaction. A crash before commit accepts +nothing. A crash after commit leaves a retryable `pending` command that long polling or heartbeat +discovery can deliver until the runner applies it. + +### D-017: Keep current Redis execution ownership for the first version + +**Status:** Confirmed by Mahmoud on 2026-09-02. + +The first version keeps the existing Redis `alive`, `running`, `owner`, and `superseded` model. +It does not add Postgres execution authority, ownership generations, or full stale-writer fencing. +Those changes have low current value because Agenta operates one runner and does not plan near-term +runner scaling. + +Durable commands and direct API-to-runner delivery are in scope. Stop no longer depends on deleting +ownership and waiting for a heartbeat. The current execution keeps its Redis ownership while +stopping and releases it after cancellation settles. Long polling is deferred behind the same +control-delivery port. + +### D-018: Use runner-initiated HTTP long polling for immediate control + +**Status:** Selected for the milestone 1 implementation on 2026-09-04. + +The runner uses HTTP long polling behind a control-delivery port. Durable commands remain +recoverable across disconnection, and the Stop path does not depend on Redis, WebSockets, or direct +runner routing. Heartbeat command discovery remains the fallback delivery path. + +Redis remains an execution lease and routing hint. Postgres command and execution rows are the +durable recovery source after process or Redis failure. + +## Proposed design decisions + +### P-001: Use one HTTP ingress and separate Redis Streams + +**Status:** Settled on 2026-09-04. + +The runner sends temporary frames and durable records through one records ingest HTTP endpoint. +The API routes frames to `streams:session-live-frames` and durable records to `streams:records`. +The live relay forwards frames without waiting for message completion. + +The current sender response and current persistence path can remain during migration. + +### P-002: Keep ownership heartbeats but remove normal control delivery from them + +**Status:** Proposed. Not approved. + +Heartbeats continue to renew runner ownership and detect failures. Immediate control delivery +handles Stop and Steer. Heartbeat detection remains a fallback when direct delivery fails. + +### P-003: Use append-only durable events for replay + +**Status:** Proposed. Requires an explicit decision reversal or separation from records. + +The existing records specification decided to use UUIDv7 ordering and no stored per-session +sequence. The new replay requirement may need an append-only event log with a per-session cursor. +The design must either reopen the existing decision or introduce a separate event-log concept. + +### P-004: Require one active execution and fence stale writers + +**Status:** Proposed. Direction confirmed, mechanism not approved. + +At most one execution can be active for a session. Admission must be atomic. Each accepted owner +receives an increasing ownership generation, also called a fencing token. Every durable write and +effect-producing command carries that generation. The API rejects a write from an older +generation even if the old runner is still alive. + +Redis heartbeats remain useful for leases and crash detection. A lease alone is not the final +correctness guarantee because it can expire during a network partition while the old runner keeps +working. + +## Open decision gates + +### O-001: Vocabulary + +Settle the meanings of `session`, `conversation turn`, and `execution`. Decide how existing +`turn_id` and `turn_index` map to those terms. + +### O-002: Stop behavior inside sandbox-agent + +Verify whether the vendored sandbox-agent can cancel one execution while preserving its harness +session. Warm resume is the required outcome. If current behavior cannot provide it, define the +required patch and whether Daytona needs a rebuilt snapshot. + +### O-003: Durable ordering + +Choose between: + +- A new append-only durable event log with a per-session sequence. +- Append-only records with a new ordering contract. +- Separate record storage and replay-event storage. + +Do not add a sequence column to mutable upserts and call the result append-only. + +### O-004: Raw live transport + +**Status:** Stream layout and retention settled on 2026-09-04. + +Temporary frames use a dedicated deployment-wide Redis Stream bounded to 15 minutes and 100,000 +frames. Trimming is approximate on both the publish and the sweep path, because the frames are +disposable. Browser fan-out shipped in milestone 2. Redaction remains open. + +### O-005: Stable record-ID semantics spike + +Before immutable event insertion is implemented, inventory every runner and backend path that +reuses a `record_id`. Separate exact delivery retries from progressive updates and resume +re-emissions. Add regression tests for the final state of tools, interactions, terminal events, +and harness reconstruction. + +### O-006: Immediate runner control + +**Status:** Resolved for version one on 2026-09-03. + +Use a direct API-to-runner HTTP call through the replaceable control-delivery port. Durable storage +precedes the call, so transport failure costs promptness rather than command correctness. Defer +runner-initiated long polling until multi-runner or user-operated routing requires it. + +### O-007: Command boundary + +Decide which actions enter a general command inbox. The working boundary is execution-affecting +intent: Send, Cancel, interaction response, Queue, and Steer. Attach is a read operation. Kill, +rename, archive, and delete remain explicit resource or lifecycle operations unless discussion +shows a need to change that boundary. + +### O-008: Public resource API versus internal command transport + +Decide whether public callers submit every execution action to one command collection or use +clear resource endpoints that translate into internal commands. The current proposal favors clear +public resources with one internal command envelope. + +### O-009: Public Cancel target + +Choose whether Cancel publicly targets: + +- The current work in a session, with no execution ID. +- A specific execution resource. +- The current work in a session plus `expected_execution_id` as a stale-request guard. + +The selected direction combines the first and third options. Cancel targets the current work in a +session. `expected_execution_id` is an optional stale-request guard supplied by clients that know +the current execution. + +### O-010: Busy-message policy names + +Choose the public names and defaults for a message submitted while work is active. The current +working set is `reject`, `queue`, and `steer` under an `on_busy` field. + +### O-011: Pending input ordering + +Pending inputs remain visible in the session snapshot and event stream. The initial contract uses +server-assigned FIFO order. Clients cannot edit or reorder queued inputs. diff --git a/docs/design/session-control-and-live-events/live-frame-envelope.md b/docs/design/session-control-and-live-events/live-frame-envelope.md new file mode 100644 index 00000000000..08c0e234239 --- /dev/null +++ b/docs/design/session-control-and-live-events/live-frame-envelope.md @@ -0,0 +1,89 @@ +# Live frame envelope + +> **AGENT-GENERATED, low weight.** + +## Measurement + +The sample used `agenta-ee-dev-session-integration` at `http://localhost:8580` on 3 September +2026. It ran Pi (`pi_core`) with `gpt-5.6-luna` and the local sandbox. Each case ran three times. +The Pi key came from `~/.agenta-qa-openai.env` under `OPENAI_API_KEY`. Its value was not recorded. + +A frame is one JSON `data:` SSE frame. Counts exclude the terminal `[DONE]` sentinel. Byte counts +include the `data:` prefix and frame delimiter. Run length starts before the invoke request and ends +when the response body closes. Raw bodies and the machine-readable results are in +`~/agenta-qa-evidence/2026-09-03-session-night/trackC/`. + +`base` below means `start:1`, `start-step:1`, `message-metadata:1`, `data-agent-status:2`, +`text-start:1`, `text-end:1`, `finish-step:1`, and `finish:1`. + +| Case | Run | Length | Frames | Frames/s | Bytes/frame min/median/max | Total bytes | Event counts | +|---|---:|---:|---:|---:|---:|---:|---| +| Short | 1 | 10.456 s | 104 | 9.947 | 30 / 63 / 190 | 6,830 | base; `text-delta:95` | +| Short | 2 | 9.406 s | 116 | 12.333 | 30 / 63 / 202 | 7,589 | base; `text-delta:107` | +| Short | 3 | 9.501 s | 105 | 11.052 | 30 / 64 / 202 | 6,938 | base; `text-delta:96` | +| Long | 1 | 39.210 s | 2,866 | 73.093 | 30 / 63 / 191 | 182,626 | base; `text-delta:2763`; reasoning start/delta/end `1/92/1` | +| Long | 2 | 41.795 s | 3,161 | 75.632 | 30 / 63 / 192 | 201,056 | base; `text-delta:3069`; reasoning start/delta/end `1/81/1` | +| Long | 3 | 46.149 s | 2,745 | 59.481 | 30 / 63 / 192 | 175,156 | base; `text-delta:2649`; reasoning start/delta/end `1/85/1` | +| Tool-heavy | 1 | 18.074 s | 674 | 37.291 | 30 / 62 / 514 | 58,983 | base; text `387`; reasoning `2/180/2`; tool start/input/output/error `7/80/2/5` | +| Tool-heavy | 2 | 17.956 s | 664 | 36.979 | 30 / 62 / 514 | 50,988 | base; text `361`; reasoning `3/240/3`; tool start/input/output/error `6/36/2/4` | +| Tool-heavy | 3 | 15.360 s | 566 | 36.850 | 30 / 61 / 514 | 39,685 | base; text `366`; reasoning `2/164/2`; tool start/input/output/error `6/11/2/4` | + +Tool input snapshots repeat under one `toolCallId`. The relay must keep that identity so the client +updates one tool preview instead of creating a tool for every snapshot. + +## Envelope + +Every temporary frame uses these fields: + +- `version` (`metadata`): Identifies the compatible envelope version. +- `kind` (`metadata`): Is `frame` for temporary output. Durable records use `event`. +- `session_id` (`identity`): Identifies the conversation. It reuses the current `sessionId` value. +- `execution_id` (`identity`): Identifies one admitted turn. It reuses the current `turnId` value. +- `frame_or_event_id` (`identity`): Identifies this frame for duplicate suppression. +- `frame_index` (`ordering`): Increases by one within an execution. It is not a durable replay cursor. +- `type` (`payload`): Reuses the current invoke event type without renaming it. +- `entity_id` (`identity`): Reuses `id`, `toolCallId`, or `messageId`. Execution-level frames use `execution_id`. +- `payload` (`payload`): Carries the current event-specific fields with their existing names. +- `created_at` (`metadata`): Records when the producer created the frame in UTC. + +The producer assigns `frame_index` before ingress. `frame_or_event_id` is stable for that index on +a retry. Redis Stream IDs order storage operations only. Clients order frames by +`(execution_id, frame_index)` and use `entity_id` to update previews. + +## Existing invoke vocabulary + +The envelope wraps the current invoke projection. It does not define a second content protocol. + +| Current event family | Existing names and fields to retain | +|---|---| +| Stream lifecycle | `start.messageId`, `start.messageMetadata.sessionId`, `start-step`, `finish-step`, `finish.finishReason`, `finish.messageMetadata.traceId`, `finish.messageMetadata.usage` | +| Execution correlation | `message-metadata.messageMetadata.turnId` | +| Text and reasoning | `text-start`, `text-delta.delta`, `text-end`, `reasoning-start`, `reasoning-delta.delta`, `reasoning-end`; all reuse `id` | +| Tools | `tool-input-start`, `tool-input-available`, `tool-output-available`, `tool-output-error`, and `tool-output-denied`; all reuse `toolCallId` and existing input or output fields | +| Other content | `data-*`, `file`, `error`, and the measured `data-agent-status` frames | + +The current `/sessions/streams/watch` SSE is not a content source. It sends `ready`, +`records-changed`, `lifecycle`, `interaction`, and heartbeat notifications. The new relay carries +the invoke frames above and can keep the existing watch notifications separate. + +## Redis transport and retention + +The records ingest HTTP endpoint accepts both temporary frames and durable records. It publishes +frames to the dedicated `streams:session-live-frames` Redis Stream and leaves durable records on +`streams:records`. Both keys use the same durable Redis deployment, but their acknowledgement and +retention policies are independent. + +The live-frame stream applies both limits across the deployment: + +- Maximum age: **15 minutes**. +- Maximum length: **100,000 frames**, trimmed exactly when a frame is appended. + +The highest observed run-average rate was 75.632 frames/s. Fifteen minutes at that rate is +`75.632 * 900 = 68,069` frames. A 100,000-frame cap leaves 47 percent headroom for one run and +represents 22.0 minutes at that rate. It also holds 31.6 times the largest measured run of 3,161 +frames. Concurrent sessions share this disposable capacity. If relay lag crosses either bound, +clients reload the durable snapshot and follow current frames. + +The largest measured run used 201,056 frame bytes. Scaling its 63.6-byte average to 100,000 frames +gives about 6.36 MB of SSE frame bytes. This excludes the envelope and Redis overhead. Each +serialized frame is limited to 64 KiB before it reaches Redis. diff --git a/docs/design/session-control-and-live-events/plan.md b/docs/design/session-control-and-live-events/plan.md new file mode 100644 index 00000000000..3882fb0e410 --- /dev/null +++ b/docs/design/session-control-and-live-events/plan.md @@ -0,0 +1,86 @@ +# Design plan + +> AGENT-GENERATED, low weight. Draft for discussion. Mahmoud makes final decisions. + +## Parallel programs + +The work has three parallel programs. The order below is a discussion order, not a requirement +that one program finish before another starts. + +### Program A: Immediate control + +1. Sandbox-agent cancellation capability and Daytona rebuild impact. +2. Ownership and execution identity. +3. Immediate Stop delivery. +4. Stop settlement and sandbox preservation. +5. Approval and Stop races. + +### Program B: Shared reading + +1. Raw live-frame ingress. +2. Multi-client live relay. +3. Explicit execution lifecycle facts. +4. Append-only durable ordering and replay. +5. Sender becomes an ordinary reader. + +### Program C: Durable input + +1. Durable command admission. +2. Second-message policies: reject, queue, and steer. +3. Approval responses as commands. +4. Steer settlement and promotion. + +## Cross-cutting foundations + +These topics apply to all three programs: + +- Vocabulary and identifier ownership. +- Harness capability reporting. +- Authentication and authorization. +- Redaction and temporary-frame retention. +- Idempotency and duplicate delivery. +- Live-stack tests and failure injection. + +## Proposed discussion order + +The first two discussions can happen in parallel. + +1. **Stop and ownership:** current lease, immediate signal options, sandbox-agent dependency, + terminal settlement, and watchdog behavior. +2. **Live frames:** one raw ingress, Redis Stream layout, multi-client fan-out, and temporary + recovery. +3. **Durable ordering:** append-only event model, cursor allocation, snapshot boundary, and the + conflict with the existing UUIDv7 record-order decision. +4. **Sender detachment:** command acceptance, execution lifetime, and making the sender a reader. +5. **Durable commands:** command states, delivery, retries, and owner routing. +6. **Queue and Steer:** second-message policy, promotion order, interruption boundary, and + interaction races. +7. **Shared client engine:** desktop and mobile state application after the server contracts are + stable. + +Before finalizing the command contract, review the proposed public interface as a whole. The +review must distinguish user-facing resource endpoints from the private command transport used to +reach runners. + +## Definition of a completed track + +Each track must contain: + +- One user problem. +- One invariant. +- One interface or state transition contract. +- The main rejected alternatives. +- One live-stack test that proves the invariant. +- Known harness or deployment limitations. + +## Initial parallel investigation + +The sandbox-agent investigation starts before the Stop interface is fixed. It must answer: + +1. Which protocol request currently ends a prompt or execution? +2. Does that request also close the harness session? +3. Can Pi and Claude Code resume the same native session after cancellation? +4. Does the runner destroy or park the sandbox on each cancellation path? +5. Which source repository owns the required change? +6. Does Daytona need a new snapshot, and how is that snapshot version deployed? +7. What automated test proves cancel followed by warm resume? diff --git a/docs/design/session-control-and-live-events/records-invariants.md b/docs/design/session-control-and-live-events/records-invariants.md new file mode 100644 index 00000000000..fcb000d4569 --- /dev/null +++ b/docs/design/session-control-and-live-events/records-invariants.md @@ -0,0 +1,239 @@ +# Record properties and current violations + +> AGENT-GENERATED, low weight. Draft for discussion. Mahmoud makes final decisions. + +This note evaluates whether the existing `records` model can become the replayable session event +history. It does not select a storage option. + +## What records do today + +Records are a durable conversation representation. The runner sends raw `AgentEvent` values to +the live response, but coalesces text and thought deltas before record ingest. Tool-family events +can use deterministic IDs. The API publishes records into a dedicated Redis Stream. A worker +writes them into the tracing Postgres database. The frontend fetches the full record collection +and reconstructs `UIMessage[]`. + +Records are therefore closer to a durable transcript projection than a raw transport log. + +## Properties required for the current transcript + +The current transcript and harness reconstruction need these properties: + +1. **Durability.** An acknowledged durable fact survives client, API, runner, and worker restarts + within the configured retention period. +2. **Complete produced order.** Reads preserve the causal order of user messages, assistant + messages, tools, interactions, and terminal markers. +3. **Idempotent retry.** Retrying one logical fact does not create a duplicate or change its place + in history. +4. **Stable correlation.** Messages, tools, interactions, turns, and executions keep stable IDs so + later facts can refer to earlier ones. +5. **Detectable incompleteness.** If retention, truncation, quota, or delivery failure prevents + complete reconstruction, the system reports that condition instead of silently replaying a + partial conversation. +6. **Client independence.** Persistence does not depend on a browser connection. + +## Additional properties required for cursor replay + +A `snapshot + events after cursor` interface adds these requirements: + +1. **Immutable history.** Once a durable event is visible at a cursor, its payload and position do + not change. +2. **Monotonic commit order.** Every committed event gets an order that only moves forward. A + cursor can request all later events without scanning or comparing timestamps. +3. **Atomic visibility.** An event becomes replayable only after its durable write commits. +4. **Replay-to-live handoff.** A reader cannot miss an event between reading history and joining + the live tail. +5. **Stable event identity.** A producer retry maps to the same logical event and does not create a + second cursor entry. + +The sequence does not need to be dense or start at one for each session. It only needs to be +strictly increasing and stable. Gaps are harmless. A plain table-global Postgres sequence is not +enough by itself: Postgres allocates sequence values before commit, so transaction 102 can commit +and become visible before transaction 101. A client that advances to 102 could then miss the late +commit of 101. The write path must preserve commit visibility order, use a committed watermark, or +serialize sequence assignment and commit for each session. + +## Current violations + +### Some rows are mutable + +The primary key is `(project_id, record_id)`. `append` and `append_many` use +`ON CONFLICT DO UPDATE`. A conflict overwrites: + +- `record_type` +- `record_source` +- `timestamp` +- `attributes` +- `turn_id` +- `span_id` + +The runner supplies deterministic UUIDv5 IDs for `tool_call`, `tool_result`, +`interaction_request`, and `interaction_response` families. The stable ID lets repeated snapshots +or retries target one row. The DAO deliberately keeps the last payload. + +This supports a latest-state model. It violates immutable event history. + +### Record IDs do not encode order + +The design document proposed UUIDv7 IDs, but the implementation does not use them: + +- Tool-family records use deterministic UUIDv5 IDs. +- Other records receive backend-generated UUIDv4 IDs. + +UUIDv4 and UUIDv5 values are not time ordered. A client cannot use `record_id` as an `after` +cursor. + +### Current read order is reconstructed from three fields + +The DAO orders records by: + +1. Producer `timestamp`. +2. Database `created_at`. +3. Per-turn `record_index`. + +`record_index` restarts at zero for each execution. `created_at` can be shared by records in one +worker batch. Producer timestamps have clock and resolution limits. The composite order is useful +for transcript rendering, but it is not a stable cursor. + +An upsert also overwrites `timestamp`. A retry or later snapshot can therefore move an existing +row to a different place in the read order. + +### Retry identity is inconsistent + +Tool-family records have deterministic IDs and upsert on retry. Most message, thought, usage, +error, and terminal records omit `record_id`; the API mints a new UUIDv4 for every ingest. + +If Redis accepted the first request but the HTTP response was lost, a runner retry without a +stable ID can create a duplicate durable row. The system therefore uses idempotent retry for some +record types but not all record types. + +### Worker failures can acknowledge unwritten records + +The records worker adds every successfully decoded Redis message ID to `processed_ids` before it +attempts the Postgres batch write. If `append_many` fails, the worker logs the failure and +continues. It still returns those IDs to the shared consumer loop, which acknowledges and deletes +them from Redis. + +This is not an inherent Redis Streams limitation. It is an acknowledgement bookkeeping defect. + +### Runner delivery is bounded and can drop + +The runner retries record ingest a bounded number of times. After the limit, it records an +in-memory failure count and drops the record. The turn-end drain can mark reconstruction unsafe in +that runner process, but the missing fact never reaches the durable history. + +Bounded retry prevents an unavailable API from hanging execution forever. Permanent silent loss +is not required by that constraint. Accepted inputs and terminal outcomes need a recoverable +delivery source outside one runner process. + +### Retention, quotas, and truncation intentionally limit completeness + +Records live in the tracing database and have their own retention policy. Attributes larger than +64 KB are truncated before Redis ingest. Enterprise quota rejection can also skip a batch. + +These are real product and operational constraints. Any design that uses records for session +reconstruction or event replay must define what happens after retention, truncation, or quota +loss. Calling the collection complete without marking these conditions would be incorrect. + +## Structural reasons behind the current design + +### Coalescing is structurally useful + +Persisting every token permanently would increase write volume and storage significantly. The +durable transcript needs completed messages, not every typing-animation fragment. Coalescing raw +text into a completed message is compatible with an append-only durable log. + +### Retries and deduplication are structurally required + +Network and worker delivery is at least once. Stable event IDs and duplicate handling are +required. Mutating an existing row is not required. A final immutable fact can use +`ON CONFLICT DO NOTHING` after every durable event receives a stable producer ID. + +### Progressive tool snapshots do not require mutable durable history + +A live tool call can publish several argument snapshots. Those snapshots can remain temporary. +The durable model can append distinct facts such as `tool.started` and `tool.completed`, or append +one final `tool_call` fact. Reusing one ID and replacing its payload is a chosen projection model, +not a storage necessity. + +### The current stable-ID behavior needs a spike before immutability work + +The same `record_id` can currently mean two different things: + +1. **Delivery retry.** The producer sends the same logical fact and payload again because it did + not receive an acknowledgement. The second insert should be an idempotent no-op. +2. **Progressive update.** The producer sends a later payload for the same logical object. Treating + this as a duplicate no-op would discard the later state and can cause a regression. + +Current runner tests deliberately reuse stable IDs for repeated `tool_result` and +`interaction_response` events. Tool-call argument snapshots share an identity but are currently +coalesced into one final persisted record. The records DAO also documents later snapshots that +replace earlier payloads. These behaviors must be reconciled before changing upserts to immutable +inserts. + +The implementation plan therefore requires a producer-semantics spike. It must inventory every +stable-ID producer, retry path, resume path, and progressive-update path. For each case, it must +classify the repeated write as one of: + +- an identical retry that becomes a no-op; +- a temporary live update that stays outside durable history; +- a new durable fact that receives a new event ID and refers to the same stable tool, message, or + interaction ID. + +The spike is complete when tests cover each classified case and prove that immutable insertion +does not lose a final tool result, interaction response, terminal outcome, or reconstructed +conversation state. Immutable storage changes must not start before this gate passes. + +### A dense per-session counter is not required, but commit order is required + +The earlier design rejected a dense per-session sequence because concurrent writers would need a +counter row, lock, or serializable retry. Cursor replay does not need dense per-session numbers, +but it must not expose a higher cursor while a lower event can still commit later. A plain global +sequence does not provide that guarantee. Viable designs include a per-session transactional +counter and lock, one ordered projector per partition with session affinity, or a separate +committed watermark protocol. The final choice must match the expected write volume. + +### The asynchronous Redis worker is structurally useful + +Redis decouples runner latency from Postgres latency and absorbs bursts. It does not require the +worker to acknowledge failed database writes. Only successfully committed message IDs should be +acknowledged. + +### Retention remains a real constraint + +If session history must outlive tracing retention, the existing records location cannot meet that +requirement without changing retention or storage. If session history follows record retention, +the tracing database remains viable. This is a product decision, not an ordering limitation. + +## Changes that could make records satisfy the properties + +The existing records model could become an append-only replay source if it changes as follows: + +1. Give every durable logical event a producer-generated stable `event_id` before its first send. +2. Make durable inserts immutable. Duplicate `event_id` writes become no-ops or verified identical + duplicates. +3. Add a monotonic cursor whose visibility order matches commit order. Do not use a plain database + sequence without solving out-of-order commits. +4. Keep temporary deltas and progressive snapshots outside permanent records. Append only durable + starts, completions, interaction changes, and execution lifecycle facts. +5. Preserve stable message, tool, interaction, and execution IDs inside event payloads. +6. Acknowledge Redis messages only after their Postgres transaction commits. +7. Store or recover unacknowledged runner output across runner loss for required durable facts. +8. Mark a session history incomplete when truncation, quota, retention, or unrecoverable delivery + loss creates a gap. A replay reader must reject any record whose attributes contain + `_truncated`; it must not pass the partial `text`, `input`, or `output` to reconstruction. +9. Register the live wake-up before reading history so replay-to-live handoff cannot miss a commit. + +These changes are substantial, but there is no proven ordering or retry constraint that forces a +separate event table. The separate-table option must instead justify itself through schema scope, +retention, migration risk, or the desire to keep transcript projections distinct from lifecycle +events. + +## Questions to answer before comparing storage options + +1. Must durable session history outlive tracing-record retention? +2. Should records contain all session lifecycle facts, or only conversation facts? +3. Is the existing records API an internal projection, a public event contract, or both? +4. Can we migrate current upsert rows to immutable events without breaking harness reconstruction? +5. Which cursor assignment method preserves commit order at the expected operational scale? +6. Which durable facts must survive a runner crash before they reach Redis? diff --git a/docs/design/session-control-and-live-events/requirements.md b/docs/design/session-control-and-live-events/requirements.md new file mode 100644 index 00000000000..e0a523117e9 --- /dev/null +++ b/docs/design/session-control-and-live-events/requirements.md @@ -0,0 +1,181 @@ +# Bugs and system requirements + +> AGENT-GENERATED, low weight. Draft for discussion. Issue text is observation. Requirements are +> proposed interpretations until Mahmoud confirms them. + +## Stop and hung executions + +Issues: [#5160](https://github.com/Agenta-AI/agenta/issues/5160), +[#5982](https://github.com/Agenta-AI/agenta/issues/5982), +[#6418](https://github.com/Agenta-AI/agenta/issues/6418), +[#6100](https://github.com/Agenta-AI/agenta/issues/6100), +[#6449](https://github.com/Agenta-AI/agenta/issues/6449), +[#6099](https://github.com/Agenta-AI/agenta/issues/6099), +[#6420](https://github.com/Agenta-AI/agenta/issues/6420), +[#6327](https://github.com/Agenta-AI/agenta/issues/6327), +[#5788](https://github.com/Agenta-AI/agenta/issues/5788), +[#6102](https://github.com/Agenta-AI/agenta/issues/6102), +[#6103](https://github.com/Agenta-AI/agenta/issues/6103), +[#6084](https://github.com/Agenta-AI/agenta/issues/6084), +[#5356](https://github.com/Agenta-AI/agenta/issues/5356), +[#5327](https://github.com/Agenta-AI/agenta/issues/5327), +[#6441](https://github.com/Agenta-AI/agenta/issues/6441), +[#6313](https://github.com/Agenta-AI/agenta/issues/6313). + +Observed examples: + +> “After clicking Stop, the UI reflects the stop action immediately, but backend processing +> continues for several minutes.” ([#5160](https://github.com/Agenta-AI/agenta/issues/5160)) + +> “The turn hangs forever. `runTurn` never resolves, the alive watchdog keeps heartbeating +> `running=true`.” ([#6418](https://github.com/Agenta-AI/agenta/issues/6418)) + +Draft requirements: + +- Normal Stop reaches the active runner within a defined short deadline. +- Every accepted execution reaches exactly one durable terminal outcome. +- The sender and every other reader see the same terminal outcome. +- Runner, sandbox, provider, tool, and adapter failures cannot leave an unbounded running state. +- Normal Stop preserves the session workspace and leaves the harness session warm and resumable. +- A watchdog settles work when the owning runner cannot produce the terminal outcome. +- A slow tool fails with an explicit tool or execution result. It does not disappear silently. + +## Steer and concurrent sends + +Issues: [#6417](https://github.com/Agenta-AI/agenta/issues/6417), +[#6020](https://github.com/Agenta-AI/agenta/issues/6020), +[#5790](https://github.com/Agenta-AI/agenta/issues/5790), +[#5539](https://github.com/Agenta-AI/agenta/issues/5539), +[#5538](https://github.com/Agenta-AI/agenta/issues/5538). + +Observed examples: + +> “I expect the platform to queue the message, or to refuse it with a clear signal. Instead both +> turns die and the session refuses every message for 30 minutes.” +> ([#6417](https://github.com/Agenta-AI/agenta/issues/6417)) + +> “The steering turn itself fails with an error and an empty reply, and every turn I send on that +> session afterwards fails the same way.” ([#6020](https://github.com/Agenta-AI/agenta/issues/6020)) + +Draft requirements: + +- At most one execution is active for a session at one time. +- Only the current execution ownership generation can append events or cause external effects. +- A second message uses an explicit `reject`, `queue`, or `steer` policy. +- The API saves an accepted queue or steer message before interrupting current work. +- The API resolves every execution-affecting command to one execution before delivery. +- Public Stop can optionally name the execution the caller expects. If omitted, it targets the + current execution. +- An older runner cannot reclaim ownership or write after replacement. +- A failed steer leaves the saved message visible and recoverable. + +## Reattach and multiple readers + +Issues: [#5609](https://github.com/Agenta-AI/agenta/issues/5609), +[#5542](https://github.com/Agenta-AI/agenta/issues/5542), +[#6404](https://github.com/Agenta-AI/agenta/issues/6404), +[#5611](https://github.com/Agenta-AI/agenta/issues/5611), +[#5443](https://github.com/Agenta-AI/agenta/issues/5443), +[#5384](https://github.com/Agenta-AI/agenta/issues/5384), +[#6397](https://github.com/Agenta-AI/agenta/issues/6397), +[#5990](https://github.com/Agenta-AI/agenta/issues/5990), +[#6388](https://github.com/Agenta-AI/agenta/issues/6388), +[#6468](https://github.com/Agenta-AI/agenta/issues/6468), +[#5950](https://github.com/Agenta-AI/agenta/issues/5950). + +Observed examples: + +> “A tab that never regains focus misses a run started in another browser.” +> ([#5609](https://github.com/Agenta-AI/agenta/issues/5609)) + +> “Reload the page. After the reload: The approval card is gone entirely.” +> ([#5542](https://github.com/Agenta-AI/agenta/issues/5542)) + +Draft requirements: + +- Every authorized client can follow one execution concurrently. +- Every connected client receives live frames, not only completed messages. +- Refresh, navigation, and sender disconnection do not stop the execution. +- A snapshot declares the durable event cursor it represents. +- A reader can replay durable events after that cursor and then follow new events. +- Missed temporary frames are repaired by the next durable checkpoint. +- Pending interactions remain visible and actionable after reload. +- Session identity is stable in URLs and across client caches. + +## Record durability and ordering + +Issues: [#5496](https://github.com/Agenta-AI/agenta/issues/5496), +[#5594](https://github.com/Agenta-AI/agenta/issues/5594). + +Observed examples: + +> “The session-records pipeline loses records permanently in three separate ways, and reports +> success while doing it.” ([#5496](https://github.com/Agenta-AI/agenta/issues/5496)) + +> “The records worker rejects the whole batch.” +> ([#5594](https://github.com/Agenta-AI/agenta/issues/5594)) + +Draft requirements: + +- A successful ingest acknowledgment has a precise durability meaning. +- One bad record cannot silently discard unrelated records in the same batch. +- Retries are idempotent and cannot change established event order. +- Durable replay uses append-only facts with a stable cursor. +- A detected persistence gap marks the session history incomplete. +- The runner drains required durable writes before terminal settlement. + +## Approvals and pauses + +Issues: [#6315](https://github.com/Agenta-AI/agenta/issues/6315), +[#6316](https://github.com/Agenta-AI/agenta/issues/6316), +[#6106](https://github.com/Agenta-AI/agenta/issues/6106), +[#5907](https://github.com/Agenta-AI/agenta/issues/5907), +[#5592](https://github.com/Agenta-AI/agenta/issues/5592), +[#5638](https://github.com/Agenta-AI/agenta/issues/5638), +[#5545](https://github.com/Agenta-AI/agenta/issues/5545), +[#5097](https://github.com/Agenta-AI/agenta/issues/5097). + +Observed examples: + +> “The playground keeps rendering an actionable card whose buttons do nothing.” +> ([#6315](https://github.com/Agenta-AI/agenta/issues/6315)) + +> “When I answer a parked approval and the resumed run fails to start, the approval is gone.” +> ([#5592](https://github.com/Agenta-AI/agenta/issues/5592)) + +Draft requirements: + +- An interaction has one visible state: pending, resolved, denied, or cancelled. +- Stop cancels pending interactions for the stopped execution. +- A late answer cannot resume a cancelled or replaced execution. +- An answer is not consumed until its continuation has a recoverable outcome. +- Side-effecting tools do not run twice after pause and resume. +- One user-visible conversation turn remains traceable across approval resumes. + +## Session list and identity + +Issues: [#6419](https://github.com/Agenta-AI/agenta/issues/6419), +[#6463](https://github.com/Agenta-AI/agenta/issues/6463), +[#5969](https://github.com/Agenta-AI/agenta/issues/5969), +[#6457](https://github.com/Agenta-AI/agenta/issues/6457), +[#6031](https://github.com/Agenta-AI/agenta/issues/6031), +[#6214](https://github.com/Agenta-AI/agenta/issues/6214). + +Observed example: + +> “The session rail shows a session titled with my message, and the conversation is empty.” +> ([#6419](https://github.com/Agenta-AI/agenta/issues/6419)) + +Draft requirements: + +- A user message accepted by the API is never lost when execution fails to start. +- A visible session has an explicit origin and owner type. +- Session list updates converge without requiring a full page reload. +- Rename and archive operations have observable success or failure. +- Session identity does not depend on the browser that created it. + +## Requirement status + +This file does not yet state priority or implementation order. Some issues may share a cause, and +some may fall outside the final RFC. Each design-track discussion must confirm which requirements +it owns and which linked issues it expects to close. diff --git a/docs/design/session-control-and-live-events/research.md b/docs/design/session-control-and-live-events/research.md new file mode 100644 index 00000000000..9abe25ff861 --- /dev/null +++ b/docs/design/session-control-and-live-events/research.md @@ -0,0 +1,202 @@ +# Research notes + +> AGENT-GENERATED, low weight. Draft for discussion. Mahmoud makes final decisions. + +## Verified current behavior + +### Normal message delivery + +The desktop sends messages through the workflow invoke transport. The response carries the live +event stream for that sender. The desktop Send path does not yet use the session command endpoint. + +### Normal Stop + +The desktop aborts its local response, then posts to `/sessions/streams/` with `session_id`, no +inputs, and `force=false`. The API classifies this as Cancel. It marks the current Redis turn owner +as superseded and clears the `alive` and `running` keys. The runner learns that it lost ownership +when a heartbeat returns `is_current_turn=false`, then aborts locally. + +### Hard kill + +`DELETE /sessions/streams/?session_id=...` is separate from normal Cancel. It contacts the runner +and tears down the sandbox. The session remains resumable after Cancel but not after Kill. + +The v1 direct client uses one configured `runner.internal_url`. It is correct for a single runner, +or when the URL fronts an owner-aware router. Redis separately stores the logical owner +`replica_id`, but the direct client does not resolve that identity to a replica-specific address. +A request that reaches the wrong replica returns not found and must not be treated as success. + +Immediate Cancel remains durable, so an unavailable owner can recover and apply it later or be +settled as lost. Kill is best effort through the same configured URL. Until owner-aware forwarding +exists, a multi-runner Kill cannot guarantee immediate teardown; authoritative session state is +cleared and sandbox lease or orphan cleanup provides the fallback. + +### Heartbeat + +The runner posts `session_id`, `replica_id`, `turn_id`, and `is_running` to +`/sessions/streams/heartbeat`. The heartbeat renews temporary ownership, mirrors liveness to the +session row, and currently carries the delayed cancellation result back to the runner. + +### Records + +The runner forwards raw events to the sender and performs message and tool coalescing before +durable ingest. Durable records travel through a Redis Stream and worker into Postgres. Record +writes use upsert behavior. + +### Watch relay + +The current SSE watch endpoint relays change notifications through Redis Pub/Sub. A reader then +refetches durable records. It does not relay raw tokens and cannot replay missed Pub/Sub messages. + +## Existing design decision that must be revisited + +`docs/designs/sessions/records/specs.md` states: + +> Ordering = uuid7 `id`, no stored `seq`. + +The same document describes records as append-only, but current implementation uses stable record +IDs and upserts. A retry can therefore update an existing row. The RFC must define whether replay +uses a new append-only event log or changes the record model. + +## Dependency to verify early + +Another design review reports that the vendored sandbox-agent cannot cancel an execution while +preserving the harness session, and that a patch would require a Daytona snapshot rebuild. This +has not yet been verified in this workspace. It is the first research task for the Stop track. + +## Current command endpoint is not a durable command system + +`POST /sessions/streams/` derives four modes from the presence of inputs and the `force` flag: + +| Inputs | `force` | Derived mode | +|---|---:|---| +| Present | `false` | Send | +| Present | `true` | Steer | +| Absent | `false` | Cancel | +| Absent | `true` | Attach | + +The endpoint edits Redis coordination state and the session stream row. Its own DTO states that it +runs nothing. Normal desktop Send still uses the workflow invoke path. Desktop Stop uses the Cancel +mode. Attach acquires watcher bookkeeping but does not deliver live frames. Interaction responses +use their own endpoint and worker path. Kill uses `DELETE /sessions/streams/`. + +This means the current endpoint does not provide a durable inbox, command status, retry handling, +or a single route for all execution-affecting actions. + +## Current interaction response path + +The frontend calls `POST /sessions/interactions/{interaction_id}/respond`. The API checks that the +interaction is pending and atomically changes it to `responded`. The winning responder enqueues a +TaskIQ job. The interaction dispatcher reconstructs the resume conversation from durable records +and calls the workflow invoke service in detached mode. Approval response therefore already uses a +resource-specific public endpoint followed by an internal invoke. + +## Current runner routing information + +Redis stores a logical `replica_id` for the runner that owns a session. The API hard-kill client +does not resolve this identifier to an address. It calls one configured runner service URL with +`project_id` and `session_id`. A normal load-balanced request is not sufficient when only one +replica holds the live sandbox, unless the runner service provides its own owner routing. + +## Existing design references + +- `docs/design/agent-workflows/projects/sessions-takeover/architecture.md` +- `docs/design/agent-workflows/projects/sessions-takeover/opencode-comparison.md` +- `docs/design/agenta-mobile/plans/2026-07-27-m3-live-relay.md` +- `docs/design/agenta-mobile/plans/2026-07-27-mobile-approvals-steering.md` +- `docs/designs/sessions/records/specs.md` +- `docs/designs/sessions/interactions/specs.md` + +## Public API comparison + +This comparison uses public vendor documentation. It describes interface shapes, not internal +implementations. + +### Gumloop + +Gumloop models one workflow execution as a run: + +- `POST /api/v1/start_pipeline` starts work and returns `run_id`. +- `GET /api/v1/get_pl_run?run_id=...` returns the run state, logs, and outputs. +- `POST /api/v1/kill_pipeline` accepts `run_id` and stops that run. + +The kill operation is a POST. It does not delete the workflow definition. The caller uses the +`run_id` returned by the start request. + +Sources: + +- https://docs.gumloop.com/api-reference/running-an-automation/start-automation +- https://docs.gumloop.com/api-reference/running-an-automation/retrieve-run-details +- https://docs.gumloop.com/api-reference/running-an-automation/kill-automation + +### OpenAI Responses background mode + +OpenAI models one background execution as a Response: + +- `POST /v1/responses` with `background: true` starts work and returns a Response with an ID. +- `GET /v1/responses/{response_id}` retrieves its current state and result. +- `POST /v1/responses/{response_id}/cancel` cancels it. Repeating Cancel is idempotent. +- Creating with both `background: true` and `stream: true` provides live events. A disconnected + reader can reconnect with `starting_after=`. + +This is the closest public example to the target read model. Execution continues independently +of the first stream. The same response ID identifies retrieval, cancellation, and resumed +streaming. + +Source: https://developers.openai.com/api/docs/guides/background + +### Claude Managed Agents + +Claude Managed Agents models control as events sent to a persistent session: + +- A `user.message` event starts or continues work. +- A `user.interrupt` event stops current work. +- Sending `user.interrupt` followed by `user.message` redirects the session. +- `GET /v1/sessions/{session_id}/events/stream` provides session events. Optional delta events + provide live text previews. Buffered message events remain authoritative. +- Tool confirmation is another event, `user.tool_confirmation`, tied to the pending tool event ID. +- Deleting a session is separate. Deletion permanently removes its events and sandbox. + +The public interrupt targets a session. The service resolves which internal execution must stop. +Claude also documents that model output can stop immediately while an active tool can take longer. + +Sources: + +- https://platform.claude.com/docs/en/managed-agents/events-and-streaming +- https://platform.claude.com/docs/en/managed-agents/session-operations + +## Findings from the public comparison + +The three interfaces use different names, but they agree on four points: + +1. Starting work returns or uses a stable public identifier. +2. Reading status is separate from stopping work. +3. Stop is an action. It does not mean deleting the session or workflow. +4. Deletion remains a separate destructive operation. + +They differ on the Stop target: + +- Gumloop and OpenAI target a specific execution ID. +- Claude targets the session and lets the service interrupt its current work. + +Agenta can support both safety and convenience. The browser can send a session-scoped Cancel with +an `expected_execution_id` prefilled from state. A human never types the execution ID. The API +rejects the Cancel if that execution already ended and another one started. + +## Public queue visibility + +The reviewed Gumloop public API exposes a run state of `QUEUED`, but its documented run API does +not expose an editable per-session message queue. It starts runs, retrieves run state, and kills a +run. This is a workflow-run queue rather than a conversation input queue. + +OpenAI background Responses expose a `queued` execution status and allow cancellation. The public +background-mode documentation does not expose editing or reordering queued conversation inputs. + +Claude Managed Agents comes closer to a conversation inbox. User events are persisted in order. +Each event has `processed_at=null` while it waits behind earlier events, and past events can be +listed. The reviewed documentation does not describe patching or reordering an already-sent user +event. + +The proposed Agenta pending-input API therefore goes beyond these reviewed public interfaces. It +addresses a product-specific need: Queue currently exists in browser state, and multiple clients +need one visible shared copy. The initial design keeps queued inputs immutable. diff --git a/docs/design/session-control-and-live-events/rfc.md b/docs/design/session-control-and-live-events/rfc.md new file mode 100644 index 00000000000..223fd5ad766 --- /dev/null +++ b/docs/design/session-control-and-live-events/rfc.md @@ -0,0 +1,484 @@ +# RFC: Session control and live events + +> AGENT-GENERATED, low weight. Draft for discussion. No architecture is approved yet. + +## Status + +Pre-design. The problem inventory and process decisions exist. Technical sections will be written +after each design-track discussion. + +## Problem statement + +Agenta currently couples live output to the sending request, uses a heartbeat response as the +normal cancellation signal, and lacks an append-only replay cursor for session changes. This makes +multi-client reading, fast Stop, durable queueing, and reliable reconnect difficult to compose. + +## Required properties + +See [Requirements](requirements.md). The RFC will include only requirements confirmed during the +track discussions. + +## Proposed architecture + +Pending discussion. + +### Public interface boundary + +The working public interface separates four operations. Every route in this section is a proposed +new public contract, not a description of an existing endpoint and not yet an approved API. + +1. Send intent to a session. +2. Read the current session snapshot. +3. Follow session changes. +4. Delete a session permanently. + +The proposal does not require one generic public command endpoint. Clear resource-specific +endpoints can all feed one private command-delivery mechanism. + +Create or send session work: + +```http +POST /sessions/{session_id}/commands +Idempotency-Key: + +{ + "type": "send", + "message": "Explain this failure", + "on_busy": "reject" +} +``` + +Stop the current execution, but only if it is still the execution the caller observed: + +```http +POST /sessions/{session_id}/cancel + +{ + "expected_execution_id": "execution-12" +} +``` + +The browser learns `execution-12` from the session snapshot or the `execution.started` event. The +person pressing Stop never enters it. This field prevents a delayed Stop request from cancelling +new work that started after the button was pressed. The field is optional. Without it, the API +uses Redis arrival and turn-start timestamps and refuses the request if the active execution began +after the request arrived. A client that needs unconditional session-scoped cancellation must use +a future command contract. + +Respond to an interaction through a resource-specific public endpoint: + +```http +POST /sessions/{session_id}/interactions/{interaction_id}/responses + +{ + "answer": {"approved": true}, + "expected_execution_id": "execution-12" +} +``` + +The API can translate the response into the same internal command envelope used by Send, Cancel, +Queue, and Steer. The public caller does not need to understand internal runner routing. + +Read current state. This is an ordinary query, not an event endpoint: + +```http +GET /sessions/{session_id} +``` + +This is not the current `GET /sessions/streams/?session_id=...`, which returns only coordination +and liveness data. + +Follow durable events and live frames: + +```http +GET /sessions/{session_id}/events?after= +Accept: text/event-stream +``` + +This is not the current `GET /sessions/streams/watch`, which sends change notifications and asks +the client to refetch records. The proposed endpoint sends replayable session events and then live +events. + +Rename, archive, delete, and hard termination remain explicit session resource or lifecycle +operations. Attach is replaced by reading the snapshot and event stream. + +### Current and proposed public behavior + +| Operation | Today | Proposed direction | Change | +|---|---|---|---| +| Send | Invoke a workflow and read its response stream | Keep this during migration. Later accept work independently and return an execution ID | Later change | +| Stop | `POST /sessions/streams/` with no inputs and `force=false` | `POST /sessions/{id}/cancel` with an optional expected execution ID | Clearer endpoint and faster delivery | +| Hard kill | `DELETE /sessions/streams/?session_id=...`; destroys the sandbox | Keep as a separate destructive operation with an explicit name | Rename or reshape only | +| Answer approval | `POST /sessions/interactions/{interaction_id}/respond` | Keep a resource-specific response endpoint. Improve acknowledgement and resume guarantees internally | Public shape mostly unchanged | +| Queue while busy | Browser-local queue | Save the message on the server with `on_busy: queue` | Changes ownership from browser to server | +| Steer while busy | Ambiguous `force=true` coordination mode; normal send still uses invoke | Save the message, request interruption, then start the saved message | Behavior becomes explicit and durable | +| Attach | `force=true` without inputs records watcher state but does not provide live output | Remove the command. Load a snapshot, then follow events | Replaced by read operations | +| Load current state | Several queries for records, liveness, and pending interactions | One versioned session snapshot, or a documented composition of existing queries | Open design choice | +| Follow changes | SSE sends change notifications; the browser refetches records | Replay events after a cursor, then continue with live frames | Changes from invalidation to replay plus live tail | +| Delete | Separate destructive behavior exists through the stream API | Explicit session deletion after work is stopped | Public naming changes | + +### Busy-message policies + +The words `reject`, `queue`, and `steer` apply only when a new user message arrives while an +execution is already running: + +- `reject`: return a conflict response. Do not save or start the new message. +- `queue`: save the new message. Start it after current work stops normally. +- `steer`: save the new message. Interrupt current work, then start the new message. + +When the session is idle, all accepted messages start normally. The contract calls this field +`on_busy` so its purpose is clear. + +### Visible pending messages + +Once Queue moves from the browser to the server, every client must be able to see the same pending +messages. A session snapshot can include them: + +```json +{ + "pending_inputs": [ + { + "id": "input-24", + "type": "user_message", + "content": "Then check the database", + "position": 1, + "status": "pending" + } + ] +} +``` + +The event stream announces changes: + +```text +input.queued +input.removed +input.promoted +``` + +Queued inputs are immutable. The management interface only needs removal: + +```http +DELETE /sessions/{session_id}/inputs/{input_id} +``` + +To change a pending message, the client removes it and submits a replacement. DELETE rejects the +request after the input was promoted into active work. The server processes pending inputs in FIFO +order, which means first in, first out. The initial interface does not support reordering. + +This keeps clients synchronized. A message is no longer hidden inside one browser's local queue. + +### One public interface for all clients + +Agenta desktop, mobile, bots, and external API users should call the same public session API. A +first-party browser must not depend on a separate privileged execution endpoint. + +The runner still needs a private protocol because it performs trusted internal work. That private +protocol carries claims, heartbeats, event frames, acknowledgements, and control wake-ups. It is +not a second product API. + +### Interaction responses + +Moving interaction response under the session URL does not itself improve correctness. It only +makes session ownership and authorization visible in the path. The current endpoint can remain: + +```http +POST /sessions/interactions/{interaction_id}/respond +``` + +or the clean public contract can use: + +```http +POST /sessions/{session_id}/interactions/{interaction_id}/responses +``` + +The material change is internal. The API must durably accept the response, make one response win, +and expose whether continuation is pending, running, or failed. URL nesting is a consistency +choice, not the reason for changing approval handling. + +### Private control path + +The public Cancel request does not need a runner address. A simple internal flow is: + +1. The browser sends Cancel to the API. +2. The API records that execution 12 must stop. +3. The API sends a private wake-up to the runner that owns execution 12. +4. The runner stops local work and reports `execution.cancelled`. +5. Every browser receives that event. + +The API already knows the logical runner owner as `replica_id`. It does not yet know a reliable +network address for that replica. The implementation must add one of these private delivery +mechanisms: + +- The runner keeps an outbound connection open to the API. The API sends control messages on it. +- The runner subscribes to a private per-runner broker channel. +- The runner service adds owner-aware routing behind one internal URL. + +This private choice does not change the public Cancel endpoint. A heartbeat remains useful for +renewing ownership and detecting a crashed runner. It stops being the normal way to deliver +Cancel. + +### Execution identity and ownership + +Current Redis state identifies a logical runner replica and the current `turn_id`. The API does +not currently map that replica identifier to a replica-specific network address. The hard-kill +path calls one configured runner service URL. The RFC must select an immediate-control routing +mechanism before it can define fast Cancel delivery. + +The recommended routing pattern for discussion is: + +1. The API saves or atomically records the command. +2. The API identifies the logical owner `replica_id`. +3. A private control channel wakes that runner immediately. +4. The runner acknowledges and applies the command. +5. Heartbeat or periodic recovery finds commands whose wake-up was lost. + +The runner can initiate the control connection to the API. This would support possible future +user-operated runners behind firewalls and keep Redis credentials behind the API boundary. That +future deployment model is a consideration, not a confirmed requirement. + +The simplest first implementation is durable long polling. The runner makes an authenticated +request that the API holds briefly until a command is available. The runner receives the command, +acknowledges it, and immediately opens the next request. A disconnected runner reconnects and +claims commands that remain durable. Redis or Postgres notifications may wake API replicas +internally, but the runner never connects to either system. + +Credential-bearing long polls require HTTPS with normal certificate validation. The client must +disable redirects or reject any redirect whose origin differs from the configured API origin, and +it must never forward runner credentials across origins. + +A persistent WebSocket or bidirectional stream can later reduce repeated requests and carry richer +runner status. It is not required for the first contract. Direct API calls into runner pods and +per-runner Redis subscriptions are poor fits for user-operated runners because they require inbound +reachability or infrastructure credentials. + +Control delivery must sit behind an internal port. Session command handling depends on this port, +not on a particular transport: + +```text +deliver(owner, command) +acknowledge(command_id, owner) +recover(owner) +``` + +Initial adapter: authenticated long polling. Possible later adapters: persistent WebSocket, +private Redis delivery, or direct managed-runner routing. Durable command state, authorization, +idempotency, execution fencing, and terminal settlement remain outside the adapter. Replacing the +adapter must not change the public session API or command state machine. + +The required invariant is stronger than “the second start usually gets a conflict”: at most one +execution is active for a session, and only the current owner can write or cause external effects. +The current Redis `alive` lease, owner affinity, heartbeat refresh, and superseded markers reduce +overlap. They do not fully enforce this invariant after lease expiry or a network partition because +record ingest does not reject a stale ownership generation. + +The target design therefore separates two jobs: + +1. **Admission and fencing.** The API atomically accepts one execution and assigns an increasing + ownership generation. Every runner event carries the execution ID and generation. The API + rejects stale generations. Settlement releases ownership only when both values match. +2. **Failure detection.** A heartbeat renews the active lease. If it expires, recovery can mark the + execution lost and assign a newer generation. The heartbeat detects failure, but it is not the + only protection against two writers. + +The RFC does not yet choose whether admission state belongs in Postgres, Redis with a durable +command record, or a transaction across projections. The selected design must prove atomic +concurrent admission and stale-write rejection. + +### Immediate control + +The existing `/sessions/streams/` endpoint is a coordination-state edit, not a durable command +inbox. It derives Send, Steer, Cancel, and Attach from inputs plus a `force` flag. Normal desktop +Send does not use this endpoint. A future explicit command contract must replace the ambiguous +shape without silently changing existing invoke behavior. + +### Command delivery and execution settlement + +Command delivery and execution lifecycle are separate state machines: + +```text +command: pending -> claimed -> applied + -> obsolete + +execution: running -> stopping -> stopped + -> failed + -> lost +``` + +The API accepts Stop by durably creating the command and moving the matching execution to +`stopping` in one transaction. `expected_execution_id` remains optional. A command claim has a +lease and can be delivered again after disconnection. In a fenced design, the runner deduplicates +by `command_id` and validates both the execution ID and ownership generation before applying it. +The v1 direct-delivery adapter has no generation token; it validates the target execution ID and +requires the addressed runner replica to own that execution. + +Claiming or acknowledging a command does not prove that execution stopped. Public clients follow +execution state. The runner normally reports the terminal outcome and the API settles the command +and execution together. If the runner disappears, a watchdog records `lost`; another runner cannot +claim that it stopped work on the missing machine. The settlement deadline will be selected after +the sandbox cancellation spike. + +### First-version ownership scope + +The first version retains Redis as the execution ownership authority. It does not introduce a new +Postgres execution table, ownership generation, or general fencing migration. + +When Stop is accepted, the API saves the durable command but does not immediately free the current +`alive` lock. Long polling delivers the command. The heartbeat can discover the same pending +command as a fallback. The runner releases owner-checked `running` and `alive` keys only after +cancellation settles, so new work cannot start during normal cancellation. + +This scope accepts the current network-partition limitation. Full multi-runner correctness and +stale-writer fencing remain future work. The command and control-delivery ports must not depend on +Redis-specific ownership details, so that later work can replace the ownership adapter. + +### Live frame ingress and relay + +The working model has one raw runner event ingress. The API acknowledges a frame only after it is +accepted into the shared Redis Stream. Live readers consume temporary frames from that stream. +The durable projector consumes the same source and commits permanent facts. + +Browser delivery never blocks the runner. A slow reader is disconnected and later recovers from +durable state. With multiple API replicas, the runner and readers can connect to different +replicas because Redis and Postgres hold the shared state. A runner-to-API disconnect does not +stop execution; the runner reconnects and resends unacknowledged frames. + +### Durable events and replay + +The durable history requires immutable event IDs, an order whose visibility matches database +commit order, idempotent retries, and a replay-to-live handoff that cannot miss a commit. A plain +Postgres `BIGSERIAL` is insufficient by itself because sequence allocation can precede an +out-of-order transaction commit. + +Two storage options remain under consideration. + +#### Option A: Repair records into the session event history + +Change records so every durable fact has a stable producer ID, immutable payload, and commit-safe +session cursor. Duplicate delivery becomes a no-op. Add execution, input, and interaction +lifecycle facts so the same append-only history can build the transcript and the session snapshot. + +Benefits: + +- One permanent history to write, retain, query, and debug. +- Existing transcript and harness reconstruction already read records. +- No consistency problem between two permanent logs. + +Costs and risks: + +- Changes the existing upsert contract and tool snapshot behavior. +- Expands a conversation-oriented tracing record into the public session event contract. +- Requires a migration story for old rows without cursors and current record retention. +- Requires commit-safe ordering and reliable delivery changes regardless of table reuse. + +This option has a mandatory discovery gate. Today a repeated stable `record_id` can be an exact +transport retry or a later snapshot with changed payload. Only the exact retry becomes a no-op. +The producer-semantics spike in `records-invariants.md` must classify every reuse and add regression +tests before the upsert contract changes. + +#### Option B: Keep records as a transcript projection and add a session event log + +Keep current records for conversation and harness reconstruction. Add an immutable session event +history for input, execution, tool, interaction, and message lifecycle events. Build session +snapshots from that history or projections updated in the same transaction. + +Benefits: + +- Leaves the current transcript and harness path largely intact during migration. +- Gives the public event contract its own schema and retention policy. +- Separates mutable or coalesced transcript projections from immutable lifecycle facts. + +Costs and risks: + +- Two permanent representations of some conversation facts. +- The projector must keep records and session events consistent. +- Debugging and recovery must define which representation is authoritative. +- More schema, storage, migrations, and cleanup machinery. + +#### Redis as permanent history + +Redis remains the temporary ingress and delivery buffer. It is not a permanent session history in +this draft because the Stream is bounded, entries are acknowledged and deleted, and Redis does not +match the existing Postgres retention, query, and recovery model. + +#### Snapshot and stream consistency + +The snapshot is a durable projection through cursor N. The event endpoint replays durable events +after N and then follows newly committed events. Snapshot data and cursor must be read from one +consistent database view, or the projection and cursor must update in the same transaction. + +Temporary live frames do not advance the durable cursor. The next durable completion repairs +missed previews. A reader subscribes to commit wake-ups before reading replay history so a commit +cannot fall between the historical read and live tail. + +The delivery chain must handle these failure boundaries: + +1. Runner to API: retry unacknowledged frames after reconnect. +2. API to Redis: acknowledge only after `XADD` succeeds. +3. Redis to projector: leave failed work pending for retry. +4. Projector to Postgres: append events and update projections in one transaction. +5. Postgres to live wake-up: a lost wake-up is repaired by querying after the cursor. +6. API to browser: reconnect after the last durable cursor. + +### Detached sender + +Starting work and watching work are separate operations. The API durably accepts an input and +returns without waiting for a runner claim, harness start, first output frame, or reader +connection. The execution then proceeds independently of the submitting HTTP request. + +The durable acceptance boundary includes: + +- The submitted input. +- Its idempotency identity. +- Its session association. +- Its accepted execution intent. + +The sender then reads the same session event stream as desktop, mobile, bots, and external +clients. Disconnecting any reader does not cancel or park the execution. A convenience request +may submit and begin streaming in one call, but that response remains a reader of an independently +accepted execution. + +During migration, the current invoke response can continue serving the sender while the shared +read path is introduced. The final client model removes this privileged sender path. + +### Durable commands + +Working scope for discussion: + +- Send a user message. +- Cancel an expected execution. +- Respond to an interaction. +- Queue a message. +- Steer with a saved message. + +Attach belongs to the read path. Kill, rename, archive, and delete remain separate lifecycle or +resource operations in the working model. + +The internal command transport does not require every public action to use one generic endpoint. +Public resource endpoints can validate domain-specific input and then create the common internal +command. + +### Queue and Steer + +Pending discussion. + +### Approvals and pauses + +Pending discussion. + +### Client state application + +Pending discussion. + +## Migration + +Pending discussion. The migration must preserve the current sender stream until the shared read +path passes its live-stack tests. + +## Test plan + +Pending discussion. Each architecture section must add one invariant and one live-stack test. + +## Rejected alternatives + +Pending discussion. diff --git a/docs/design/session-control-and-live-events/slice-admission.md b/docs/design/session-control-and-live-events/slice-admission.md new file mode 100644 index 00000000000..1aa2633389e --- /dev/null +++ b/docs/design/session-control-and-live-events/slice-admission.md @@ -0,0 +1,373 @@ +# Slice: single-turn admission + +Status: built and verified live on 2026-09-02. Branch `feat/session-single-turn-admission`. +Not pushed, no pull request. + +This slice makes one invariant true: **at most one execution runs per session, decided in one +place.** A second message sent while a turn is running is refused before anything is destroyed. +That is the `on_busy: reject` policy. Queue and steer are not in this slice. + +Closes [#6417](https://github.com/Agenta-AI/agenta/issues/6417), +[#5539](https://github.com/Agenta-AI/agenta/issues/5539), and +[#5538](https://github.com/Agenta-AI/agenta/issues/5538). + +--- + +## What happens today, and why + +A user sends a second message while the agent is still answering the first. Both turns die and +the session stays locked for about thirty minutes. Every step below is **verified** in code. + +1. A desktop Send does not go through the session coordination endpoint. It goes to the workflow + invoke path, `POST /services/agent/v0/invoke` + (`web/packages/agenta-playground/src/state/execution/agentRequest.ts:400`). The only caller of + `commandSessionStream` in the web tree is Stop. +2. The runner mints its own turn id for that request + (`services/runner/src/server.ts:189`). +3. The runner starts the turn's alive watchdog **before** it touches any sandbox + (`services/runner/src/server.ts:519` versus the run at `:621`). That watchdog's first heartbeat + is an atomic `nx` acquire of the session's `alive` lock in the API + (`api/oss/src/core/sessions/streams/service.py:513`). +4. The second turn loses that acquire, because a different turn holds `running` + (`api/oss/src/core/sessions/streams/service.py:534`). The API answers `is_current_turn: false`. + **The arbiter was already correct.** +5. The runner read that answer only as "abort this run later" + (`services/runner/src/sessions/alive.ts:217`), then carried on into the keepalive pool, found + the first turn's environment busy, and **destroyed it**: + the `evict (supersede-busy)` branch, now at + `services/runner/src/lifecycle/session-coordinator.ts:1343` and no longer reachable by a live + turn. The first turn lost its sandbox mid-answer. +6. The second turn then aborted on its own watchdog signal. Both turns were dead, and the session + read as alive under a dead turn's lock until the lease expired. + +So the fix is not a new subsystem. It is reading an answer the platform already gives, before +acting on the session. + +--- + +## What changed + +Seven commits on `feat/session-single-turn-admission`. + +### 1. The runner reads the admission answer (`7675eb0dc7`) + +| File | Change | +|---|---| +| `services/runner/src/sessions/admission.ts` | New. Holds the stable code `session_turn_in_use` and the one line the user reads. The decision is not made here; this is only how the runner reports it. | +| `services/runner/src/sessions/alive.ts:190` | `startAliveWatchdog` now returns `admitted`, the FIRST beat's answer. A later `is_current_turn: false` is a Stop or steer and still travels the `onInterrupted` to abort path. | +| `services/runner/src/server.ts:534` | A refused turn stops at the edge and returns. | +| `services/runner/src/lifecycle/session-coordinator.ts:1320` | A `busy` pool entry is refused, never evicted. A `destroyed` entry still evicts and cold-starts. | +| `services/runner/src/engines/sandbox_agent/errors.ts:69` | `session_turn_in_use` added to `RunErrorCode`. | + +The refusal in `server.ts` sits above three things it must not do, and this ordering is the +point: + +- `cancelStaleInteractions` (`server.ts:573`) cancels the session's unanswered approval gates. A + refused turn running it would cancel the **live** turn's approval card. +- The persisting emitter (`server.ts:587`) would write the refused message into the durable + transcript, so it would come back on reload as a message the user never sent. +- `run()` (`server.ts:621`) is what reaches the keepalive pool. + +The refusal streams as an `error` event carrying the code, then a failed terminal result. That is +the path every runner failure already takes to the browser, so no new transport is involved. + +The first heartbeat is the admission decision and fails closed unless the coordination plane +confirms ownership. Later heartbeat failures remain best effort for a turn that was already +admitted. The coordinator stays as a same-runner backstop and refuses a competing `busy` pool entry +without destroying the live environment. + +### 2. The browser keeps the user's text (`bdd7116520`) + +A naive refusal is worse than the bug for the person typing. The composer clears synchronously on +submit (`web/packages/agenta-ui/src/RichChatInput/assets/submit.ts:31`), so without this change +their text is simply gone. + +| File | Change | +|---|---| +| `web/packages/agenta-chat/src/model/error.ts` | The refusal constants, `isSessionBusyRefusal`, and a stable class on the parsed error. | +| `web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts:98` | Remembers the message handed to `sendQueued` and hands it back once through `takeLastSent`. | +| `web/oss/src/components/AgentChatSlice/AgentConversation.tsx:433` | Puts that text back in the composer on a refusal. | +| `web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx:204` | The bubble says "Message not sent" rather than "The agent run failed", and offers no retry. | + +The message is **not** re-queued. The queue releases on a settled `"error"` status +(`useAgentChatQueue.ts:68`, and the release effect below it), which for a refusal would re-send and be refused again in a tight +loop. The user decides when to send again. + +The refusal message text is the contract between the runner and the browser. It is produced once, +in `services/runner/src/sessions/admission.ts`, and reaches the browser verbatim: the SDK's +`sanitize_runner_error` passes a clean one-line error through unchanged +(`sdks/python/agenta/sdk/agents/utils/wire.py:60`) and the Vercel egress puts it on the stream as +`errorText` with the code beside it +(`sdks/python/agenta/sdk/agents/adapters/vercel/stream.py:954`). The two constants must stay +byte-identical. + +Mobile shares the queue hook and the error model, so it gets the refusal class. It has its own +composer and its own copy of the error effect, so it does not get the text restore. See the open +questions. + +### 3. The client learns which execution it is watching (`ce0f1e12da`, `ca600cb1e6`) + +Added on request from the Stop guard lane, which found that no first-party client can send +`expected_execution_id` on the public Cancel: the runner mints the turn id per execution +(`services/runner/src/server.ts:189`) and never tells anyone, so a Stop can only mean "whatever +is running now", never "the turn I was watching". + +**The `start` frame cannot carry it.** It is built and sent by the SDK's Vercel egress before the +runner replies at all: the `start` yield is the first statement of the projection +(`sdks/python/agenta/sdk/agents/adapters/vercel/stream.py:459-464`), and the runner is not +consulted until the loop below it. Putting the id there would mean moving the mint out of the +runner and threading a new correlation id through the normalizer, the response models and the +routing layer for **every** workflow, not just agent ones. That is a much larger change than the +problem needs. + +The earliest frame that can carry it is the one right after: + +| File | Change | +|---|---| +| `services/runner/src/protocol.ts:479` | New `{type: "turn", turnId}` agent event. | +| `services/runner/src/server.ts:579` | Emitted as the first event of a session-owned run, immediately after admission, through `liveEmit` and never the persisting emitter. It is transport correlation, not conversation, and must not become a session record. | +| `sdks/python/agenta/sdk/agents/adapters/vercel/stream.py:361` and `:663` | Forwarded onto the MESSAGE METADATA, in both the live and dev-twin projections. | + +The client reads it as `message.metadata.turnId`, beside the `sessionId` the `start` frame already +sets and the `traceId` and `usage` the `finish` frame adds, rather than scanning parts for it. +A `message-metadata` chunk is a first-class chunk in the pinned `ai@6.0.0-beta.150`. + +That is safe **because the AI SDK merges metadata rather than replacing it** (`mergeObjects`), so +the `finish` frame's own metadata lands beside the turn id rather than over it. A test pins that +the two carry disjoint keys and the turn id is written first. If the SDK ever changed to replace, +a client would lose the id exactly when a late Stop needs it. + +A missing, empty or non-string id emits no frame, so a client is never handed a guard value that +names nothing. A refused turn emits none either: it runs nothing, so there is nothing to stop. + +Verified live on the stack below. The frame arrives third, after `start` and `start-step` and +before any content: + +``` +["start", "start-step", "message-metadata", "text-start"] +``` + +and its id is the one holding the session's alive lock, cross-checked against the runner log: + +``` +message-metadata turnId=6a49ff3f-2165-4e4b-bbe8-c9f7192fabb3 +[sessions] stream sessionOwned=true sessionId=2fa74edd-… turnId=6a49ff3f-2165-4e4b-bbe8-c9f7192fabb3 +``` + +The first version of this (`ce0f1e12da`) used a `data-agent-turn` part instead. `ca600cb1e6` +replaced it rather than adding to it: one fact should travel one channel, and nothing consumed the +part yet. The Stop guard lane adds the browser half on its own branch. + +### 4. API tests only (`8b1a45e5a6`) + +No API code changed. Three cases now pin the answers the runner depends on, in +`api/oss/tests/pytest/unit/sessions/test_heartbeat_is_current_turn.py`. + +--- + +## Approvals still resume + +This is the case a naive "is anything alive on this session?" gate breaks, and it was checked +before the design was chosen. + +A turn parked awaiting approval still holds `alive`, which is what makes the session +reattachable, but its turn-end beat released `running` +(`api/oss/src/core/sessions/streams/service.py:590`). The approval resume arrives as a new turn. +The heartbeat sees stale `alive` with **no** `running` owner, treats it as a legitimate handover, +tombstones the parked turn and admits the resume +(`api/oss/src/core/sessions/streams/service.py:536-561`). + +So `running` is the discriminator, not `alive`. Both cases are now tested, at the API and end to +end at the runner. + +--- + +## Tests + +| Suite | Command | Result | +|---|---|---| +| Runner unit | `cd services/runner && pnpm test` | 2639 passed, 4 failed | +| Chat package | `cd web/packages/agenta-chat && pnpm test` | 626 passed | +| SDK agents unit | `pytest oss/tests/pytest/unit/agents/` | 1198 passed, 4 skipped | +| API sessions unit | `pytest unit/sessions/` | 328 passed, 41 skipped | +| Web lint | `cd web && pnpm lint-fix` | 25 tasks, 0 errors | +| Web typecheck | `tsc --noEmit` on `@agenta/oss` and `@agenta/chat` | clean | + +The four runner failures are **pre-existing**, all in +`tests/unit/gateway-run-turn-composition.test.ts`. Confirmed by stashing this slice's changes and +re-running: the same four fail on the branch tip. + +The 11 collection errors in the API run came from a virtual environment that resolved `agenta` +from a different checkout. They are import errors in unrelated files. + +New tests: + +- `services/runner/tests/unit/session-admission.test.ts` (7 tests). A real runner HTTP server + driven over a socket against a fake platform API. Covers: a refused turn never calls `run()`, + the error event carries the code, no interaction sweep or attachment claim happens, the end + beat names the refused turn, an admitted turn proceeds, a resume-shaped request is admitted, + and an unreachable platform fails closed before `run()`. +- `services/runner/tests/unit/session-alive-interrupt.test.ts` (+4). `admitted` semantics: first + beat only, fail-closed without confirmation, and a later interruption does not un-admit. +- `services/runner/tests/unit/session-keepalive-dispatch.test.ts` (+1, 1 rewritten). A busy entry + refuses with no eviction and no cold acquire; a destroyed entry still evicts. +- `services/runner/tests/unit/session-steer-mount-loss.test.ts` (3 rewritten). These pinned the + old supersede outcome. They now pin the refusal, and one new case asserts the live turn's + environment is never torn down. +- `web/packages/agenta-chat/tests/unit/model/error.test.ts` (+4). +- `web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts` (+4). +- `api/oss/tests/pytest/unit/sessions/test_heartbeat_is_current_turn.py` (+3). +- `services/runner/tests/unit/session-admission.test.ts` (+3, the turn-id frame): it arrives + first, it is the id the alive lock was acquired under, and a refused turn emits none. +- `sdks/python/oss/tests/pytest/unit/agents/adapters/test_vercel_stream_conformance.py` (+4): the + egress forwards the id verbatim exactly once in both projections, before any content; the + `finish` frame's metadata does not displace it; and a frame with no usable id emits nothing. + +One rewritten test was found to be passing for the wrong reason. The `destroyed`-entry case in +`session-keepalive-dispatch.test.ts` called `pool.destroyAll`, which clears the map, so the +assertion ran against a `miss` rather than a `destroyed` entry. The runner typecheck caught the +argument-type error that exposed it. It now marks the entry directly, because every public route +that destroys a session also removes it. + +--- + +## Live verification + +### The stack + +A standalone EE development stack built from this worktree at `:`. The deployment +used a current EE development environment file with isolated ports and project name. Dev-mode bind +mounts confirmed that the containers ran this worktree's source. + +When host and container users differ, dependency ownership can prevent the web entrypoint from +updating generated binaries. Repair only the affected dependency or generated paths with targeted +ownership or ACL changes, then restart the container. Never make the whole web tree world-writable. + +Sandbox provider: `local`. Harness: `pi_core`. The model credential came from the stack's test +vault; no key or secret is part of this record. + +### The scenario + +The verification driver worked at wire level, asserted on SSE frame types, and never used model +prose as evidence. Its environment-specific path and credentials are intentionally not recorded. + +1. Turn A starts on a fresh session and runs `sleep 40 && echo DONE_A` as a shell tool. +2. Fifteen seconds in, turn B sends "What is 2 + 2?" to the same session. +3. After A settles, turn C sends a third message. + +The agent config sets `runner.permissions.default` to `allow`, so the long tool runs instead of +parking on an approval card. The first attempt without it proved nothing: the tool parked, turn A +ended after eleven seconds, and the two turns never overlapped. + +### Results + +| Turn | HTTP | Duration | Outcome | +|---|---|---|---| +| A, long turn | 200 | 50.3 s | finished, `finishReason: stop`, reply "Finished.", ran its tool | +| B, second send | 200 | **0.18 s** | refused, no assistant text, no tool call | +| C, after | 200 | 2.0 s | ran, reply "READY" | + +Turn B's error frames, verbatim: + +```json +{"code": "session_turn_in_use", "errorText": "This session is already running a turn. Your message was not sent. Wait for the reply, or stop the turn, then send again."} +{"type": "error", "errorText": "This session is already running a turn. Your message was not sent. Wait for the reply, or stop the turn, then send again."} +``` + +Runner log for the test session, in order: + +``` +[sessions] stream sessionOwned=true sessionId=081a1fe7-… turnId=444d272b-… cred=present +[sessions/alive] heartbeat OK session=081a1fe7-… turn=444d272b-… running=true +[keepalive] miss key=01a063ea-…:081a1fe7-…; cold +[keepalive] reserve key=01a063ea-…:081a1fe7-… poolSize=2 +[sessions] stream sessionOwned=true sessionId=081a1fe7-… turnId=0e4a90c0-… cred=present +[sessions/alive] heartbeat OK session=081a1fe7-… turn=0e4a90c0-… running=true INTERRUPTED +[sessions] admission REFUSED session=081a1fe7-… turn=0e4a90c0-…; another turn owns this session. No pool resolve, no eviction. +[sessions/alive] heartbeat OK session=081a1fe7-… turn=0e4a90c0-… running=false +[sessions/alive] heartbeat OK session=081a1fe7-… turn=444d272b-… running=true +[sandbox-agent] complete OK session=081a1fe7-… turn=0 +[keepalive] park key=01a063ea-…:081a1fe7-… ttl=60000ms state=idle (re-park) poolSize=1 +[sessions] stream sessionOwned=true sessionId=081a1fe7-… turnId=42fa2fa0-… cred=present +[keepalive] hit-continue key=01a063ea-…:081a1fe7-… +[sandbox-agent] complete OK session=081a1fe7-… turn=1 +``` + +Three things to read from that log: + +- There is **no** `evict (supersede-…)` line. The refused turn touched the pool not at all. +- Turn A ran to `complete OK` and then `park … state=idle`, so it kept its sandbox. +- Turn C got `hit-continue`, which means it continued the **warm** session A parked. The warm + sandbox and the native harness session survived the second send. That is the constraint this + slice was bound by, checked rather than assumed. + +Use the matching edition, image mode, and environment file to tear down the isolated stack: + +```bash +bash ./hosting/docker-compose/run.sh --ee --dev --down +``` + +Add `--nuke` only when the isolated volumes should also be removed. + +### Not verified + +The browser behaviour was **not** verified in a browser. The composer restore, the "Message not +sent" bubble and the mobile path are covered by unit tests and a typecheck only. The web app on +this stack is serving (`/w` answers 200), so a UI pass is available and is worth doing before +this ships. Reproducing the refusal by hand needs two browser tabs on one session, or one tab +plus a curl invoke while a turn runs. + +--- + +## What is left for queue and steer + +Refusing needs no storage. Queue and steer both do, and that is the whole reason they are not in +this slice. + +- **Queue** needs a durable pending-input store, because a saved message has to survive the turn + it is waiting on and a browser reload. The client-side queue in `useAgentChatQueue` is a + per-tab convenience; it is lost on reload and invisible to any other reader of the session. +- **Steer** needs the same store plus a decision that this slice deliberately does not make: is + steer reject-with-message, keeping the turn and the warm session, or interrupt-and-restart? The + RFC (`rfc.md:125`) says interrupt-and-restart, which reverses the ruling of 2026-07-22 without + saying so, and interrupt-and-restart is the shape that loses warm state today. +- **The 409 shape.** The API's `_start_turn` already raises `SessionTurnInUse` and the router + already maps it to 409 (`api/oss/src/apis/fastapi/sessions/router.py:192`). This slice does not + route Send through that endpoint, because the runner's own heartbeat already performs the same + atomic acquire one step earlier and the invoke path does not otherwise touch the API. If Send + ever moves onto the coordination endpoint, the refusal should become the 409 and the runner's + edge check becomes a second line of defence. +- **The watchdog** is untouched by this slice and remains the highest-value next change. It + bounds every hang rather than only the double send. + +--- + +## Open questions for Mahmoud + +1. **Should the refused message be queued instead of handed back to the composer?** + *Recommendation: keep handing it back for now.* Queueing reads better, but the queue + auto-releases on a settled error status, so a refusal would re-send and be refused in a loop + until the running turn ends. Fixing that needs a refusal-aware release gate, which is queue + work, not admission work. + +2. **Should mobile also restore the text?** Today it gets the refusal class but not the restore, + because its composer and its error effect are separate files from desktop's. + *Recommendation: yes, in a follow-up.* The precedent already exists at + `web/mobile/src/features/chat/Composer.tsx:98`, which puts text back and shows a composer-level + rejection strip. It is a few lines, but it is a second host to QA and this slice is already + wide. + +3. **Is the composer-level rejection strip a better home for this than a red transcript bubble?** + Mobile already has one. A refusal is a fact about the message the user just typed, not about + the conversation. *Recommendation: move it there once someone looks at it in a browser.* The + current bubble is honest but it sits in the transcript, which is where run failures live. + +4. **Should initial admission fail closed when the API is unreachable?** *Decision: yes.* At-most-one + execution has to hold across replicas. Later watchdog failures remain best effort so an already + admitted healthy turn is not aborted by a transient API failure. + +5. **Should `--build` have been skipped?** The brief said to skip it if the images were under + three hours old, and they were fifteen minutes old. The live results therefore depend on dev + mode bind-mounting this worktree's source, which the runner log confirms it did (the + `admission REFUSED` line only exists in this branch). *Recommendation: no action.* Flagged only + so the evidence is auditable. diff --git a/docs/design/session-control-and-live-events/slice-durable-cancel.md b/docs/design/session-control-and-live-events/slice-durable-cancel.md new file mode 100644 index 00000000000..cd9478acb79 --- /dev/null +++ b/docs/design/session-control-and-live-events/slice-durable-cancel.md @@ -0,0 +1,255 @@ +# Slice: the durable Stop command, with the direct-call adapter + +> AGENT-GENERATED, low weight. Built and verified live. Mahmoud makes final decisions. + +Branch `feat/session-durable-cancel`, rebased onto `spike/session-cancel-warm` at `f5b1ae6244`. +It implements [the durable command design](spike-b-durable-commands-design.md) at `86281fa313` +and [the route contracts](api-design.md), with the direct-call adapter of that design's +section 9. The long-poll adapter is not built. + +Every claim below is marked **verified** (observed on the running stack, or read in this +branch's code with a `path:line`) or **reported** (taken from a document). + +--- + +## What a Stop does now + +**Verified live.** A user Stop reaches the running turn in 82 milliseconds, ends it, and leaves +the sandbox and the native harness session warm. Before this branch it reached the runner on the +next heartbeat, up to 30 seconds later. + +| Step | Observed at | After the Stop request | +|---|---|---| +| The browser's request arrives, the command row commits, the API calls the runner | 00:12:45.624 | 0 | +| The runner aborts the execution | 00:12:45.706 | 82 ms | +| The harness confirms it stopped | 00:12:45.730 | 106 ms | +| The runner reports, and the API settles the command and the execution | 00:12:45.750 | 126 ms | +| The sandbox is parked warm, not deleted | 00:12:46.617 | 993 ms | + +The 5 second budget in the design is met with two orders of magnitude to spare. The next message +on that session recalled a codeword from the stopped turn, which is warm resume measured from +the product rather than from a timer. + +--- + +## What changed, with references + +### The record + +`session_commands` holds one row per durable request to change an execution +(`api/oss/src/dbs/postgres/sessions/commands/dbes.py:14`, migration +`api/oss/databases/postgres/migrations/core_oss/versions/oss000000022_add_session_commands.py`). +Two columns are never merged: `state` says where the COMMAND is (`pending`, `claimed`, +`applied`, `obsolete`) and `outcome` says what happened to the EXECUTION (`stopped`, +`not_running`, `superseded_by_newer_turn`, `failed`, `lost`). + +`session_streams` gains `stopping_turn_id` and `turn_started_at` +(`api/oss/src/dbs/postgres/sessions/streams/dbes.py:73` and `:82`). The start time is stamped +only when the turn id actually changes +(`api/oss/src/dbs/postgres/sessions/streams/mappings.py`, the edit mapper), so the heartbeat that +restamps the same id every 30 seconds never moves it. + +Every transition is one `UPDATE ... WHERE RETURNING *` decided by +`scalar_one_or_none()` (`api/oss/src/dbs/postgres/sessions/commands/dao.py`). Two API replicas +cannot both win a claim or both write a terminal outcome. + +### Admission + +`SessionCommandsService.request_cancel` +(`api/oss/src/core/sessions/commands/service.py:111`) stamps the arrival time before it reads +anything, resolves the target once from Redis `running` falling back to `alive` +(`service.py:217`), applies the three late-Stop guards, then writes the command and the session's +`stopping_turn_id` in one transaction. **Redis is not written at admission**, so the stopping +execution keeps both locks while it stops, which is what prevents a second message from starting +underneath it. + +### Settlement + +`SessionCommandsService.settle` (`service.py:419`) settles the command and the execution +together, guarded on the command's state so a repeat changes nothing. For a `stopped` outcome it +tombstones the turn, then releases `running` under an owner check, then cancels that execution's +pending interactions, then publishes the existing `lifecycle: ended` notification. **It leaves +`alive` to its own time to live**, exactly as the end of an ordinary turn does. That single +decision is what makes Stop a stop rather than a session teardown. + +### Delivery + +`ControlDeliveryPort` (`api/oss/src/core/sessions/commands/interfaces.py`) is the port. The one +adapter is `DirectControlDelivery` +(`api/oss/src/dbs/http/sessions/control_delivery_direct.py`), which posts to the runner's own +`/cancel` beside the existing `kill_runner_sandbox` +(`api/oss/src/core/sessions/streams/runner_client.py`). The command row is committed BEFORE the +runner is called, and a delivery failure never fails the request. + +### The runner + +`POST /cancel` sits beside `POST /kill` behind the same token gate +(`services/runner/src/server.ts:821`). It resolves a live execution through a module-level +registry (`services/runner/src/sessions/execution-registry.ts`), falls back to the keep-alive +pool for a parked approval, and answers 404 when it holds neither. + +**The abort carries the user-stop label.** `shouldPark` parks only an abort the runner can prove +was a cooperative Stop (`services/runner/src/sessions/stop-signal.ts`, from Spike A), so the +registry aborts with `USER_STOP_ABORT_REASON`. Without it a Stop delivered as a command ends the +turn `cancelled` and then DESTROYS the sandbox, which is the failure Stop exists to avoid. Two +tests pin both directions, and the live run after the rebase logs `park-cancelled`. The applier +sits above the transport (`services/runner/src/sessions/control-channel.ts`) with the +deduplication set beside the session pool (`services/runner/src/sessions/applied-commands.ts`), +so a long-poll loop would reuse every guard unchanged. + +### The routes + +`POST /sessions/{session_id}/cancel` and +`POST /sessions/control/commands/{command_id}/outcome`, both on `SessionControlRouter` +(`api/oss/src/apis/fastapi/sessions/router.py:1909`). The public route checks +`Permission.RUN_SESSIONS` and is deliberately **not** behind `check_runner_concurrency_limit`: +refusing to STOP work because a project is at its run limit is the wrong answer to a busy +project. The internal route authenticates with the shared runner token +(`router.py:2027`) and resolves the project from the command id, so the auth exemption +(`api/oss/src/middlewares/auth.py`, the `/sessions/control/` prefix) widens no tenant boundary. + +`POST /sessions/streams/` is untouched. Its cancel branch becomes a thin wrapper over this +command in a later change, together with the mobile client; do both in one change so one revert +restores one behaviour. + +### The desktop + +The Stop button posts the new route +(`web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts`, `stopCurrentExecution`), +awaits it, and refreshes the session state on the answer. It names the execution it means, read +FRESH from the session row rather than from the project-wide liveness poll, which is up to +15 seconds stale; a stale id is refused with a conflict and the Stop would silently do nothing. +The client is `cancelSessionExecution` +(`web/packages/agenta-entities/src/session/api/api.ts`), written against raw axios because the +Fern client does not know the route yet. Mobile is untouched. + +--- + +## Four defects the live run and the rebase found + +None were visible in unit tests. The first three were found by pressing Stop against a real +agent turn; the fourth by rebasing onto Spike A's final tip. Each is committed with its own fix. + +1. **The execution registry never held the session, so every Stop got a 404 and the turn ran to + completion.** The entry was keyed by `:`, but the project scope is not + known when a run starts: `runContext.project.id` is empty on the live invoke path and the + scope that forms the pool key comes from the signed mount, which the coordinator resolves + after the run is in flight (`services/runner/src/lifecycle/session-coordinator.ts:281`, + verified). The registry is now keyed by session id and the coordinator fills the project in + through `onScopeResolved`. A lookup with a disagreeing project is refused; an entry whose + project is not known yet matches, because refusing every Stop in the first moments of a run is + the bug being replaced. +2. **The outcome report was refused with a 409, leaving the command `claimed` and the session + marked stopping forever.** The API claimed on the runner's behalf under a placeholder while + the runner reported under its own replica id, and the settle guard compares the two. The + runner's acknowledgement now carries its replica id and the API claims under that. +3. **The multi-replica census refused delivery for five minutes after every runner restart.** A + runner mints a fresh replica id at boot when `AGENTA_RUNNER_REPLICA_ID` is unset + (`services/runner/src/sessions/alive.ts:31`, verified), so its previous id is still inside the + window and the count reads two, which broke Stop after every ordinary deploy. **The census is + now removed entirely**, on the revised design's guidance that it is optional and the exact + detector is the one to build. That deletes a Redis write on every heartbeat, two settings and + a module. What remains is the detector that cannot be fooled: a `not_held` for a session whose + row says alive with a heartbeat younger than one interval means some process is running that + session and it is not the one we called. It logs at error level naming the owner replica from + the Redis `owner` key, and settles the command `lost` rather than `not_running`, so the user + is told the Stop failed instead of that the work had already finished + (`api/oss/src/core/sessions/commands/service.py`, `_settle_not_held`). + +4. **The control-plane abort carried no label, so after the rebase every Stop would have + destroyed the sandbox.** Spike A's `96012e8d8e` made `shouldPark` require proof that an abort + was a cooperative Stop, because inferring it from the stop reason alone would let any future + `controller.abort()` park a sandbox nobody had checked. The registry handed the applier a bare + `controller.abort()`. It now aborts with `USER_STOP_ABORT_REASON`, and two tests pin both + directions of the contract. + +A fifth, smaller one: two Stops **in the same instant** both inserted, because admission reads +for an open command and then inserts and neither request can see a row the other has not +committed. Sequential Stops always collapsed. A unique partial index over the open states now +makes the database decide, and the losing insert reads the winner back. + +--- + +## Live verification + +Stack: `http://144.76.237.122:9180`, project `agenta-ee-dev-session-cancel`, EE, dev images, +built from this worktree. The agent ran the `pi_core` harness on the local sandbox with an +OpenAI model. + +| Scenario | Result | Evidence | +|---|---|---| +| 1. Stop during a 60 s tool call | **Pass**, re-verified after the rebase. Turn ends at 26.2 s instead of 77.6 s. Command `pending` to `applied`, outcome `stopped`, settled 116 ms after the request. Runner logs `aborted`, then `harness_cancel sent=true settled=true elapsed_ms=17`, then `park-cancelled`. Next message recalled the codeword. | command `01a0641f-b775-75c1-bfe1-32a80e85f85e` | +| 2. Stop when nothing runs | **Pass.** 200, one row inserted already settled: `obsolete` with outcome `not_running`, no target, no Redis write. | command `01a0641f-5535-7130-a6be-537d287b6d9b` | +| 3. Stop with a stale `expected_execution_id` | **Pass.** 409 naming the current execution, and no row inserted. | `detail.current_execution_id` returned the live turn | +| 4. Two Stops in a row | **Pass.** Two simultaneous requests return the same command id and one row exists. Sequentially, the second now correctly reports nothing running, because a Stop settles in about 100 ms. | command `01a06423-c067-7c80-9b68-636953655698` returned to both | +| 5. Stop a turn parked for approval | **Pass.** The interaction goes `pending` to `cancelled`, the command settles `applied` with `not_running` in 68 ms, the pool keeps the entry, and the next message recalled the codeword. | command `01a06424-102b-76d0-a7cf-9e7d25c88041` | +| 6. Runner gone while a command is open | **Not settled, as expected.** No sweep exists in this slice. | see below | + +**Redis after a Stop, verified by direct inspection:** `running` gone, `alive` still present and +by then held by the resuming turn, and `superseded::session::turn:` +written. That is the same shape an ordinary turn end leaves, which is the point. + +**Scenario 6 in detail.** A command that is claimed and never reported stays `claimed`, and the +session's `stopping_turn_id` stays set, indefinitely. Observed directly: command +`01a0641a-d3c0-7980-8675-5349d0e3a118` sat `claimed` for over ten minutes with nothing to settle +it, and two session rows were left marked stopping. **This slice does not build the settlement +sweep.** The DAO exposes `expire_claims(now, max_deliveries)` and `settle_command` for it. The +handoff is to the branch `feat/session-execution-watchdog`, and the rule both sides must obey is +that one execution reaches exactly one terminal outcome from exactly one writer. It has to be +agreed before either lands; a second sweep racing the first is a worse bug than the one being +fixed. + +### Tests + +| Suite | Result | +|---|---| +| `api/oss/tests/pytest/unit/sessions/test_session_cancel_admission.py` | 15 pass. Admission guards, the arrival-time stamp, the collapse, the settlement, and the assertion that pins warm resume: `alive` survives a Stop. | +| `api/oss/tests/pytest/unit/sessions/test_session_commands_dao.py` | 17 pass against a real Postgres. Two concurrent claims yield one winner, two concurrent admissions yield one command, the settle guard refuses a foreign replica, a terminal command cannot be settled twice. | +| `api/oss/tests/pytest/unit/sessions` (whole directory) | 553 pass. Four failures in `test_records_turn_span_dao.py` are a DNS failure reaching the tracing database from the host, unrelated to this branch. | +| `cd services/runner && pnpm test` | 2663 pass, 4 fail. All four are `gateway-run-turn-composition.test.ts`, verified failing on the base commit before any change here. | +| `cd web && pnpm lint-fix` | 25 tasks, no errors. | +| `ruff format` and `ruff check` in `api/` | Clean, run with the CI-pinned 0.15.12. | + +--- + +## What is left + +- **The settlement sweep.** Named above. It is the difference between "a Stop the runner missed + settles in two minutes" and "it never settles". +- **The long-poll adapter.** Not built. `AGENTA_SESSIONS_CONTROL_ADAPTER` defaults to `direct` + and any other value refuses to boot (`api/entrypoints/routers.py`) rather than falling back + silently to a transport the operator did not choose. Building it changes one file plus one + runner module, and no route, data shape, or transition. +- **The wrapper.** `POST /sessions/streams/` still does what it always did. Its cancel branch + becomes a call to `request_cancel` in the same change that flips mobile, so released clients + get the new behaviour with no client change. +- **The Fern client.** The desktop calls the new route through raw axios. Move it when the API + client is next regenerated. +- **Mobile.** Untouched, as the brief asked. + +--- + +## Open questions for Mahmoud + +1. **Is the exact `not_held` detector enough on its own, with no replica census?** Settled in + the revised design and built that way. Recommendation: **yes**. Reason: the census could not + tell two live replicas from one that had restarted and broke Stop after every deploy, while + the `not_held` condition is produced by nothing but the wrong-replica failure. Listed here + only so the removal is on the record. +2. **Who owns settling an abandoned command?** Recommendation: **the execution watchdog**, using + the DAO methods this slice exposes. Reason: one execution must reach exactly one terminal + outcome from one writer, and two sweeps racing to write `lost` is worse than the bug. Until + it exists, a Stop the runner never reports leaves the session reading "stopping" forever. +3. **Does the desktop read the execution id with an extra request?** Recommendation: **yes, as + built.** Reason: the cached liveness poll is up to 15 seconds stale and a stale id is refused + with a conflict, which would make Stop silently do nothing. The extra read costs about 30 + milliseconds inside a budget of five seconds. The alternative is to send no expectation, which + switches off the cheapest late-Stop guard. +4. **Should a Stop settle before the sandbox has finished parking?** Recommendation: **yes, as + built.** The runner reports as soon as it has issued the abort, about 70 milliseconds in, + while the park completes around a second later. Reason: the command's job is to deliver the + Stop, and waiting for the teardown would make a Stop that worked look stuck. The cost is that + `outcome = stopped` means "the cancel was delivered", not "the sandbox is parked". +5. **Do we keep `session_commands` rows forever?** Recommendation: **delete settled rows seven + days after `settled_at`**, as the design says. Not built here, because it belongs with the + sweep. Commands are operational state; durable history stays in `session_records`. diff --git a/docs/design/session-control-and-live-events/slice-records-ack.md b/docs/design/session-control-and-live-events/slice-records-ack.md new file mode 100644 index 00000000000..96a3e9220f7 --- /dev/null +++ b/docs/design/session-control-and-live-events/slice-records-ack.md @@ -0,0 +1,188 @@ +# Slice: the records worker acknowledges only what Postgres has + +> AGENT-GENERATED, low weight. Draft for discussion. Mahmoud makes final decisions. + +This slice closes the durability half of GitHub issue +[#5496](https://github.com/Agenta-AI/agenta/issues/5496) and all of +[#5594](https://github.com/Agenta-AI/agenta/issues/5594). It changes the records stream worker +and the shared stream consumer it runs on. It does not touch the runner, the records DAO, or +the records table. + +Every claim below marked **verified** was read in code at the cited `path:line`, proven by a +test in this branch, or observed in the live run in "What I verified". Nothing here is +reported from another document without saying so. + +## The answer + +Three defects deleted records and reported success. All three are fixed. + +| Defect | What happened | Where it is fixed | +| --- | --- | --- | +| The worker acknowledged records before the write | Every failed Postgres write deleted its records from Redis | `records_worker.py:277`, `:341`, `:349`, `:383` | +| One rejected record discarded its whole batch | A batch of fifty lost forty-nine good records to one bad one | `records_worker.py:192-232` | +| A record left unacknowledged was never redelivered | `read_batch` only asks for new entries, so "leave it pending" meant "lose it silently" | `consumer.py:181-268` | + +A fourth defect is fixed in its own commit because it is one line and unrelated to the worker. +Enterprise record retention referenced `RecordDBE.id`, an attribute the model does not have, so +the retention statement raised before it deleted anything. Records were never aged out. Fixed at +`api/ee/src/dbs/postgres/sessions/records/dao.py:107` and `:121`. Verified: `hasattr(RecordDBE, +"id")` is `False`, the key is `(project_id, record_id)` +(`api/oss/src/dbs/postgres/sessions/records/dbes.py:18`), and the corrected statement compiles +against the Postgres dialect. + +## What happened before this change + +The worker added every decoded Redis message id to its acknowledged list during +deserialization, before it tried the Postgres write. A failed `append_many` logged an error and +continued. The ids were still returned, and the shared consumer loop acknowledged and deleted +them from the stream. Verified in the previous revision of `records_worker.py` at lines 177, +184, 236-246 and 278, and in `shared/consumer.py:143-155`. + +Two consequences followed. + +1. Any Postgres failure deleted the records of the turn that was running. The user saw a + complete conversation on screen, and the durable transcript kept a hole. The runner rebuilt + later turns from that incomplete transcript. +2. `append_many` writes one statement in one transaction, so one record Postgres rejected took + its whole batch with it. Up to fifty unrelated records were lost per rejection. This is + #5594. + +A third problem was hidden underneath. The obvious fix, "do not acknowledge a failed batch", does +not work on its own. `read_batch` reads only `>`, which means new entries +(`consumer.py:118`, `:143`). An entry that is never acknowledged is invisible to every later +read of that consumer group. Without a reclaim pass, not acknowledging turns silent loss into a +pending list that grows forever and still never writes. Verified by test: +`test_unacknowledged_entry_comes_back_through_the_reclaim_pass` asserts that a second +`read_batch` returns nothing. + +## What the worker does now + +An id enters the acknowledged list for exactly three reasons. + +1. Its rows committed. +2. It could not be decoded, so a redelivery cannot help. Counted as a loss. +3. Its organization is over its records quota, which is a deliberate product drop. Counted as a + loss. + +Everything else stays pending and comes back. + +**The write path.** `_append_committed` (`records_worker.py:192`) calls `append_many` for the +whole project group. If that commits, every id in the group is acknowledged. If it fails and the +group holds more than one record, the worker writes the group one record at a time and +acknowledges only the records that committed. A rejected record stays pending on its own. + +**The reclaim pass.** `reclaim_batch` (`consumer.py:181`) runs at the top of the worker loop +(`consumer.py:333`). It asks Redis for the group's pending entries with `XPENDING`, claims them +with `XCLAIM`, and hands them back to `process_batch`. It is opt-in through `reclaim_pending`, +which is off for the tracing and events workers and always on for records +(`records_worker.py:98`). It runs at most once per idle window, so a busy stream does not add a +round trip per loop turn. + +**The retry bound.** Redis counts deliveries per entry. After `max_deliveries` deliveries the +worker drops the entry, logs at error with the session id, record id and record type, and +increments `dropped_messages` (`consumer.py:270`). The drop is data loss, and the log line is +what makes it countable. + +**The guard on the bound.** The delivery counter alone cannot tell a record Postgres will never +accept apart from a Postgres that is simply down. Both fail every delivery. Dropping on the +count alone therefore deletes every record in flight as soon as an outage outlasts +`max_deliveries` windows, which is the loss this slice exists to prevent. So the worker drops an +over-budget entry only while other records are committing (`consumer.py:167`, +`records_worker.py:181`). While nothing at all is writing, over-budget entries are kept and the +worker logs a warning instead. This is safe because a pending entry in a Redis stream does not +block later entries: `read_batch` keeps delivering new records the whole time. + +I found this hole in the live run, not in review. The first live run dropped all five records of +the second turn because the outage lasted ten reclaim windows. See "What I verified". + +## The retry policy, and why + +| Setting | Default | Environment variable | Meaning | +| --- | --- | --- | --- | +| `reclaim_idle_ms` | 30000 | `AGENTA_RECORDS_RECLAIM_IDLE_MS` | How long a failed record waits before the worker tries it again | +| `max_deliveries` | 5 | `AGENTA_RECORDS_MAX_DELIVERIES` | Deliveries after which a record is dropped, but only while other records are committing | + +Both live in `api/oss/src/utils/env.py:528` and `:532`, and are wired in the composition root at +`api/entrypoints/worker_streams.py:101-102`. + +Three choices are worth stating. + +**One record at a time, not a binary split.** A split costs about `2 log2(n)` calls when one +record is bad and about `2n` calls when Postgres is down. Writing one record at a time costs `n` +calls in both cases, and Postgres being down is the common case. The simpler rule is also the +cheaper one where it matters. + +**The reclaim lives in the shared consumer, not in the records worker.** It belongs next to +`read_batch` and `ack_and_delete`, which are the two halves it completes, and the tracing and +events workers have the same defect waiting for them. It is off by default, so this change alters +no other worker's behaviour. + +**A failed entitlements check now defers instead of dropping.** An over-quota organization is a +deliberate drop and is still acknowledged. An entitlements service that cannot be reached is a +transient failure, and its records now stay pending (`records_worker.py:334`). This is the same +defect class as the main bug, so I fixed it here rather than filing it. + +## What I verified + +**Unit tests.** `api/oss/tests/pytest/unit/sessions/test_records_worker_durability.py`, 11 tests. +The redelivery tests run against `fakeredis`, so the pending-list bookkeeping is real consumer +group behaviour rather than a mock of it. + +I also updated one assertion in +`api/oss/tests/pytest/unit/sessions/test_watch_publish.py:137-144`. That test pinned the old +acknowledge-before-write rule, and its own comment said it was not an endorsement of it. + +Full API unit suite, OSS and Enterprise: 3248 passed, 74 skipped, 0 failed. The skips need a +Postgres or an external key that this environment does not have. None of them cover the records +worker. `ruff format` and `ruff check` are clean at version 0.15.12, which is what continuous +integration pins. + +**Live run against a real Redis 8.** I did not deploy a stack. Swap on the box was fully used +(31 GB of 31 GB) and three other agent stacks were already running, so a fourth stack would have +put the others at risk. Instead I ran the real `RecordsWorker.run` loop against a throwaway +`redis:8` container on port 6399, with a write path that fails on demand. The script is at +`/tmp/claude-1000/-home-mahmoud-code-agenta-2/7c724667-82cd-41a6-ba0b-e47bc96b4f67/scratchpad/verify_records_ack.py`. +The container is stopped and removed. + +| Step | Result | +| --- | --- | +| Turn one, three records, healthy write path | 3 committed, `XLEN` 0 | +| Turn two, five records published while the write path is down for 20 seconds | 0 committed, `XLEN` 5, `XPENDING` 5, 0 acknowledged | +| Write path restored | All 8 records present, 0 duplicates, `XLEN` 0, `XPENDING` 0, 0 dropped | +| One always-rejected record among three good ones | The 3 good records committed, the rejected one stayed pending | +| Traffic resumes | The rejected record dropped at its budget, logged at error naming `sess-2::message`, `XLEN` 0, `XPENDING` 0 | + +This covers the substance of the scenario in the brief. It does not cover the real +`RecordsDAO.append_many` against a real Postgres, or the runner and the browser. Those are not +verified. + +## What I did not do + +- I did not deploy a docker compose stack, for the memory reason above. +- I did not change the runner's bounded retry, the records DAO upsert rule, or the records table. +- I did not add a metric or an alert for `dropped_messages`. It is a counter on the worker object + and a log line, nothing more. +- The tracing and events workers still acknowledge before their write. The mechanism to fix them + now exists, and turning it on for them is one constructor argument each. I left it off. + +## Open questions for Mahmoud + +1. **Is 5 deliveries over 30 second windows the right bound?** Recommendation: keep it. With the + health guard, the bound only applies while other records are committing, so it now measures + "this record is bad" rather than "the database is slow". Both values are environment + variables if a deployment disagrees. +2. **Should a dropped record raise an alert, not just a log line?** Recommendation: add one when + the observability plane is next touched, not now. The counter and the error log make the loss + countable, and Agenta runs one records worker, so the volume is small. +3. **Should the tracing and events workers get the same treatment?** Recommendation: yes, but as + a separate change. They have the same acknowledge-before-write defect, and the machinery is + already shared and off by default. Traces and events are less costly to lose than a + conversation, so they do not need to ride with this one. +4. **Enterprise record retention starts deleting records the day this ships.** It has never run + successfully, so old records have accumulated since the feature landed. Recommendation: + check the row count and the configured cutoff on the first deployment before the job runs, so + the first sweep is not a surprise. +5. **The reclaim pass makes a lost record land late rather than never.** A record can now be + written a minute or more after its turn ended. Recommendation: accept it. The runner rebuilds + history at the start of the next turn, not at the end of the previous one, so a late write is + still in time for the reader that matters. diff --git a/docs/design/session-control-and-live-events/slice-stop-guard.md b/docs/design/session-control-and-live-events/slice-stop-guard.md new file mode 100644 index 00000000000..00d4daf3638 --- /dev/null +++ b/docs/design/session-control-and-live-events/slice-stop-guard.md @@ -0,0 +1,363 @@ +# Slice: the Stop guard and pending cancel + +Branch `feat/session-stop-guard`. Three changes on the existing cancel path, no new transport, no +new table, no runner change. + +## What happens today + +Stop is `POST /sessions/streams/` with no inputs and `force=false`. The service classifies that as +CANCEL and calls `_displace_turns`, which tombstones whichever turn holds `alive` or `running` at +that instant and clears both keys. + +Two things follow from that, and both are bugs. + +1. **A Stop applied after its turn ended kills the next turn.** Nothing recorded which turn the + Stop meant, so the tombstone lands on whatever is there. The tombstone lives for 3600 s and its + TTL is refreshed on every read (`api/oss/src/dbs/redis/sessions/locks.py:147-153`), so the + session stays wedged. This is review finding H-3 and a plausible mechanism for #6417. +2. **A stopped session keeps a live approval card.** Kill cancels pending interactions + (`api/oss/src/apis/fastapi/sessions/router.py:441-444`); cancel did not. The card's buttons then + answer a turn that no longer exists (#6315). `requirements.md:149` asks for this and no work + package owned it (review open question 5). + +## What changed + +### 1. The cancel guard + +| Change | Where | +|---|---| +| `expected_execution_id` on the cancel request | `api/oss/src/core/sessions/streams/dtos.py:150-166` | +| `SessionTurnMismatch`, the refusal | `api/oss/src/core/sessions/streams/types.py:41-73` | +| The guard itself | `api/oss/src/core/sessions/streams/service.py:174-222` | +| `_displace_turns` takes the guard and reports what it killed | `service.py:224-297` | +| The cancel branch passes both guards | `service.py:384-411` | +| 409 mapping with both ids in the body | `api/oss/src/apis/fastapi/sessions/router.py:203-212` | + +`expected_execution_id` keeps the RFC's public name. Internally it is a turn id, which is the +coordination plane's word for one execution of a session, and the service maps the two at the +boundary. It stays optional in the contract, per D-010. A whitespace-only value is read as absent, +which is the safe failure. + +With the id present, cancel touches that turn or nothing. Another turn holding the session means the +turn the caller meant is already gone, so the request returns 409 and no key is written. A named turn +that holds nothing is still tombstoned, so a beat still in flight for it cannot re-take the session. + +With no id, cancel refuses a turn whose start is later than the request's arrival. Read the next +section before relying on that. + +### 2. Turn start times + +Nothing recorded when a turn started, and it cannot be derived. `session_turns.start_time` is +written by the runner after the fact, and a browser turn's id is a runner-minted uuid4 +(`services/runner/src/server.ts:188`), so it carries no timestamp. The slice adds one API-side Redis +key, in the same shape as the existing tombstone key and with the same lifetime as `alive`. + +| Change | Where | +|---|---| +| `started::session::turn:` | `api/oss/src/dbs/redis/sessions/contract.py:81-93` | +| `record_turn_start` (write-once) and `get_turn_start` | `api/oss/src/dbs/redis/sessions/locks.py:171-221` | +| Stamped when the API mints a turn | `service.py:1079-1086` | +| Stamped on a runner-minted turn's first beat | `service.py:601-613` | + +An absent record means unknown, never old, so a turn from before this shipped stays stoppable. + +This branch adds **no migration and no column**. `feat/session-durable-cancel` owns +`session_streams.turn_started_at`; when that lands it replaces this key and the two helpers in +`locks.py` can go. The Redis key is here because the alternative offered, comparing the turn id read +at the start of the cancel handler with the one read at the end, only catches a turn that changes +inside the handler, which is microseconds wide and catches nothing real. + +### 3. Stop cancels the stopped turn's pending interactions + +The cancel response now reports every turn it tombstoned (`cancelled_turn_ids` on +`SessionStreamCommandResponse`, `dtos.py:175-178`). The route reads it and calls +`cancel_session_pending` once per turn, scoped with the existing `only_turn_id` argument +(`router.py:413-433`). That helper already publishes the `interaction: resolved` watch event, so an +open browser refetches and re-renders. A cancel that ended no turn cancels every pending gate on the +session, because nothing holds the session and nothing can ever answer them. That is kill's +reasoning. + +The runner writes a gate with `request.turnId` (`services/runner/src/engines/sandbox_agent/run-turn.ts:708-714`), +which is the same id it heartbeats with, so the scoping matches what the runner produces. Verified in +code. + +### 4. The browser renders a cancelled gate as closed + +Replay already did: `settleApprovalPart` maps a `cancelled` interaction row to `output-denied` +(`web/packages/agenta-chat/src/assets/transcriptToMessages.ts:240-243`). The live path did not. The +in-memory pending list was not gated on `stopped`, unlike the two docks beside it, so a live card with +working buttons and hot keyboard shortcuts stayed up until a reload. + +`getLivePendingApprovals` (`web/packages/agenta-chat/src/model/approvals.ts:68-82`) holds the rule for +both clients. Desktop reads it at `web/oss/src/components/AgentChatSlice/AgentConversation.tsx:378-387` +and mobile at `web/mobile/src/features/chat/LiveConversation.tsx:191-196`. `stopped` clears on the next +send, so a new turn's gates appear normally. + +### 5. The concurrency limit no longer refuses a Stop + +Added scope, raised after the first pass. `check_runner_concurrency_limit` gated every mode, so a +project at its per-project run limit could not stop the very runs holding the limit: the one request +that frees capacity was the one refused with 429. Cancel starts nothing, so it is now exempt +(`api/oss/src/apis/fastapi/sessions/router.py:407-413`). + +The route needs the mode before the service runs, so the inputs-by-force matrix moved into +`derive_command_mode` (`api/oss/src/core/sessions/streams/service.py:144-160`) and both the route and +the service call it. One derivation, so the two cannot disagree about what a cancel is. + +### 6. A refused Stop reaches the user + +Added scope. The desktop Stop was fire-and-forget and `callFern` logs and returns null for every +non-abort failure, so a Stop the server refused was invisible: the transcript said "Stopped" while +the run continued and kept billing. Now the outcome is read. + +`cancelSessionStream` (`web/packages/agenta-entities/src/session/api/api.ts:629-690`) returns one of +three answers, `cancelled`, `stale`, or `failed`, carrying the server's own 409 message. It is a +separate function rather than a flag on `commandSessionStream` because the other callers of that +function deliberately ignore the result and use a null check that widening would break. + +The desktop reads it at `web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts:505-524`: +on a refusal it withdraws the local "Stopped" marker, shows a short notice, and invalidates the +liveness query so the running-elsewhere strip tells the truth. + +### 7. The mobile composer Stop calls the server + +Added scope. Mobile had the server-calling Stop only on the running-elsewhere strip, the button that +appears when the turn is NOT this device's. The composer's own Stop called `conversation.stop`, which +aborts this device's fetch and nothing else, so stopping your own turn on mobile left the run going +and billing. + +`stopHere` (`web/mobile/src/features/chat/LiveConversation.tsx:197-213`, wired at `:482`) now aborts +locally and sends the same cancel the desktop sends, with the same refusal handling. The strip's +`StopButton` moved to the same helper (`web/mobile/src/features/chat/StopButton.tsx:15-38`) and shows +the stale message instead of "try again", which would have sent the user round the same refusal. + +### 8. The browser sends the guard + +The client half of the turn id. The runner sends it as a `message-metadata` chunk, so it lands on +`message.metadata.turnId` beside the `sessionId` the start frame sets. It arrives third, before any +content, and the SDK merges metadata, so the finish frame's `traceId` does not overwrite it. It +cannot ride on the start frame itself: the SDK egress emits `start` before the runner is consulted. +The runner half is runner commit `ca600cb1e6` on `feat/session-single-turn-admission`; until it +lands no metadata arrives, nothing is stored, and Stop sends no guard, exactly as before. + +| Change | Where | +|---|---| +| `getMessageTurnId` and `latestTurnId`, the strict readers | `web/packages/agenta-chat/src/assets/agentTurn.ts:19-37` | +| The per-session store, cleared with the session's ephemera | `web/packages/agenta-chat/src/state/sessionEphemera.ts:33-56` | +| Desktop keeps the id and sends it | `web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts:353-360`, `:524-531` | +| Mobile keeps it (shared hook) and sends it | `web/packages/agenta-chat/src/hooks/useAgentConversation.ts:343-350`, `web/mobile/src/features/chat/LiveConversation.tsx:200-207` | +| `cancelSessionStream` carries it | `web/packages/agenta-entities/src/session/api/api.ts:629-680` | +| The typed client gained the field | `web/packages/agenta-api-client/src/generated/.../SessionStreamCommandRequest.ts` | + +The store is a Map beside the composer drafts rather than an atom, because nothing renders the id: +it is written once per turn and read once, when Stop is pressed. It is deliberately kept past the end +of the turn. A turn parked on an approval has finished streaming and is still the turn a Stop means, +which is exactly the state review finding H-2 is about. + +**Why an effect on the messages and not a callback.** The pinned `ai@6.0.0-beta.150` gives the +client chat exactly four callbacks: `onError`, `onToolCall`, `onFinish` and `onData` (`ChatInit` in +that package's `dist/index.d.ts:3121-3157`). There is no metadata callback. `onData` takes a +`DataUIPart` (`:3101`), so it never sees a `message-metadata` chunk, and `onFinish` is too late for +Stop, which happens mid-turn. `messageMetadataSchema` is a validation schema, not a hook. Reading +the merged metadata off the streaming message is therefore the only channel this version exposes. +Verified in the installed package, not assumed. + +It is in memory and never persisted with the messages, which is what makes it safe. Message metadata +does round-trip through the browser's message cache, so reading `metadata.turnId` straight off the +transcript at Stop time would name a turn from a previous page load. The Map starts empty after a +reload, so Stop then sends no guard, which is the old behavior rather than a wrong refusal. + +Three rules the code holds to, each because the wrong id refuses a Stop that is correct: + +- The readers are strict. A missing id, a blank id, or a non-string yields null, and null means send + nothing. `latestTurnId` consults only the NEWEST assistant message and does not fall back to an + older one, because an older message carries an older turn's id. +- The field is omitted, never sent as null, when the client never learned an id. The server then + falls back to its own arrival-time check. +- The running-elsewhere button on mobile sends no guard at all + (`web/mobile/src/features/chat/StopButton.tsx:15-21`). That turn runs on another device, so this + device never saw its metadata, and naming a turn it watched earlier would refuse a correct Stop. + +**The typed client was regenerated**, against this stack's own OpenAPI +(`clients/scripts/generate.sh --language typescript --url /api/openapi.json`). +The diff is two fields and nothing else: `expected_execution_id` on the request and +`cancelled_turn_ids` on the response. The checked-in client was otherwise already in sync. + +One transition hazard, stated rather than guarded: if a session's first turn carried the metadata and +a later turn did not, the stored id would be stale and that Stop would be refused. It needs a runner +version change in the middle of one session, and the refusal is visible and says why. The code does +not retry without the guard, because retrying unguarded is exactly the behavior #6417 is about. + +## The honest limit of the arrival-time guard + +**The arrival-time check does not close #6417 on its own. `expected_execution_id` does.** Both +first-party clients now send it, and the id reaches them only once the runner half lands on +`feat/session-single-turn-admission`. Until then the measurement below is what Stop does. + +Measured, not argued. Fourteen runs of the real race against the live stack: turn one takes the +session, then a Stop with no id and the next Send are fired together, Stop first. Results below. + +| Measurement | Result | +|---|---| +| Stops refused by the arrival-time guard | 0 of 14 | +| Runs where turn two was tombstoned | 1 of 14 | + +The guard never fired because in every run where turn two died, the Stop genuinely reached the API +after turn two had started. The check only catches a request that arrives before the turn starts and +is processed after it. That window is the permission check plus the concurrency check, both database +round trips, which is why the stamp is taken at the route's first line +(`router.py:385-391`) rather than inside the service. It is still small next to the client's own +network latency, which is the larger half of the race and which the server cannot see. + +The mechanism itself works. Forcing one turn's recorded start five seconds into the future and then +sending a Stop with no id returns 409 and leaves the turn holding `alive` and `running`, untombstoned. +That protocol is under "Live verification" below. + +A client-supplied age would close the gap without a clock-skew problem: the browser sends how many +milliseconds ago the button was pressed, and the server subtracts that from arrival. It is not in the +RFC and it is not in this slice. It is open question 2 below. + +## Live verification + +The scenarios ran against an access-controlled EE development deployment with a local sandbox +provider. Endpoint, project, container, and host-path identifiers are omitted from the repository. +The raw transcript is retained in the restricted test record. + +### (a) A Stop naming a turn that has ended is refused, and the new turn keeps running + +Turn one took the session, a steer replaced it with turn two, then a Stop named turn one. + +``` +--- STALE STOP: expected_execution_id = T1 --- +{"detail":{"message":"Session '' is running turn '', not the expected turn ''. Nothing was cancelled.", + "expected_execution_id":"", + "actual_execution_id":""}} +HTTP=409 +--- state after the refused stop --- +alive -> +running -> +tombstone(T2) exists -> 0 +tombstone(T1) exists -> 1 +``` + +A Stop naming turn two was then accepted, returned `cancelled_turn_ids`, and cleared `alive`. + +### (b) A Stop with no id does not tombstone a turn that started after it + +Constructed, because the timing cannot be forced from outside the process. One turn was started +normally, its recorded start was moved five seconds into the future, and a Stop with no id was sent. + +``` +forced start -> +--- Stop with NO expected_execution_id --- +{"detail":{"message":"Session '' started turn '' + after this cancel arrived, so the cancel is stale. Nothing was cancelled. + Send `expected_execution_id` to cancel a specific turn.", ...}} +HTTP=409 +alive -> +running -> +tombstone(T2) -> 0 +``` + +The unconstructed version of this scenario is the 14-run race above, which the guard did not catch. + +### (c) Stop cancels a pending gate and a late answer is refused + +The gate was created through `POST /sessions/interactions/`, the endpoint and body the runner uses, +with the same `turn_id` as the running turn. + +``` +status before Stop = pending turn_id = +=== STOP === +{"mode":"cancel","session_id":"","turn_id":"", + "detached":true,"cancelled_turn_ids":[""]} +HTTP=200 +status after Stop = cancelled +=== late answer === +{"detail":"Interaction is no longer pending"} +HTTP=409 +``` + +An open browser sees the refresh signal. The watch stream for the same sequence: + +``` +event: ready +event: interaction data: {"type": "interaction", "session_id": "...", "status": "pending"} +event: lifecycle data: {"type": "lifecycle", "session_id": "...", "state": "ended"} +event: interaction data: {"type": "interaction", "session_id": "...", "status": "resolved"} +``` + +Not verified live: a gate raised by a real agent turn rather than by the same endpoint the runner +posts to. The turn id the runner uses was checked in code, not on the wire. + +### (d) A project at its concurrency limit can still Stop + +The API was recreated with `AGENTA_SESSIONS_REDIS_CONCURRENCY_LIMIT=1`, driven, then recreated with +the setting removed. The stack is back on the default. + +``` +=== a SEND takes the one slot === HTTP=200 +=== a second SEND is refused === HTTP=429 + {"detail":"Concurrency limit of 1 concurrent runs reached for this project."} +=== STOP on the running session === HTTP=200 + {"mode":"cancel", ... "cancelled_turn_ids":[""]} +=== the freed slot lets the next SEND through === HTTP=200 +``` + +Before the change the third line was a 429. + +Not verified live: the desktop and mobile notices in a browser. Both need an agent run with a model +key, which this stack has no key for. The three outcomes of `cancelSessionStream` are unit-tested, +all four touched packages typecheck, and the web container compiled the chat route clean +(`✓ Compiled /w`). + +## Tests + +| Suite | File | Result | +|---|---|---| +| The guard, the start record, steer staying unguarded | `api/oss/tests/pytest/unit/sessions/test_cancel_stop_guard.py` | 12 passed | +| The route: pending gates and the concurrency exemption | `api/oss/tests/pytest/unit/sessions/test_cancel_cancels_pending_interactions.py` | 11 passed | +| The live approval rule | `web/packages/agenta-chat/tests/unit/model/liveApprovals.test.ts` | 3 passed | +| The Stop outcomes and the guard on the wire | `web/packages/agenta-entities/tests/unit/session-cancel-stream.test.ts` | 8 passed | +| The turn-id readers and their store | `web/packages/agenta-chat/tests/unit/assets/agentTurn.test.ts` | 9 passed | + +`api/oss/tests/pytest/unit/sessions/` as a whole: 505 passed, 41 skipped. The `@agenta/entities` +suite is 1472 passed and `@agenta/chat` is 634 passed. `pnpm lint-fix` in `web/` is clean, `ruff +format` and `ruff check` in `api/` are clean, and `@agenta/entities`, `@agenta/chat`, +`@agenta/mobile` and `@agenta/oss` all typecheck. + +## What is left + +- **The guard is inert until the runner sends the turn id in the message metadata.** Both clients read it and send it; + no runner on this branch emits it. The runner half is commit `ca600cb1e6` on + `feat/session-single-turn-admission` (commit `ca600cb1e6`). The stream row's `turn_id` was rejected as a source: it + reaches the browser through a 15 s liveness poll, and a stale id refuses a legitimate Stop of the + current turn, which is worse than the bug. +- The residual in-handler race: a turn that takes `alive` between `_displace_turns` reading the owners + and clearing them is still tombstoned. Microseconds wide, and closing it needs a Lua script or the + fencing that D-017 defers. +- The desktop and mobile notices, and the guard actually travelling from a browser, were not seen in + a browser. All of it is unit-tested and typechecked, and the wire body the client now sends was + driven by hand against the live API. Seeing it end to end needs the runner half plus an agent run + with a model key, and this stack has no key. + +## Open questions for Mahmoud + +1. **Should the browser's Stop carry how long ago the button was pressed?** Recommendation: yes, one + optional integer. Reason: it is the only thing that closes #6417 before a turn id reaches the + browser, it needs no clock agreement between client and server, and the measurement above shows the + server-side arrival stamp catches nothing on its own. +2. **Should the same turn id also guard the interaction responses?** Recommendation: yes, once the + runner half lands. Reason: `rfc.md:68-75` already asks for `expected_execution_id` on a response, + the browser will now have the id in hand, and an approval answered against a turn that has ended is + the same class of bug as a stale Stop. +3. **Should a refused Stop be a 409 or a quiet success?** Recommendation: 409 with both ids, as built. + Reason: the browser can retry with the id in the body, and a silent success would tell the user the + run stopped when it did not. +4. **Should Stop keep cancelling every pending gate when it ended no turn?** Recommendation: keep it. + Reason: nothing holds the session in that state, so no gate can ever be answered, and leaving them + pending reproduces #6315 for the case where the turn had already lapsed. +5. **Should closing a chat tab keep sending a cancel?** Recommendation: no. Reason: + `AgentChatPanel.tsx:138` is now the only Stop that still discards its outcome, and it fires on tab + close, which contradicts `requirements.md:98` and surprises anyone who closes a tab to reopen the + session elsewhere. If it stays, it should say so in the requirements and use the same helper. diff --git a/docs/design/session-control-and-live-events/slice-watchdog.md b/docs/design/session-control-and-live-events/slice-watchdog.md new file mode 100644 index 00000000000..2e5be47db1a --- /dev/null +++ b/docs/design/session-control-and-live-events/slice-watchdog.md @@ -0,0 +1,465 @@ +# Slice: the execution watchdog + +Branch `feat/session-execution-watchdog`. Commits `59fb1a7864` and `5bbd5a36df`. + +This slice makes one RFC requirement true: **every accepted execution reaches exactly one +durable terminal outcome within a bounded time** (`requirements.md:36`, D-016 at +`decisions.md:129-139`). It adds no table, no transport, and no new subsystem. + +## What happens today + +A turn can run out of ways to end. + +The runner writes its terminal record downstream of `await run(...)` +(`services/runner/src/server.ts:622`), and releases its alive watchdog in the `finally` around +that same await (`services/runner/src/server.ts:618`). Both are correct on every path where +`run()` returns. Neither happens when it does not. An await inside the run that never settles +leaves the heartbeat announcing `running=true` every thirty seconds for good, and each beat +re-arms a Redis lease whose TTL is an hour. + +The user sees a session that is running, refuses a new message, and never finishes. The only +exits were the thirty-minute idle threshold and pressing Stop. + +Three ways in, all reported: + +- The sandbox dies under the turn ([#6418](https://github.com/Agenta-AI/agenta/issues/6418)). + Verified: the agent-to-client half of the ACP channel is a long-lived SSE `GET`; when the peer + dies the transport's read loop swallows the severed stream and never fails the readable + (`services/runner/node_modules/acp-http-client/dist/index.js:335-339`), so the pending + `session/prompt` request is structurally incapable of settling. +- The runner itself is gone: a container restart, a crash, an OOM kill. Nothing on the runner + can write an outcome, because there is no runner. +- A write failure is swallowed and the turn beats on + ([#6100](https://github.com/Agenta-AI/agenta/issues/6100), + [#5327](https://github.com/Agenta-AI/agenta/issues/5327), + [#6099](https://github.com/Agenta-AI/agenta/issues/6099)). + +The existing run limits do not cover these. Time-to-first-byte (2 min) catches a sandbox that +dies before the first token and idle (30 min) catches one that dies mid-stream, but +`notePaused()` retires every timer permanently the moment a turn parks for a human +(`services/runner/src/engines/sandbox_agent/run-limits.ts:207-210`) — which is exactly when a +long turn is most likely to outlive its sandbox. Verified. + +An embryonic watchdog already existed. `orphan_sweep.py` found stale rows and cleared their +Redis nest, but it wrote nothing to the transcript and told no open browser, so a swept +session's conversation simply stopped mid-turn. Verified before this change. + +## What this slice changes + +Two halves, and **they close different bugs**. Neither one alone is enough, and it is worth +being precise about which does what, because the obvious reading is wrong. + +| Failure | Detected by | Why the other half cannot | +|---|---|---| +| The sandbox dies under the turn ([#6418](https://github.com/Agenta-AI/agenta/issues/6418)) | The runner's sandbox liveness probe | The runner is healthy and keeps beating, so its heartbeat never goes stale and the API scan never sees the row | +| The runner is gone: restart, crash, OOM | The API watchdog | There is no runner left to detect anything | + +**The API watchdog does not close [#6418](https://github.com/Agenta-AI/agenta/issues/6418), and +cannot.** It keys off heartbeat age, and a wedged turn's own heartbeat stays perfectly fresh — +only the machine underneath is gone. The runner-side probe is what closes that one, and it is +proved live in Scenario B below. This was flagged from the Stop map before the work started and +it held up in the live test: at the moment of the kill the runner logged `ECONNREFUSED` on the +ACP socket and `heartbeat OK ... running=true` in the same second. + +### API: settle an execution whose runner cannot report one + +`api/oss/src/tasks/asyncio/sessions/orphan_sweep.py`, extended rather than duplicated. A second +job scanning the same rows would race this one: whichever collapsed the flags first would hide +the row from the other, and the terminal record would sometimes never be written. + +For each stale row that claims a running turn, in this order: + +1. Write the two records the dead runner owed, `_lost_turn_records` at `orphan_sweep.py:112`. +2. Collapse the row's flags so the session reads as ended. +3. Clear the Redis nest and tombstone the turn, so a late beat cannot re-nest it. +4. Publish the watch notification on the session channel and the project channel. + +Step 1 is deliberately first. A crash between the steps leaves the row a candidate for the next +pass, which is recoverable; collapsing the flags first would hide the row forever with no +ending ever written. + +**The records mirror the runner's own error path exactly**: an `error` event carrying the class +a client can act on, then the terminal `done`. A lone `done` would render as a clean finish, +which is the opposite of what happened. The message is character-for-character the runner's +`EXECUTION_LOST_MESSAGE`, so one outcome never reaches the user in two wordings. + +**Idempotent twice over.** A stable `uuid5` per (turn, record) (`orphan_sweep.py:94`) means the +ingest upsert writes the same two rows however many passes or replicas see the turn. And +`RecordsDAO.settled_turns` (`api/oss/src/dbs/postgres/sessions/records/dao.py:233`) asks, in one +query per project, which turns already carry a terminal record — because a runner can die +*after* writing its outcome but *before* its final `is_running=false` beat lands. That turn is +already settled; its row still needs collapsing, but a second, contradictory ending would +corrupt the transcript. The records table lives in the tracing database and the stream rows in +the core database, so this is a two-phase read, never a join. + +If that lookup fails, the pass writes nothing and still collapses the row. Saying nothing is +better than inventing a second ending. + +**Only a turn that still claims `is_running` is eligible**, and that is what protects a parked +approval. A turn that parks for a human sends one final beat with `is_running: false` +(`services/runner/src/sessions/alive.ts:241-252`) and then stops beating on purpose, so its +heartbeat goes stale within seconds. It is also the state we most need to keep: the sandbox is +warm and the user is about to answer. Such a row never becomes a candidate for a terminal +record however long it sits. It is still reclaimed after thirty minutes, which is the +pre-existing sweep behaviour keyed to the approval TTL, but no ending is written for it. +Pinned by `test_a_parked_approval_is_never_settled`. + +### Runner: never wait on a run forever + +`services/runner/src/sessions/turn-settle.ts` (new). `awaitTurnOrAbandon` wraps the run in +`server.ts`. It waits normally, and gives up when the platform says the turn is no longer +current, or when the hard deadline elapses. Giving up is two steps: `abort()` first, because +most hangs do unwind from an abort, and only if the run is still pending after the grace window +does the request write the outcome itself and stop waiting. + +This closes the loop with the API half. When the watchdog settles a turn it tombstones it, so +the wedged runner's next heartbeat answers `is_current_turn: false`, which already aborts the +run (`services/runner/src/sessions/alive.ts:207`). Where that abort lands somewhere the signal +is observed, the turn ends cleanly. Where it does not, the grace window ends the request anyway. + +`services/runner/src/engines/sandbox_agent/sandbox-liveness.ts` (new) covers the case the API +cannot see: a dead sandbox under a runner that is still beating happily. It probes the daemon's +own health route, a different socket from the wedged ACP channel, and trips the existing +run-limit path after three consecutive failures. Any HTTP status counts as alive, 401 and 404 +included: the question is whether something is listening, and only a transport failure answers +it. + +The turn is closed to further events once the request has written its outcome +(`services/runner/src/server.ts`, the `gatedEmit` wrapper). An abandoned run that unwinds +minutes later must not append a second ending. + +The heartbeat itself gained a request timeout and an in-flight guard +(`services/runner/src/sessions/alive.ts`). Both beats used a bare `fetch` with no signal, so a +stalled socket never settled: beats piled up behind it, and the final beat in `release()` could +hold the whole request open after the turn had ended. + +### Web + +Two small changes, both found by tracing what a browser does when the records land. + +- `execution_lost` joins `RETRYABLE_CODES` + (`web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx:154`). The retry wiring + already existed; the code was simply in no branch, so the failed turn offered no action. +- The desktop watch now listens for `lifecycle` + (`web/oss/src/components/AgentChatSlice/hooks/useSessionRecordsWatch.ts`). It previously + registered only `ready`, `records-changed` and `interaction`, so the watchdog's `ended` event + was received by the EventSource and discarded, and the session kept *looking* alive until the + next fifteen-second liveness poll. Mobile already did this. + +The error itself needed no frontend change: the replay adapter already folds +`{type: "error", message, code}` onto the interrupted turn and `done` already closes it. + +## The timeouts, and how to change them + +Every value is a setting. Nothing here needs a redesign to tune. + +| Setting | Default | Environment variable | +|---|---|---| +| Heartbeat age before a running turn is lost | 90 s | `AGENTA_SESSIONS_WATCHDOG_STALE_HEARTBEAT_SECONDS` | +| Grace before an alive-but-idle row is settled | 1800 s | `AGENTA_SESSIONS_WATCHDOG_IDLE_GRACE_SECONDS` | +| How often the watchdog runs | 60 s | `AGENTA_SESSIONS_WATCHDOG_INTERVAL_SECONDS` | +| Rows settled per pass | 500 | `AGENTA_SESSIONS_WATCHDOG_BATCH_SIZE` | +| Sandbox probe interval | 30 s | `AGENTA_RUNNER_SANDBOX_PROBE_INTERVAL_MS` | +| Sandbox probe timeout | 10 s | `AGENTA_RUNNER_SANDBOX_PROBE_TIMEOUT_MS` | +| Consecutive probe failures before the sandbox is declared gone | 3 | `AGENTA_RUNNER_SANDBOX_PROBE_FAILURES` | +| Hard per-turn deadline | 11.5 h | `AGENTA_RUNNER_TURN_HARD_DEADLINE_MS` | +| Grace after an abort before the request stops waiting | 60 s | `AGENTA_RUNNER_TURN_ABANDON_GRACE_MS` | +| Heartbeat request timeout | 15 s | `AGENTA_RUNNER_HEARTBEAT_TIMEOUT_MS` | + +Definitions live in `api/oss/src/utils/env.py:564` (`SessionWatchdogConfig`), +`services/runner/src/engines/sandbox_agent/sandbox-liveness.ts` and +`services/runner/src/sessions/turn-settle.ts`. + +Two of these deserve their reasoning stated. + +**The rule is heartbeat age, not lease expiry, and the difference is an hour.** The Redis +`alive` and `running` keys carry a 3600-second TTL (`api/oss/src/utils/env.py:1416-1422`), so a +watchdog phrased as "settle shortly after the lease expires" would leave a dead turn running for +an hour. The runner beats every 30 seconds +(`services/runner/src/sessions/contract.ts:18`) and the beat is mirrored onto +`session_streams.updated_at`, so the age of that column is the real signal. The threshold is 90 +seconds of it: three missed beats. It was a flat 300 seconds, which was defensible while the +sweep only collapsed flags and nobody saw the result, and is too long now that it writes an +ending a user reads. + +**The hard per-turn deadline sits ABOVE the longest legitimate run, not below it.** The run +limits already own when a real turn should stop, and users have asked for longer runs, not +shorter ones ([#6084](https://github.com/Agenta-AI/agenta/issues/6084), +[#5356](https://github.com/Agenta-AI/agenta/issues/5356)). A turn that reaches this deadline is +one whose own limits already tripped and failed to end it. `AGENTA_RUNNER_RUN_TOTAL_TIMEOUT_MS` +is unchanged. + +## Tests + +**API**, `api/oss/tests/pytest/unit/sessions/test_execution_watchdog.py`, 8 tests, all passing. +A lost turn gets an `error` then a `done`; a second pass writes no second ending; record ids are +stable across passes; an idle row owes no ending; a running row with no turn id is settled +silently; open readers are told the session ended; the Redis nest follows the settled row; a +failed lookup never invents an ending. + +The existing `test_orphan_sweep_thresholds.py` and `test_orphan_sweep_clears_redis.py` still +pass. Their fixtures gained the `turn_id` column, and the threshold assertion now names 120 +seconds with the reason written down. + +**Runner**, vitest, 14 tests, all passing. +`services/runner/tests/unit/sandbox-liveness.test.ts` (6): the threshold of consecutive +failures, tolerance of a single blip, a probe that *hangs* counted as a failure, and firing at +most once and never after dispose. `services/runner/tests/unit/turn-settle.test.ts` (8): the +happy path leaves no timer armed, a rejecting run still reaches the caller's own catch, an +interruption aborts first, a run that will not unwind hands back a reason, the hard deadline +works with no interruption signal at all, and an abort that throws does not break the settle. + +Full suites: `services/runner` 2642 unit tests pass. `api/oss/tests/pytest/unit/sessions` 333 +pass. 11 modules in that directory error on import with +`cannot import name 'InvalidHarnessKindError' from 'agenta.sdk.agents'`; that is pre-existing, +confirmed by running the same command on the unmodified tree, and comes from borrowing the main +checkout's virtual environment, whose SDK is installed from a different tree. + +Commands: + +``` +cd services/runner && pnpm exec vitest run --project unit +cd api && PYTHONPATH=$PWD python -m pytest oss/tests/pytest/unit/sessions/ -q +``` + +## Live verification + +Stack: `agenta-ee-dev-session-watchdog` at **http://144.76.237.122:8880**, EE, dev images, +local sandbox provider, its own Postgres on 5442. Deployed from this worktree at commit +`59fb1a7864`; the runner picked up `5bbd5a36df` by hot reload. Images were 40 minutes old at +deploy time, so `--build` was skipped as the brief allows. + +### Scenario A: the runner is gone + +A turn was opened by beating `POST /sessions/streams/heartbeat` once with +`is_running: true` — the runner's only liveness contribution — and then going silent, which is +byte-for-byte what a runner that died produces. + +Before: the Redis lease had 3586 seconds left, a second turn asking for the session got +`is_current_turn = False`, and the session had zero records. + +``` +2026-09-02T21:26:29.624Z [WARN.] watchdog: settled a session_stream whose runner went silent + extra={'session_id': 'wd-scenario-a-161c2d24', + 'stream_id': '01a06402-25b6-7072-97ea-9164efb69baf', + 'turn_id': 'e79207c5-813c-4913-98b8-a12d244afefb', 'lost': True} +2026-09-02T21:26:29.643Z [INFO.] watchdog: settled 1 sessions (1 turns marked lost) +``` + +The row was created at 21:24:17 and settled at 21:26:29, so 132 seconds. That run used the +earlier 120-second threshold; at the 90-second threshold this slice now ships, the same case +settles between 90 and 150 seconds depending on where the sweep tick falls. The behaviour under +test is unchanged — only the constant moved. + +After, all four verified by reading the stores: + +| Check | Result | +|---|---| +| Records for the turn | `error` (`code: execution_lost`) at `21:26:29.623`, then `done` at `21:26:29.624` | +| Stream row flags | `is_alive: false, is_running: false, is_attached: false` | +| Redis `alive` / `running` / `owner` | all empty | +| Redis `superseded:...:turn:` | `1` | +| A new turn on the same session | `is_current_turn = True` | + +### Scenario A1: re-run at the 90-second threshold, beside a parked approval + +Run again after the threshold changed from 120 seconds to 90, on a redeployed stack, with two +sessions opened in the same second so the two rules are tested against each other: + +- one turn beating `is_running: true` and then going silent, which is a runner that died; +- one turn sending a final beat with `is_running: false` and then going silent on purpose, + which is a turn parked for a human. + +Both were opened at 21:56:24. The constants in the running container, read from the live process: + +``` +running threshold (heartbeat age): 90 seconds +idle threshold: 1800 seconds +sweep interval: 60 seconds +``` + +``` +2026-09-02T21:58:02.911Z [WARN.] watchdog: settled a session_stream whose runner went silent + extra={'session_id': 'wd-90s-cdb259fb', ..., 'lost': True} +2026-09-02T21:58:02.926Z [INFO.] watchdog: settled 1 sessions (1 turns marked lost) +``` + +One session, not two. 98 seconds from the last beat, which is the 90-second threshold plus the +part of a sweep interval that had still to run. + +| Session | Records written | Row after | +|---|---|---| +| Runner died | `error` (`code: execution_lost`), then `done` | `is_alive: false, is_running: false` | +| Parked for a human | none | `is_alive: true, is_running: false` | + +The parked session kept its warm, resumable state and was given no ending, while sitting on a +heartbeat that had been stale for the same 98 seconds. That is the whole point of eligibility +resting on `is_running` rather than on silence alone. + +### Scenario A2: the runner wrote its outcome but lost its final beat + +The idempotency guard, on a real deployment. A turn was opened, the runner's own `done` record +was ingested, and the beating stopped. + +``` +2026-09-02T21:29:29.654Z [WARN.] watchdog: settled a session_stream whose runner went silent + extra={'session_id': 'wd-already-settled-4863aef0', ..., 'lost': False} +2026-09-02T21:29:29.663Z [INFO.] watchdog: settled 2 sessions (1 turns marked lost) +``` + +`lost: False`, and the session still holds exactly one record: the runner's own `done`. The row +was collapsed and the Redis nest cleared, with no second ending invented. The other session +settled in the same pass was a genuinely different lost turn, and it got its own single +`error` + `done` pair. + +### Scenario B: the sandbox dies under the turn + +A real agent turn on the local sandbox provider (codex harness, OpenAI through the vault), asked +to run `sleep 240`. Once the tool call was in flight, the sandbox's process group was killed +from outside the runner. + +The kill produced exactly the reported failure shape, and this is what made the first attempt +worth having: + +``` +Error: connect ECONNREFUSED 127.0.0.1:35171 + at async StreamableHttpAcpTransport.postMessage (acp-http-client/src/index.ts:406:21) +[sandbox-agent] unhandledRejection: TypeError: fetch failed +[sessions/alive] heartbeat OK session=wd-sandbox-gone-3eb8bb02 turn=0bc24bf1... running=true +``` + +The ACP socket was refusing every write while the heartbeat kept reporting the turn as running, +and the turn never ended. **The first version of the probe did not catch it**, because it called +`SandboxAgent.getSession()`, which reads a local persist driver and never touches the daemon — +so it answered happily while the sandbox was dead. That is fixed in `5bbd5a36df` and written +into the module docstring so nobody reaches for it again. + +Re-run with the corrected probe. The sandbox was killed at 21:32:08, mid tool call: + +``` +[sandbox-agent] [sandbox-liveness] probe failed (1/3): fetch failed +[sessions/alive] heartbeat OK session=wd-sandbox-gone-75399fc3 turn=c682ebb5... running=true +[sandbox-agent] [sandbox-liveness] probe failed (2/3): fetch failed +[sessions/alive] heartbeat OK session=wd-sandbox-gone-75399fc3 turn=c682ebb5... running=true +[sandbox-agent] [sandbox-liveness] probe failed (3/3): fetch failed +[sandbox-agent] [sandbox-liveness] sandbox is gone: 3 consecutive liveness probes failed (last: fetch failed) +[sessions/alive] heartbeat OK session=wd-sandbox-gone-75399fc3 turn=c682ebb5... running=false +``` + +The turn ended at 21:33:30, 82 seconds after the kill, and the last line is the point of the +whole exercise: the beat that used to say `running=true` for ever now says `running=false` once +and stops. + +The client's stream carried a real ending rather than closing on a broken pipe: + +``` +error: {"type": "error", "errorText": "The sandbox running this session stopped responding, + so the run was ended. Send the message again to start a fresh sandbox."} +finish: {"type": "finish", "messageMetadata": {...}} +``` + +And the durable transcript for that turn, read back from the records endpoint: + +| Record | Content | +|---|---| +| `message` | the user's prompt | +| `message` | "I'm running the command and will report its output when it completes." | +| `tool_call` | `sleep 240 && echo finished` | +| `usage` | the turn's token accounting | +| `error` | `code: sandbox_gone`, with the line above | +| `done` | terminal | + +The stream row ended as `is_alive: true, is_running: false`. That is the intended result and not +an oversight: the turn is over, and the session stays alive and reattachable. Only the runner +being gone entirely makes a session not alive. + +Without this change the same kill produced, and stopped at, this — captured on the first attempt: + +``` +Error: connect ECONNREFUSED 127.0.0.1:35171 +[sandbox-agent] unhandledRejection: TypeError: fetch failed +[sessions/alive] heartbeat OK session=... running=true <- for ever +``` + +### Reproducing it + +```bash +docker exec agenta-ee-dev-session-watchdog-runner-1 sh -c 'ps -eo pid,args | grep "[s]andbox-agent server"' +docker exec agenta-ee-dev-session-watchdog-runner-1 sh -c 'kill -9 -' +docker logs -f agenta-ee-dev-session-watchdog-runner-1 2>&1 | grep -E "sandbox-liveness|turn-settle" +docker logs -f agenta-ee-dev-session-watchdog-api-1 2>&1 | grep -i watchdog +``` + +**The stack has been torn down.** It ran on port 8880 as `agenta-ee-dev-session-watchdog` while +the scenarios above were recorded, and was stopped with `--down` once they were, to give the box +back its memory. Volumes were kept, so a rebuild is a redeploy rather than a fresh database. + +To bring it back, from this worktree: + +```bash +set -a && . hosting/docker-compose/ee/.env.ee.dev.watchdog && set +a +bash ./hosting/docker-compose/run.sh --license ee --dev --env-file .env.ee.dev.watchdog --no-tunnel +``` + +Two notes for whoever does. The env file is gitignored and carries a stack-local +`AGENTA_SERVICES_INTERNAL_KEY`, which is not in the template and which the deploy refuses to +start without. And the QA OpenAI key lives in that stack's vault, never in this repository. + +## What this slice does not do + +- It does not change `shouldPark` or any teardown rule. An abandoned run keeps its environment + and still runs its own teardown if it ever unwinds. Reclaiming machines stays with the + keep-alive pool. +- It does not close the turns ledger. `session_turns.end_time` still stays NULL on a lost turn. + `SessionTurnsDAO.complete` is idempotent and safe to call, but it needs the turn index, which + is an extra read per row, and nothing in the transcript depends on it. +- It does not hold a distributed lock across API replicas, because deterministic record ids make + a concurrent pass harmless rather than merely unlikely. Two replicas would each do the work; + neither would write a duplicate. +- It does not fix the originating tab. `refreshFromRecords` deliberately early-returns while the + tab is busy, so a tab holding an open-but-dead HTTP stream ignores the watchdog's records until + its own stream errors. Other tabs and a reload see the settled turn immediately. + +## Handover: command settlement + +The durable-cancel slice writes a command's terminal outcome and deliberately does not sweep +expired claims. Its DAO exposes `expire_claims(now, max_deliveries)` and `settle_command` for +this watchdog to call, on the principle that one execution reaches one terminal outcome from +one writer, and that the watchdog is that writer. + +**Agreed, and not built here.** Those functions do not exist on this branch, so code written +against them could be neither compiled nor tested, and a second sweep beside this one is exactly +the race this slice avoided. The work is small and belongs in the pass that already exists: once +the commands slice lands, extend `run_orphan_sweep` to expire claims and settle each expired +command in the same loop that settles its execution. + +## Open questions for Mahmoud + +1. **Is 90 seconds of heartbeat silence the right time to declare a turn lost?** + *Recommendation: ship it and watch.* It is three missed beats at the runner's 30-second + cadence, and it is a setting rather than a constant, so a wrong answer costs a restart rather + than a redesign. The old 300 was chosen when the sweep only collapsed flags and nobody saw the + result. The risk to watch for is the opposite of the obvious one: not settling a turn too + late, but settling a live turn whose runner was merely slow to beat. + +2. **Should the watchdog also close the turns ledger?** *Recommendation: not in this slice.* + `session_turns.end_time` stays NULL on a lost turn, which is a real inconsistency, but nothing + reads it for the transcript and closing it costs a query per row. Worth doing when something + actually reports on turn durations. + +3. **Should the sandbox probe run on Daytona too, given it cannot tell a deleted sandbox from a + proxy error?** *Recommendation: yes, leave it on.* It is a strict improvement where the proxy + does refuse, it costs one request per turn per thirty seconds, and the API watchdog is the + backstop where the proxy answers for a sandbox that is gone. + +4. **Does a lost turn deserve a distinct look in the transcript, rather than the same red + callout as a model failure?** *Recommendation: leave it as it is for now.* The copy and the + Try again button say the useful part, and a new visual state is worth designing only once we + know how often users see this. + +5. **The runner's SSE read loop swallows a severed stream instead of failing the pending + request** (`acp-http-client/dist/index.js:335-339`). That is the true root cause of + [#6418](https://github.com/Agenta-AI/agenta/issues/6418), and this slice bounds it rather than + fixing it. *Recommendation: raise it upstream rather than growing the local patch.* The patch + file already carries four changes, and a fifth in the read path is the kind that breaks + quietly on the next version bump. diff --git a/docs/design/session-control-and-live-events/spike-a-sandbox-cancel.md b/docs/design/session-control-and-live-events/spike-a-sandbox-cancel.md new file mode 100644 index 00000000000..c125b9533db --- /dev/null +++ b/docs/design/session-control-and-live-events/spike-a-sandbox-cancel.md @@ -0,0 +1,422 @@ +# Spike A: cancelling a turn without losing the warm sandbox + +> AGENT-GENERATED, low weight. Findings and a first implementation. Mahmoud makes final decisions. + +Status: the six questions are answered, the runner change is written and unit tested, and the live +scenario passed on the local sandbox for two harnesses. The Claude harness is not tested, because +this stack has no Anthropic key. + +**Codex process reaping is best effort after a settled Stop.** Pi kills its child; Codex does not. +The runner attempts to reap the Codex child and records cleanup misses for QA. A cleanup miss does +not revoke warm reuse or continuity; the 600-second stopped-session window bounds any leftover +process. + +## The answer in one paragraph + +A user Stop can keep the sandbox warm today, and the change to do it is small. The runner already +receives the Stop through its heartbeat and already ends the turn as `cancelled` rather than as an +error. Two things were missing. First, nothing told the harness to stop: the abort only made the +runner stop waiting, so the harness kept an open prompt and a running tool, and only the teardown +that was already deleting the sandbox ever stopped it. Second, `shouldPark` answered `false` for +every aborted run, so a Stop always deleted the sandbox. The fix sends the ACP `session/cancel` +notification, waits for the harness to answer its open prompt, and parks when it does. Live, Pi +answered in 14 ms and Codex in 22 ms, and the next message reused the same sandbox and the same +native harness session. + +## What was tested, and on what + +One table for the whole spike, so nobody has to infer coverage from the prose. "Live" means the +scenario in "The live test" ran against a real deployment; everything else is a code read. + +| Harness | Live test | What `session/cancel` does to the in-flight tool | Evidence | +| --- | --- | --- | --- | +| Pi (`pi_core`) | yes, local sandbox | harness answers the prompt in 14 to 31 ms, and the shell child is GONE | live, process probe returned `NO_SLEEP_PROCESS` | +| Codex | yes, local sandbox | harness answers the prompt in 22 ms; the runner reaps the shell child before parking | live process tree captured the leak; the runner reap is covered at the turn boundary | +| Claude Code | no, this stack has no Anthropic key | not measured | expected to match, from code: the runner branches on capabilities, never on harness name, and sends the same ACP notification to all three | + +| Sandbox provider | Live test | Note | +| --- | --- | --- | +| local | yes, every run | The "sandbox" is a process tree in the runner container. | +| Daytona | no | The park-versus-delete decision costs real money here, so it belongs in the release gate. No snapshot rebuild is needed for the runner-side cancel; a Codex bridge fix would need one. | + +Before this change, the abort sent NO cancel to Claude Code or Codex at all: it resolved a local +promise and left the harness working (`services/runner/src/engines/sandbox_agent/run-turn.ts`, the +cancel race). Only Pi sent one, and only as a side effect of its trace-flush path calling +`destroySession`. All three now get a real cancel. + +## The six questions + +### 1. Which request cancels a running prompt, and where is the guard? + +The request is the ACP `session/cancel` notification. It is the same request for all three +harnesses, because the runner talks to every harness through the same Agent Client Protocol +adapter. There is no per-harness cancel. + +The guard is in the vendored TypeScript client only. `sandbox-agent`'s `SandboxAgent` refuses a +caller-sent cancel: + +```js +var MANUAL_CANCEL_ERROR = "Manual session/cancel calls are not allowed. Use destroySession(sessionId) instead."; +... +async sendSessionMethodInternal(sessionId, method, params, options, allowManagedCancel) { + if (method === SESSION_CANCEL_METHOD && !allowManagedCancel) { + throw new Error(MANUAL_CANCEL_ERROR); + } +``` + +`services/runner/node_modules/sandbox-agent/dist/chunk-TVCDKGSM.js:561` and `:1550` (verified). +The public `rawSendSessionMethod` passes `allowManagedCancel: false`; only `destroySession` passes +`true` (`:1407`). + +The guard is NOT in the daemon. The daemon is a Rust binary +(`@sandbox-agent/cli-`, resolved at `services/runner/src/engines/sandbox_agent/daemon.ts:26`) +that proxies ACP over HTTP. The client sends the cancel as a plain notification with no response +envelope (`services/runner/node_modules/acp-http-client/dist/index.js:115`), and the runner already +sends exactly this notification on every teardown through `destroySession` +(`services/runner/src/environment/harness-session-lifecycle.ts:163`). Verified live: the new +cancel reached the adapter and both harnesses answered. + +`destroySession` is misleadingly named. It sends the cancel, resolves the client's pending +permission requests, and stamps `destroyedAt` on its own local record. It does not tell the daemon +to drop the session, and `resumeSession` clears `destroyedAt` again +(`node_modules/sandbox-agent/dist/chunk-TVCDKGSM.js:1364`). + +### 2. Does the cancel preserve the native harness session? + +Yes, verified live for Pi and Codex. ACP requires the agent to end the open `session/prompt` with +`stopReason: "cancelled"` after a cancel, and both harnesses did: the runner logged +`prompt stopReason=cancelled` in every run. The ACP session stays bound, so the next turn on the +same environment prompts the same native session with no reopen. The live proof is the second +turn recalling a codeword from the first, with no `create_session` stage in the log. + +What the harness reports is the prompt's own answer, not a separate frame. The runner reads the +settlement as "the prompt promise resolved", which is the harness saying it is idle again. + +### 3. What happens to a running tool call and a partial message? + +**In the transcript, the same on every harness.** The runner closes it honestly: on `cancelled` it +drains the queued ACP frames, keeps any real tool completion that already arrived, and settles +every still-open tool call with the `INTERRUPTED_BY_USER` sentinel +(`services/runner/src/engines/sandbox_agent/run-turn.ts:1305`, verified). No orphaned running part +and no invented success. Live, the browser-visible stream for the cancelled turn ended +`tool-input-available`, `tool-output-error`, `finish-step`, `finish`, and the partial assistant text +that had already streamed stayed in the stream. + +**In the sandbox, the harnesses differ, and this is the finding that needs a decision.** The +transcript says the tool was interrupted. Whether the PROCESS actually stopped is a separate +question, and the answer is not the same for both harnesses. Measured by cancelling a running +`sleep`, then asking the next turn to run `ps -eo pid,etimes,args | grep '[s]leep '`: + +| Harness | Cancel answered | Shell child after the Stop | +| --- | --- | --- | +| Pi (`pi_core`) | 14 to 31 ms | gone (`NO_SLEEP_PROCESS`) | +| Codex | 22 ms | still running | + +The Codex reading is unambiguous. One probe returned two leftovers at once, `sleep 120` at 84 +seconds elapsed and `sleep 300` at 31 seconds elapsed, which are the cancelled turns of two +different sessions, so the child survives its own turn AND the session that spawned it. + +**Parking can expose the original leak.** The runner performs a best-effort cleanup in +`reap-exec.ts`: after the cancelled prompt settles, it finds the `codex app-server` below this +sandbox's daemon, selects only descendants started during the stopped turn, and checks that +`kill -9` exits successfully before reporting them reaped. The turn-boundary test pins the order as +cancel, process scan, reap, then park. Failed or unknown cleanup is recorded for QA, while the +settled Stop still preserves the sandbox and native session for the 600-second stopped window. + +**What reaches the API.** The turn's `message`, `tool_call` and `tool_result` rows, a `usage` row, +and the terminal `done` row were present in the live runs. The terminal record carries +`stopReason: "cancelled"` (see below). When the harness confirms cancellation, the runner completes +the turn ledger row and preserves the native-session continuity record. Reap outcomes do not alter +that confirmation. + +### 3b. A stopped turn is now distinguishable from a completed one + +The runner used to drop `stopReason` from the terminal `done` record unless it was exactly +`"paused"`, so nothing downstream could tell a Stop from a normal finish. The record now carries +`"cancelled"` too (`services/runner/src/tracing/otel.ts`, an explicit two-value allowlist rather +than passing the harness's reason through, so `end_turn` cannot start appearing there by accident). + +Verified in Postgres on the live stack, one stopped turn and one completed turn of the same session: + +``` + record_index | record_type | attributes + 4 | done | {"type": "done", "traceId": "a278...", "stopReason": "cancelled"} + 3 | done | {"type": "done", "traceId": "65b4..."} +``` + +### 4. Does the runner park or destroy on every cancellation path today? + +Before this change: it destroyed on every one of them. `shouldPark` opened with +`if (signal?.aborted) return false`, and every Stop reaches the runner as an abort. The path is: + +1. The API Stop tears the `alive` and `running` locks off the turn + (`api/oss/src/core/sessions/streams/service.py:169`, `:288`). +2. The runner's next heartbeat reads `is_current_turn: false` and calls the interrupt callback + (`services/runner/src/sessions/alive.ts:100`, `:205`). +3. The callback aborts the run signal, the turn races to `CANCELLED`, and the result carries + `stopReason: "cancelled"` with `ok: true`. +4. `shouldPark` answered `false`, so the session coordinator evicted with + `no-park:cancelled` and the teardown reason `aborted`, which deletes + (`services/runner/src/engines/sandbox_agent/teardown.ts`, `aborted` is not in the parkable set). + +The keepalive pool never saw a cancelled turn park. Verified live in the negative control run: +`evict key=... reason=no-park:cancelled`, then a cold rebuild on the next message. + +Other teardown reasons are unaffected. A failed turn still destroys, a pause still parks under its +own approval path, and a client disconnect still destroys. + +**The park decision now asks WHY the run aborted, not just whether it did.** Reading +`signal.aborted` cannot tell a cooperative Stop from any other abort, and inferring the Stop from +`stopReason === "cancelled"` would be worse than it looks: the turn sets that value whenever the +signal aborts, whatever aborted it. Any future `controller.abort()` anywhere in the runner would +then silently start parking sandboxes nobody had checked, which is exactly the failure the teardown +allowlist exists to prevent. So the one call site that means a Stop labels its abort +(`server.ts`, the heartbeat interrupt) and `shouldPark` requires that label. The mechanism is the +standard `AbortController.abort(reason)`, so nothing new is threaded through the engine, the +coordinator or the turn. See `services/runner/src/sessions/stop-signal.ts`. + +Today only one call site could have produced a false park, and it is guarded another way: a +non-session run aborts on client disconnect (`server.ts`), but such a run is never `resumable`, so +`runSandboxAgent` would not have parked it. The label is what keeps that true tomorrow. + +**Cancel, steer and kill are indistinguishable to the runner today**, because all three reach it as +the same "you lost the alive lock" heartbeat. That is safe rather than merely tolerable. A steer +WANTS the warm environment for the turn it starts, and a kill separately calls the runner's `/kill`, +which destroys the pool entry by key whether or not it was parked first +(`services/runner/src/server.ts`, the `/kill` route). Naming the actual operation needs the durable +command plane, which is work package B. + +### 5. Is a sandbox-agent patch needed? + +Yes, and it is eight lines. The guard is client-side, so the patch adds one method that sends the +managed cancel without stamping the session record destroyed. It is appended to the existing +`services/runner/patches/sandbox-agent@0.4.2.patch` through the normal pnpm patch flow: + +```js + async cancelSession(id) { + this.cancelPendingPermissionsForSession(id); + await this.sendSessionMethodInternal(id, SESSION_CANCEL_METHOD, {}, {}, true); + } +``` + +plus the matching line in `dist/index.d.ts`. + +Calling `destroySession` instead would also work at the wire level, and would need no patch. It is +the wrong call for two reasons. It marks the session destroyed when it is not, and on the Pi path +it aborts `env.mcpAbort`, which belongs to the ENVIRONMENT rather than the turn, so a parked +environment would come back with a dead tool-MCP server. The runner therefore uses `cancelSession` +and treats a client without it as "cannot cancel cleanly, so destroy". + +### 6. Does Daytona need a rebuilt snapshot? + +No. The daemon is baked into the snapshot +(`services/runner/images/sandbox/daytona/build_snapshot.py:53`, base image +`rivetdev/sandbox-agent:0.5.0-rc.2-full`, snapshot name `agenta-agent-sandbox-v1`, +selected by `AGENTA_RUNNER_DAYTONA_SNAPSHOT`). The change touches only the client library, which +lives in the runner image, and the daemon needs no new behavior: it already forwards this exact +notification on every teardown. Reported, not verified live, because this stack ran the local +sandbox provider. See the release-gate plan below. + +## What the change does + +Five files, one new module, one patch. + +| File | Change | +| --- | --- | +| `services/runner/src/engines/sandbox_agent/cancel-turn.ts` | New. Sends the cancel, waits for the harness, reports whether it settled. | +| `services/runner/src/engines/sandbox_agent/run-turn.ts:1271` | On `cancelled`, cancel the harness first, then record `cancelSettled`. | +| `services/runner/src/sessions/stop-signal.ts` | New. Labels the Stop abort so the park policy can tell it from every other abort. | +| `services/runner/src/server.ts` | The heartbeat interrupt aborts WITH that label. | +| `services/runner/src/engines/sandbox_agent/engine.ts:28` | `shouldPark` parks a labelled, settled Stop. `clientGone` moved above the abort check. | +| `services/runner/src/tracing/otel.ts` | The terminal `done` record carries `stopReason: "cancelled"`. | +| `services/runner/src/engines/sandbox_agent/session-identity.ts` | New `stoppedTtlMs` park window. | +| `services/runner/src/engines/sandbox_agent/teardown.ts:35` | New parkable teardown reason `cancelled`. | +| `services/runner/src/lifecycle/session-coordinator.ts:773` | Both park paths use the stopped window and log `park-cancelled`. | +| `services/runner/src/protocol.ts` | `AgentRunResult.cancelSettled`. | +| `services/runner/patches/sandbox-agent@0.4.2.patch` | Adds `cancelSession(id)`. | + +The rule is: only a CONFIRMED stop parks, and three separate things must be true. The abort must +carry the user-Stop label, the turn must have ended `cancelled`, and the harness must have answered +its prompt inside the budget. A cancel that cannot be sent, a cancel that throws, a prompt that +rejects on the transport, an unlabelled abort, and a harness that stays silent all fail at least one +of the three, and every one of them destroys. This keeps the teardown allowlist's discipline: a new +situation deletes until somebody proves its sandbox is safe to reuse. + +Two deliberate non-changes: + +- **`clientGone` still always destroys.** The check moved above the abort check so the disconnect + verdict cannot be overridden by a settled cancel. One line, and it keeps today's behavior exactly. +- **The cancel does not abort `env.mcpAbort`.** That controller is the environment's, not the + turn's. The approval-park path already skips it for the same reason + (`services/runner/src/engines/sandbox_agent/run-turn.ts:491`). A teardown that does happen still + aborts it through `teardownRuntimeInFlight`. + +## The park window for a stopped session + +A Stop asks a different question from an ordinary idle park. The ordinary window asks how long a +conversation might keep going by itself. A Stop is a button the user just pressed, so the answer is +known: they are about to type. On the 60 second local idle window the sandbox can be thrown away +while they are still writing, which is the cold start this change exists to remove. Mahmoud decided +on 2026-09-05 that a settled Stop uses the same 600 second window as an approval card on both +providers. A stopped Daytona sandbox can therefore remain billed for up to ten minutes. + +Current windows, all from `services/runner/src/engines/sandbox_agent/session-identity.ts`: + +| Window | Local | Daytona | Env override | +| --- | --- | --- | --- | +| Idle (a clean finished turn) | 60 s | 120 s | `AGENTA_RUNNER_SESSION_TTL_MS`, `AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS` | +| Awaiting approval | 600 s | 120 s | `AGENTA_RUNNER_SESSION_APPROVAL_TTL_MS` | +| Stopped by the user (new) | 600 s | 600 s | `AGENTA_RUNNER_SESSION_STOPPED_TTL_MS` | + +The stopped window has its own environment override so operators can choose a different retention +and billing trade-off without changing the ordinary idle or approval windows. The 600 second value +was exercised live and logged `park-cancelled key=... ttl=600000ms`. + +## The settlement timeout (RFC D-016) + +**Recommendation: 10 seconds, overridable with `AGENTA_RUNNER_HARNESS_CANCEL_SETTLE_MS`.** + +Measured settlement, local sandbox, both cancelling a running `sleep 90`: + +| Harness | Time from cancel sent to prompt answered | +| --- | --- | +| Pi (`pi_core`) | 14 ms, 31 ms | +| Codex | 22 ms | +| Claude | not measured, no Anthropic key on this stack | + +Ten seconds is about three hundred times the measured cost, which leaves room for a harness that +has to kill a child process, flush a partial turn, or answer over a Daytona network hop. It is +also short enough that a Stop which genuinely wedges gives up before a user gives up. Raise it only +against a measurement, because every extra second is a second the Stop looks unfinished. Do not +lower it below about one second: the budget also absorbs a slow network to a remote sandbox. + +The timeout is not the user-visible Stop latency. That is dominated by the 30 second heartbeat +interval, which work package B replaces with long polling. + +## The live test + +An isolated EE development stack built from the spike branch used the local sandbox provider and +development images. + +Protocol, driven by `spike_cancel_live.py` in the evidence folder: + +1. Mint an account through `POST /admin/simple/accounts/` and stock the vault with an OpenAI key. +2. Create a workflow, a variant and a revision. The agent config sets + `runner.permissions.default = "allow"`, so no approval gate can end the turn before the Stop + lands. +3. Turn 1: ask the agent to run `sleep 90` through its shell tool, streamed over SSE. +4. At 30 seconds, send the Stop: `POST /api/sessions/streams/` with `{"session_id": ..., "force": false}`. + The API answers `{"mode":"cancel", ...}`. +5. Turn 2: same session, replay the cancelled turn's assistant message, then ask for the codeword + from turn 1. + +Results: + +| Harness | Cancel settled | Sandbox after Stop | Turn 2 | Turn 2 wall clock | Recalled turn 1 | +| --- | --- | --- | --- | --- | --- | +| Pi (`pi_core`) | yes, 14 ms | parked | same sandbox, `hit-continue` | 2.3 s | yes | +| Codex | yes, 22 ms | parked | same sandbox, `hit-continue` | 12.2 s | yes | +| Pi, budget forced to 1 ms | no, timeout | destroyed | new sandbox, cold | 8.0 s | yes, from replay | + +The negative control is also the "before" picture: with the cancel unable to settle, the log reads +`evict key=... reason=no-park:cancelled` and the next turn pays a full rebuild. That is what every +Stop did before this change. + +The scenario was re-run after the review changes landed, and the park now shows the stopped window: +`park-cancelled key=... ttl=600000ms`, then `hit-continue` on the next turn. + +Codex's 12.2 second second turn is the model, not a cold start: the log shows `hit-continue` and +no `sandbox_start`, and the turn spent its time on reasoning tokens and two file reads. + +Log lines and raw run output: `~/agenta-qa-evidence/2026-09-02-spike-a-sandbox-cancel/`. + +**One trap worth writing down.** The first attempt looked like a failure and was not. The keepalive +pool matches a warm session on a fingerprint over the prior user texts AND the tool-call ids the +previous turn emitted (`services/runner/src/engines/sandbox_agent/session-identity.ts:436`). A +resume that omits the cancelled turn's assistant message therefore mismatches on history and +rebuilds cold, no matter how well the cancel worked. The browser sends that message, so the product +path is fine, but any test driver must replay it. + +## Unit tests + +`services/runner/tests/unit/harness-cancel-park.test.ts` (new) pins four rules: + +- The cancel helper's settled, timed-out, rejected, unpatched-client and throwing cases. +- The Stop label: only the labelled abort counts, and a look-alike value cannot forge it. +- `shouldPark` parking a labelled settled Stop, destroying an unsettled one, destroying an + UNLABELLED abort even when the cancel settled, destroying a failed turn, and still destroying on + client disconnect. +- The park windows, their env override, and the teardown reason stopping rather than deleting. +- The terminal `done` record: a Stop carries `cancelled`, a pause still carries `paused`, and a + completed turn plus every harness-reported reason carry nothing. The last case is the point of + the two-value allowlist. + +`services/runner/tests/unit/teardown.test.ts` gains the `cancelled` row, and +`services/runner/tests/unit/session-pool.test.ts` gains the new config field. + +On the frontend, `web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts` pins that +a cancelled `done` closes the turn like a completed one and does not mark it paused. Reconstruction +reads only `"paused"` (`transcriptToMessages.ts`), so the new value is inert there, which is a claim +worth a test rather than a comment. + +Full suite: `cd services/runner && pnpm test` gives 159 files passed, 2651 tests passed. The +agenta-chat transcript suite gives 52 passed. + +## What is not done + +- **Claude is untested.** This stack has no Anthropic key. The cancel is the same ACP notification + for every harness and the runner branches on capabilities rather than harness name, so the + expectation is that Claude behaves like the other two. It is an expectation, not a measurement. +- **Daytona is untested.** Every live run used the local sandbox provider. The Daytona park path is + the one where park versus delete costs real money, so it belongs in the release gate. +- **A settled Stop preserves the continuity record.** The durable row carries the native session + ID and an end time, so a runner restart can load the same native conversation from its mounted + transcript instead of discarding the Stop as an invalid resume point. +- **The Stop still takes up to 30 seconds to reach the runner.** That is work package B. +- **The Codex orphan is reaped by the runner.** Live Daytona verification remains part of the + pair-level release gate; the runner-side fix needs no vendored bridge or snapshot rebuild. + +## Live test plan for the release gate + +Add one cell, run per harness and on both sandbox providers. + +1. Start a turn that runs a long shell command, on a fresh session. +2. Wait until a `tool-input-available` frame for that command has arrived, then send the Stop. +3. Assert on the stream: the turn ends with `finish`, its open tool call settles as + `tool-output-error`, and no `error` frame claims the run failed. +4. Assert on the runner log: `stage=harness_cancel sent=true settled=true`, then, for Codex, + `stage=harness_reap killed=...`, then `prompt stopReason=cancelled`, then `park-cancelled`. Record + `cleanup_miss=true` as QA evidence, but fail the warm-reuse cell only on `no-park:cancelled`. +5. Send a second message on the same session, replaying the cancelled turn's assistant message. +6. Assert on the runner log: `hit-continue` for the same pool key, and NO `stage=sandbox_start` + between the two turns. On Daytona, additionally assert the sandbox id is unchanged. +7. Assert the second turn's answer references something only turn 1 said. +8. Assert the stopped turn's terminal `done` record carries `stopReason: "cancelled"` and the + completed turn's does not. +9. When reaping succeeds, assert that no leftover process from the cancelled command survives into + the second turn. When reaping fails or is unknown, record the cleanup miss and still assert warm + parking and native-session continuity; the stopped TTL bounds the leftover process to 600 seconds. + +The negative leg is worth keeping too: with `AGENTA_RUNNER_HARNESS_CANCEL_SETTLE_MS=1` the same +scenario must log `settled=false` and `no-park:cancelled`. That proves the guard still guards. + +## Open questions for Mahmoud + +1. **How should a failed Codex reap affect parking?** Decision: keep the settled Stop parked. Reaping + is best effort, cleanup misses are QA evidence, and the 600-second stopped TTL bounds leftovers + without sacrificing warm reuse or native-session continuity. +2. **Ten seconds for the settle budget?** Recommendation: yes, ship it. The measured cost is + 14 to 31 ms, so the budget is not a latency cost in the normal case, and it only ever delays a + Stop that is already going badly. +3. **Should the Stop also settle the turn ledger row, rather than leaving the turn incomplete?** + Recommendation: yes, in work package C. The terminal record now says `cancelled`, so a reader can + tell a Stop from a completion, but the ledger row still looks like a turn that never finished. +4. **Do we test Claude and Daytona before the RFC is accepted, or at the release gate?** + Recommendation: at the release gate, with the cell above. Blocking the design on an Anthropic key + tonight buys little, because the cancel is one protocol request shared by every harness, and the + Codex result shows the interesting variation is in what the harness does with it, not whether it + accepts it. + +Every settled Stop preserves the continuity row and native session, regardless of its best-effort +Codex reap outcome. Only a harness cancel that does not settle invalidates continuity and falls back +to cold replay. A plain `clientGone` still destroys because a disconnect is not a Stop. diff --git a/docs/design/session-control-and-live-events/spike-b-durable-commands-design.md b/docs/design/session-control-and-live-events/spike-b-durable-commands-design.md new file mode 100644 index 00000000000..65f82bd3806 --- /dev/null +++ b/docs/design/session-control-and-live-events/spike-b-durable-commands-design.md @@ -0,0 +1,1355 @@ +# Spike B: durable commands and control delivery + +> AGENT-GENERATED, low weight. Implementation-ready design for discussion. Mahmoud makes final +> decisions. + +Scope: reliable API-to-runner commands, version one. The only command kind in version one is +Cancel, which the product calls Stop. The design keeps Redis execution ownership as it is, adds no +Postgres execution authority, no ownership generations, no stale-writer fencing, and no +multi-runner routing. + +Every claim below is marked **verified** (read in the code of this worktree, with `path:line`) or +**reported** (taken from a document, named at the point of use). + +This revision answers the architecture review at `review-architecture.md`, sections 3 and 4. The +holes it names are addressed here: H-2 in sections 5 and 7, H-3 in sections 4 and 7, H-4 in section +4, H-5 in section 4, H-6 in section 5, and the interface corrections in sections 2, 5 and 9. H-1, +the `shouldPark` change, belongs to Work package A and is named as a dependency in section 7. + +Terms used here: + +- **Execution:** one runner attempt at one user message. In the code today its identifier is the + `turn_id` the runner mints (`services/runner/src/server.ts:190`). This design does not rename it. +- **Command:** one durable request to change an execution. +- **Held session:** a session this runner process holds warm, whether it is running a turn, idle in + the keep-alive pool, or parked awaiting an approval. + +--- + +## 1. What happens today when a user presses Stop + +**Verified.** The browser stops its own stream at once. The runner learns nothing until its next +heartbeat, which is up to 30 seconds later. The sandbox is then deleted, so the next message is a +cold start. + +The chain, in order: + +1. `handleStop` marks the turn stopped locally and aborts the client fetch + (`web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts:480`). +2. The browser posts `POST /sessions/streams/` with no inputs and no `force`, and **with no + execution id** (`useAgentChatSession.ts:505`, which passes only `{sessionId, projectId}`). + Mobile posts the same call (`web/mobile/src/features/chat/StopButton.tsx:18`). +3. The route runs `set_session_stream` (`api/oss/src/apis/fastapi/sessions/router.py:369`), which + calls `SessionStreamsService.command` (`api/oss/src/core/sessions/streams/service.py:229`). +4. No inputs and no `force` resolves to `CommandMode.cancel` + (`api/oss/src/core/sessions/streams/service.py:288`). +5. Cancel calls `_displace_turns` (`api/oss/src/core/sessions/streams/service.py:169`). It writes a + supersession tombstone for the current `alive` and `running` owners, then force-deletes both keys + (`service.py:190` and `service.py:193`). It marks the row ended and publishes the `ended` + lifecycle event. **The API never contacts the runner.** +6. The runner finds out on its next heartbeat. The beat runs on a 30 second interval + (`services/runner/src/sessions/alive.ts:221`, `HEARTBEAT_INTERVAL_SECONDS = 30` in + `services/runner/src/sessions/contract.ts:18`). +7. The beat returns `is_current_turn: false` + (`api/oss/src/core/sessions/streams/service.py:452`), the runner reads it as `interrupted` + (`services/runner/src/sessions/alive.ts:105`), and the watchdog fires `onInterrupted` once + (`alive.ts:207`), which `server.ts:519` wires to `controller.abort()`. +8. The abort makes `shouldPark` return false, so the environment is destroyed rather than parked + (`services/runner/src/engines/sandbox_agent/engine.ts:26`). The sandbox and the native harness + session are gone. + +### The delay chain + +| Step | Where | Cost | +|---|---|---| +| Browser aborts its own stream | `useAgentChatSession.ts:480` | immediate | +| Cancel request returns | `router.py:369` | one API round trip | +| Redis keys cleared, row marked ended | `streams/service.py:190` | inside that call | +| Runner notices | `alive.ts:221` | **0 to 30 seconds** | +| Run aborts | `server.ts:519` | immediate after the beat | +| Harness cancel and sandbox teardown | `engine.ts:26` | seconds, and the sandbox is deleted | + +The 30 second wait is the whole problem. Four further defects ride on it: + +- **A Stop can be lost silently.** A heartbeat that returns a non-2xx status yields + `interrupted: false` by design (`services/runner/src/sessions/alive.ts:92`). A run whose platform + credential expired or was dropped can never be stopped. The credential states are logged at + `services/runner/src/server.ts:445`. +- **A parked session has no channel at all.** When a turn parks awaiting an approval, the request + handler's `finally` calls `aliveWatchdog.release()` (`services/runner/src/server.ts:618`), which + clears the heartbeat interval and sends one last beat with `is_running: false` + (`services/runner/src/sessions/alive.ts:241`). From that moment the runner sends no heartbeat for + that session, so the only existing control channel is gone. This is review hole H-2, and it is why + section 5 makes the poll session-scoped rather than turn-scoped. +- **A late Stop can kill the next turn.** `_displace_turns` reads whoever holds `alive` and + `running` at the moment it runs, so a Stop applied 300 ms after the turn ended tombstones the turn + that started in between. The tombstone lasts an hour and every read refreshes it + (`api/oss/src/dbs/redis/sessions/locks.py:147`). This is review hole H-3. +- **Stop is not free.** Because the abort path destroys the environment, Stop today costs the warm + sandbox and the native harness session. Work package A owns the fix. This design assumes it + delivers a warm park on Stop. + +--- + +## 2. The command record + +### Placement + +| Question | Answer | +|---|---| +| Database | Core Postgres (`env.postgres.uri_core`, `TransactionsEngine`), the same database as `session_streams`, `session_turns`, `session_interactions`. Verified at `api/oss/src/dbs/postgres/shared/engine.py:29`. | +| Table | `session_commands` | +| Core module | `api/oss/src/core/sessions/commands/` with `dtos.py`, `interfaces.py`, `service.py`, `types.py`, matching the layout of `core/sessions/interactions/` | +| Storage module | `api/oss/src/dbs/postgres/sessions/commands/` with `dbas.py`, `dbes.py`, `dao.py`, `mappings.py` | +| Migration | `api/oss/databases/postgres/migrations/core_oss/versions/oss000000022_add_session_commands.py`, revising `oss000000021` (verified: `oss000000021_add_session_streams_references.py` is the current head of that chain) | + +Not tracing. The tracing database holds spans, and a command is coordination state that the +sessions plane owns. + +### Columns + +The mixins are the house ones from `api/oss/src/dbs/postgres/shared/dbas.py`: `ProjectScopeDBA`, +`IdentifierDBA`, `LifecycleDBA`, `DataDBA`, `FlagsDBA`, `TagsDBA`, `MetaDBA`. That is the same set +`SessionInteractionDBA` uses (`api/oss/src/dbs/postgres/sessions/interactions/dbas.py:14`). + +| Column | Type | Role | Meaning | +|---|---|---|---| +| `project_id` | UUID, not null | scope | Tenant boundary. Foreign key to `projects.id`, `ON DELETE CASCADE`. | +| `id` | UUID, not null, uuid7 | identity | The `command_id`. The API mints it. | +| `session_id` | String, not null | routing | Which session the command acts on. A bare correlator, not a foreign key, like every other sessions table. | +| `kind` | String, not null | routing | `cancel` in version one. | +| `target_turn_id` | String, null | target | The execution this command must reach, resolved once at admission. Null only when nothing was running or parked. | +| `expected_turn_id` | String, null | target | The caller's `expected_execution_id`, stored as sent. Null when the caller supplied none. | +| `data` | JSON, null | input | The command's own arguments, shaped `{"input": {"text": ..., "attachments": [...]}, "policy": {"on_busy": ...}}`. Empty for `cancel`. | +| `state` | String, not null | delivery | `pending`, `claimed`, `applied`, `obsolete`. | +| `claimed_by` | String, null | delivery | The replica that holds the current claim. Bookkeeping, not an address. | +| `claim_expires_at` | TIMESTAMP tz, null | delivery | When the claim may be delivered again. | +| `claim_count` | Integer, not null, default 0 | delivery | Deliveries so far. Caps re-delivery. | +| `outcome` | String, null | result | What happened to the execution: `stopped`, `not_running`, `superseded_by_newer_turn`, `failed`, `lost`. Null while open. | +| `idempotency_key` | String, null | context | The caller's `Idempotency-Key` header, stored verbatim. | +| `settled_at` | TIMESTAMP tz, null | metadata | When the command reached a terminal state. | +| `flags`, `tags`, `meta` | JSONB / JSON, null | metadata | House mixins. Unused in version one, present for consistency. | +| `created_at`, `updated_at`, `deleted_at`, `created_by_id`, `updated_by_id`, `deleted_by_id` | `LifecycleDBA` | metadata | House lifecycle columns. `created_at` carries a guard: it is the "do not supersede a newer turn" comparison of section 4. | + +Four grouping rules from the interface review are applied here. + +- **Delivery bookkeeping is one group.** `state`, `claimed_by`, `claim_expires_at` and `claim_count` + are the delivery record. On the wire they are nested under `delivery`. In the table they are flat + columns because a claim query filters and orders on them, and a JSON blob cannot be indexed for + that. The names carry the grouping. +- **Delivery is never merged with the result.** `state` says where the command is; `outcome` says + what happened to the execution. That separation is the whole point of decision D-016. +- **The target has its own two columns.** `expected_turn_id` is what the caller asserted; + `target_turn_id` is what the API resolved. Keeping both makes a 409 explainable after the fact and + gives a future `target.execution_id` an obvious home. +- **There is no `owner_replica_id` and no `runner_url`.** The first revision routed commands by the + owner replica. Section 5 replaces that with session-scoped claims, so the record needs no routing + identity at all, and an address in a durable record would be an implementation detail with a + lifetime longer than the thing it points at. + +### Two columns added to `session_streams` + +**`stopping_turn_id`**, String, nullable. It names the execution that an accepted Stop is waiting on. +It is written in the same transaction as the command insert, and cleared at settlement. + +**`turn_started_at`**, TIMESTAMP tz, nullable. It records when the row's current `turn_id` started. +It exists for one reason: the stale-Stop guard in section 4 needs to compare a command's arrival +time with the current execution's start time, and **there is nowhere to read that today**. The +options were checked, and none of them works: + +| Candidate | Why it does not serve | +|---|---| +| `session_streams.updated_at` | It is the heartbeat timestamp and moves every 30 seconds. Verified: the mirror write is unconditional (`api/oss/src/core/sessions/streams/service.py:618`). | +| The turn id itself | API-minted turns use uuid7 and are time-ordered (`streams/service.py:940`), but the runner mints its own with `randomUUID()`, which is uuid4 and carries no time (`services/runner/src/server.ts:190`, verified). Every browser turn today is runner-minted. | +| Redis `running` or `alive` | The value is the bare turn id, and the release-if-owner script compares the whole value (`api/oss/src/dbs/redis/sessions/contract.py:153`). Packing a timestamp into it would break that compare and the golden fixture the runner shares. | +| `session_turns.start_time` | It is written, from `turnStartedAt` captured at `services/runner/src/engines/sandbox_agent/run-turn.ts:192` and sent at `:469`. But the append is fire-and-forget (`.catch(() => {})`) and it needs a stream id and a continuity index, so a turn can be running with no row at all. It is a good secondary source, not a guard. | + +So add the column. It is written wherever `turn_id` is written, in the same statement, and only when +the id actually changes: + +```sql +UPDATE session_streams + SET turn_id = :turn_id, + turn_started_at = CASE + WHEN turn_id IS DISTINCT FROM :turn_id THEN now() + ELSE turn_started_at + END, + ... +``` + +That form is idempotent under the repeated heartbeats that stamp the same id every 30 seconds, and +it needs no new writer: both `_start_turn` (`streams/service.py:940`) and the heartbeat's +`durable_turn_id` stamp already go through `SessionStreamEdit`. + +Both are columns and not bits inside `flags` because `flags` is the Redis mirror. Every heartbeat +rewrites it (`api/oss/src/core/sessions/streams/service.py:618`), so a value stored there would be +erased on the next beat. `SessionStreamEdit` carries only `flags`, `tags`, `meta` and `turn_id` +(`api/oss/src/core/sessions/streams/dtos.py:73`), so the heartbeat path cannot touch +`stopping_turn_id` by accident, and it touches `turn_started_at` only through the guarded `CASE`. + +### Indexes and constraints + +```python +__table_args__ = ( + ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"), + PrimaryKeyConstraint("project_id", "id"), + UniqueConstraint( + "project_id", "session_id", "idempotency_key", + name="uq_session_commands_idempotency", + ), + CheckConstraint("kind IN ('cancel')", name="ck_session_commands_kind"), + CheckConstraint( + "state IN ('pending', 'claimed', 'applied', 'obsolete')", + name="ck_session_commands_state", + ), + Index( + "ix_session_commands_open", + "project_id", "session_id", "created_at", + postgresql_where=text("state IN ('pending', 'claimed') AND deleted_at IS NULL"), + ), + Index( + "ix_session_commands_claims", + "claim_expires_at", + postgresql_where=text("state = 'claimed' AND deleted_at IS NULL"), + ), + Index( + "ix_session_commands_project_session", + "project_id", "session_id", "created_at", + ), +) +``` + +`ix_session_commands_open` is the claim query's index. It leads with `(project_id, session_id)` +because a claim asks for the commands of a named set of sessions, and it is partial on the open +states because a settled command is never claimed again. It also serves the open-command collapse +read at admission. + +The check constraints copy the shape of `ck_session_attachments_state` +(`api/oss/src/dbs/postgres/sessions/attachments/dbes.py:35`). + +### Idempotency, in two layers + +1. **Client key.** `uq_session_commands_idempotency` on `(project_id, session_id, + idempotency_key)`, the same triple `uq_session_attachments_idempotency` uses + (`api/oss/src/dbs/postgres/sessions/attachments/dbes.py:29`). An insert that hits the constraint + is caught, the existing row is read back, and it is returned to the caller. That is the pattern + `SessionInteractionsDAO.create_interaction` already uses + (`api/oss/src/dbs/postgres/sessions/interactions/dao.py:60`). A null key never collides, because + Postgres treats nulls as distinct in a unique index. +2. **Open-command collapse.** Even with no client key, admission first looks for an open command + (`state IN ('pending','claimed')`) of the same `kind` for the same `(project_id, session_id, + target_turn_id)`. If one exists, the API returns it instead of creating a second. This is what + makes "two Stops in a row" correct without asking the browser to send a key. + +The server `command_id` is the idempotency identity of every later step. A settle for a command that +already reached a terminal state returns the stored state and changes nothing. + +### Retention + +Settled rows (`state IN ('applied','obsolete')`) are deleted 7 days after `settled_at` by the sweep +described in section 4. Commands are operational state, not session history. Durable session history +stays in `session_records`. Open rows are never deleted by the sweep; the watchdog settles them +first. + +--- + +## 3. The state machine + +```text + admission + | + v + +------------> pending ------------------------+ + | | | + | claim expired, | claim (poll, direct call, | nothing to do + | session still | or heartbeat) | + | beating v v + | claimed --------> applied obsolete + | | runner + +-----------------+ reports + | + | claim expired and the session stopped beating + v + obsolete (outcome = lost) +``` + +`applied` and `obsolete` are terminal. There is no transition out of either. + +Every transition is one `UPDATE ... WHERE ... RETURNING *` whose `WHERE` names the state it expects. +`scalar_one_or_none()` decides the winner, so two API replicas cannot both win. This is exactly the +pattern `SessionInteractionsDAO.transition_interaction` already uses +(`api/oss/src/dbs/postgres/sessions/interactions/dao.py:120`). Verified. + +| Transition | Who does it | Guard | +|---|---|---| +| none to `pending` | The API, on an accepted Cancel | `INSERT`, protected by `uq_session_commands_idempotency` and by the open-command collapse read in the same transaction | +| none to `obsolete` | The API, when nothing is running or parked | Same insert, with `state='obsolete'`, `outcome='not_running'`, `settled_at=now()` | +| `pending` to `claimed` | The API, serving a claim, a direct call, or a heartbeat | `WHERE state = 'pending'` | +| `claimed` to `pending` | The command sweep, when a lease expired and the session is still beating | `WHERE state = 'claimed' AND claim_expires_at < now() AND claim_count < :max_deliveries` | +| `claimed` to `applied` | The API, on the runner's outcome report | `WHERE state = 'claimed' AND claimed_by = :replica_id` | +| `claimed` to `obsolete` | The API, on a report of `not_running` or `superseded_by_newer_turn` | Same guard | +| `claimed` to `obsolete` (`lost`) | The command sweep, when the lease expired and the session stopped beating | `WHERE state = 'claimed' AND claim_expires_at < now()`, plus the heartbeat-age test of section 4 | +| `pending` to `obsolete` (`lost`) | The command sweep, when nobody ever claimed it | `WHERE state = 'pending' AND created_at < :admission_deadline` | + +The claim statement, in the form the DAO writes it: + +```sql +UPDATE session_commands + SET state = 'claimed', + claimed_by = :replica_id, + claim_expires_at = now() + make_interval(secs => :lease_seconds), + claim_count = claim_count + 1, + updated_at = now() + WHERE (project_id, id) IN ( + SELECT project_id, id + FROM session_commands + WHERE state = 'pending' + AND deleted_at IS NULL + AND (project_id, session_id) IN :held_sessions + ORDER BY created_at + LIMIT :limit + FOR UPDATE SKIP LOCKED + ) +RETURNING *; +``` + +`:held_sessions` is the set of sessions the calling runner holds warm, sent with the request. See +section 5. `FOR UPDATE SKIP LOCKED` is what lets two API replicas serve two claims at the same time +without either blocking or double-claiming. + +The settle statement: + +```sql +UPDATE session_commands + SET state = :result, outcome = :outcome, settled_at = now(), updated_at = now() + WHERE project_id = :project_id + AND id = :command_id + AND state = 'claimed' + AND claimed_by = :replica_id +RETURNING *; +``` + +Zero rows means the claim had already expired or another actor settled it. The route then reads the +row and answers 409 with its stored state, so the runner learns the truth instead of retrying. + +--- + +## 4. The claim lease and the settlement rule + +| Setting | Value | Reason | Environment variable | +|---|---|---|---| +| Lease duration | 90 seconds | Three heartbeat intervals, the window the review picked for H-4 | `AGENTA_SESSIONS_COMMAND_LEASE_SECONDS` | +| Maximum deliveries | 3 | Bounds a delivery loop when a runner accepts but never reports | `AGENTA_SESSIONS_COMMAND_MAX_DELIVERIES` | +| Sweep interval | 10 seconds | Fine enough that a lost Stop settles inside two minutes | `AGENTA_SESSIONS_COMMAND_SWEEP_SECONDS` | +| Admission deadline | 90 seconds | A command nobody ever claimed is a runner that is not there | `AGENTA_SESSIONS_COMMAND_ADMISSION_TIMEOUT_SECONDS` | + +All four go in a new `SessionsCommandsConfig` block in `api/oss/src/utils/env.py`, read through the +shared `env` object. Do not call `os.getenv` in the service (`AGENTS.md`, "Environment config"). + +**Renewal: none in version one.** A claim is not renewed while the runner works. It expires and is +either delivered again or settled. This is safe because applying a Cancel is idempotent, and because +the runner deduplicates. A renewal route is the first thing to add if a harness cancel is ever +slower than the lease, and the column that would carry it (`claim_expires_at`) already exists. + +### The settlement rule when the runner is gone (H-4) + +**The Redis time to live cannot be the signal.** `alive` and `running` both hold 3600 seconds +(`api/oss/src/utils/env.py:1417` and `:1421`, verified). A `stopping` state that waits for those +keys to expire is a `stopping` state that lasts an hour. Settlement must key off **heartbeat age**, +which is `session_streams.updated_at`, the column the heartbeat writes on every beat and the one the +orphan sweep already filters on (`api/oss/src/tasks/asyncio/sessions/orphan_sweep.py:57`, verified). + +The rule, evaluated by the sweep for every command whose `claim_expires_at` has passed: + +| Heartbeat age for that session | Attempts left | Action | +|---|---|---| +| Under 90 seconds (the runner is alive, the report was lost) | yes | Re-arm to `pending` and deliver again | +| Under 90 seconds | no | Settle `obsolete`, `outcome='lost'`, and run the settlement side effects | +| 90 seconds or more (the runner is gone) | either | Settle `obsolete`, `outcome='lost'`, and run the settlement side effects | + +A session parked awaiting an approval stops beating on purpose (`server.ts:618`), so it would look +"gone" by heartbeat age alone. Exclude it: a command whose target session has an open interaction, +or whose stream row is `alive` but not `running`, uses the admission deadline rather than the +heartbeat-age test. That is the same distinction the orphan sweep already draws between its 300 +second running threshold and its 1800 second idle threshold +(`api/oss/src/tasks/asyncio/sessions/orphan_sweep.py:33` and `:37`, verified). + +**The watchdog owns settlement, not this design.** A separate agent is building the execution +watchdog on branch `feat/session-execution-watchdog`. This design does not build a second one. The +command sweep described here is either that watchdog with the command rules folded in, or a caller +of it. The single rule both must obey: **one execution reaches exactly one terminal outcome, written +by exactly one writer.** If the watchdog marks an execution `lost`, it must settle that execution's +open commands in the same transaction, and vice versa. Decide the ownership before either lands. + +The side effects of a `lost` settlement are the same as a normal settlement (section 7, step 10 to +13), with one difference: Redis keys are cleared with the force variants rather than the +owner-checked ones, because the owning process is gone. + +### Deduplication on the runner (H-5) + +The applied-command set must outlive the poll loop, because a loop restart with an empty set would +apply a Stop a second time, and by then the session may be running a newer turn. + +- The set lives in the same module as the session state the runner already keeps across turns, next + to `SessionPool` (`services/runner/src/engines/sandbox_agent/session-pool.ts:90`), keyed by + `${projectId}:${sessionId}` with a bounded list of applied command ids and their apply times, kept + for 30 minutes. It is not owned by the poll loop and does not reset when the loop restarts. +- Both delivery paths call one `applyCommand(command)` entry point that consults the set first. +- **Applying an already-applied command is a no-op that re-sends the acknowledgement.** It does not + abort anything, and it does report the stored outcome, so a lost acknowledgement is repaired + without a second abort. + +### The three guards on the target execution (H-3) + +A Stop that arrives after its turn ended must not touch the next turn. Three guards, in order of +strength: + +1. **The API compares arrival time with the current turn's start time.** This is the guard that + closes the reported race, so it is spelled out below. +2. **The target is pinned at admission.** The API resolves `target_turn_id` once and never + re-resolves it. A turn that starts later has a different id, so a pinned command cannot reach it. +3. **The runner repeats the comparison locally.** The envelope carries the command's arrival time. + The runner refuses to abort an execution that started after it, and settles the command + `obsolete` with `outcome='superseded_by_newer_turn'`. The runner holds its own execution's start + time in memory, so this check is exact even when the API's is not. +4. **First-party clients always send `expected_execution_id`.** The field stays optional in the + contract, as decision D-010 requires, but the desktop and mobile Stop buttons must send it. Today + the desktop sends nothing (`useAgentChatSession.ts:505`, verified). Treat an omitted id from a + first-party client as a bug, not as a supported mode. + +#### The arrival-time comparison, when no expected execution id was sent + +The race: the user presses Stop at t=0 while turn one is running. Turn one ends at t=0.1. Turn two +starts at t=0.2. The request is applied at t=0.3, reads Redis, finds turn two, and targets a turn the +user never meant to stop. + +The rule, applied at admission before anything is inserted: + +1. The service stamps `received_at = now()` as its **first** action, before it reads Redis. It later + writes that same value as the row's `created_at` rather than letting the server default fill it, + so the value it compared is the value it stored. +2. It reads the current running owner from Redis and the session's row, which gives `turn_id` and + `turn_started_at` in one query the admission path already makes. +3. If `turn_started_at > received_at`, the current execution began after the user pressed Stop. + Insert the command already settled: `state='obsolete'`, + `outcome='superseded_by_newer_turn'`, `settled_at=now()`, `target_turn_id=null`. Return 200 with + `execution.state = "idle"`. **Do not target that turn and do not touch Redis.** +4. Otherwise proceed normally. + +This runs only when `expected_execution_id` is absent. When the caller sent one, the 409 comparison +already settles the question and is stricter. + +**When `turn_started_at` is null, the guard does not fire.** A row written before this column +existed, or a turn whose stamp was lost, yields no comparison. The API then targets the turn as it +does today and leaves the decision to guard 3, which is exact because the runner reads its own +memory. Failing this way round is deliberate: a guard that refuses to Stop whenever it lacks data +would break the common case to protect a rare one. + +`session_turns.start_time` is a useful secondary source when the row exists, but the design does not +depend on it, for the reasons in the table in section 2. + +The `expected_execution_id` check itself happens twice, for two different reasons. At admission the +API compares it to the Redis running owner and answers 409 if they differ. At application the runner +applies the command only to a local execution whose `turnId` equals `target_turn_id`, and settles +`obsolete` with `outcome='not_running'` when it holds no such execution. + +--- + +## 5. The claim contract + +### The loop is session-scoped and lives as long as the session is warm (H-2) + +This is the single most important correction from the review. The first revision started one poll +per runner process and routed by owner replica. That has two faults: it cannot say which sessions +the runner actually holds, and a per-turn loop would go silent exactly when a turn parks. + +The rule: + +- **One loop per runner process.** Not one per turn and not one per session. +- **The loop declares the sessions it holds.** Every claim carries the current set. That set is the + union of the execution registry (turns in flight) and the keep-alive pool keys, which are already + `${projectId}:${sessionId}` strings and already include parked entries + (`SessionPool.keys()` and `SessionPool.snapshot()`, + `services/runner/src/engines/sandbox_agent/session-pool.ts:108` and `:127`, verified; a parked + entry is seated as `awaiting_approval` at + `services/runner/src/lifecycle/session-coordinator.ts:764`, verified). +- **A session leaves the set only when the runner stops holding it warm.** A parked approval stays + in the set, so a Stop reaches it. That is H-2 closed. +- **Claims are queries over durable state, never a stream position** (H-6). The request declares a + set of sessions and the API answers with whatever is pending for them right now. There is no + cursor, no offset and no resume token, so a command created while the connection was down is + picked up by the next claim like any other. + +### Routes + +| Route | Method | Caller | Purpose | +|---|---|---|---| +| `/sessions/control/commands/claim` | POST | Runner | Claim the pending commands for the sessions this runner holds, waiting up to the hold if there are none | +| `/sessions/control/commands/{command_id}/outcome` | POST | Runner | Report the terminal outcome | + +Both live on a new `SessionControlRouter` in `api/oss/src/apis/fastapi/sessions/router.py`, included +with no prefix like the streams router (`api/entrypoints/routers.py:1354`, verified), and excluded +from the public schema. + +### Authentication + +The runner authenticates its per-run calls as the invoke caller, using the ephemeral platform +credential from the run (`services/runner/src/sessions/alive.ts:60`, verified). That credential +cannot carry these routes: the loop belongs to the process and spans many projects, and a run's +credential expires while the process keeps polling. + +So both routes use the shared runner token, `AGENTA_RUNNER_TOKEN`, which both sides already hold +(`api/oss/src/utils/env.py:1161` as `env.runner.token`, and `services/runner/src/server.ts:104`). +Verified. It is the same secret the existing API-to-runner hop uses in the other direction +(`api/oss/src/core/sessions/streams/runner_client.py:44`). + +Mechanics: + +- Add the prefix `/sessions/control/` to `_PUBLIC_ENDPOINTS` + (`api/oss/src/middlewares/auth.py:52`), so the project-scoped auth middleware does not reject a + request that carries no user credential. This is the same treatment the OAuth callback and the + Composio event routes already get. +- The route then does its own check, with a constant-time comparison against `env.runner.token`, + accepting `X-Agenta-Runner-Token: ` first and `Authorization: Bearer ` second. That + is the header pair and the comparison the runner itself already implements + (`services/runner/src/server.ts:127`). +- **Fail closed.** If `env.runner.token` is unset or blank, both routes answer 503 and serve nothing. + Being exempt from the middleware makes the route's own check the only gate, so it must never + default to open. +- The project scope of every command comes from the row and from the declared session set, never + from a header. A runner can only receive commands for sessions it named, and a session id is + meaningful only inside its project, so the pair is the scope. + +### Request and response bodies + +Claim request: + +```json +{ + "replica_id": "runner-7f3c", + "sessions": [ + {"project_id": "1f0a4b2c-0000-4000-8000-000000000002", "session_id": "sess-42"}, + {"project_id": "1f0a4b2c-0000-4000-8000-000000000002", "session_id": "sess-77"} + ], + "wait_seconds": 25, + "limit": 10 +} +``` + +`replica_id` is delivery bookkeeping: it becomes `claimed_by` so a settle can be matched to its +claim. It is not routing, and it is not an address. `sessions` is the routing input, capped at 200 +entries and ordered most recently used first. `wait_seconds` is bounded server-side to +`[0, AGENTA_SESSIONS_CONTROL_POLL_HOLD_SECONDS]`, default 25. `limit` is bounded to `[1, 50]`, +default 10. + +Claim response, 200: + +```json +{ + "count": 1, + "commands": [ + { + "id": "0199a3f2-0000-7000-8000-000000000001", + "project_id": "1f0a4b2c-0000-4000-8000-000000000002", + "session_id": "sess-42", + "kind": "cancel", + "target": { + "turn_id": "0199a3f1-0000-7000-8000-00000000000a", + "expected_turn_id": "0199a3f1-0000-7000-8000-00000000000a" + }, + "delivery": { + "claimed_by": "runner-7f3c", + "claim_expires_at": "2026-09-02T22:10:31Z", + "attempt": 1 + }, + "created_at": "2026-09-02T22:09:01Z" + } + ] +} +``` + +`count` plus a list is the house response envelope (`SessionsResponse`, +`api/oss/src/apis/fastapi/sessions/models.py:105`). A `cancel` carries no `input` and no `policy`; +both appear only for the kinds that have them, so a reader never has to interpret an empty object. +`created_at` is on the envelope because the runner needs it for guard 3 of section 4. + +Claim response, 204: the hold expired with nothing to deliver. No body. + +Outcome request: + +```json +{ + "replica_id": "runner-7f3c", + "result": "applied", + "execution": { + "id": "0199a3f1-0000-7000-8000-00000000000a", + "state": "stopped" + } +} +``` + +`result` is the command's terminal state, `applied` or `obsolete`. `execution.state` is one of +`stopped`, `failed`, `not_running`, `superseded_by_newer_turn`. `execution.error` is a short string, present only +when the state is `failed`. The two objects are separate because they answer different questions and +have different owners: `result` is delivery bookkeeping the runner controls, `execution` is a +product fact the user sees. + +Outcome response, 200: + +```json +{ + "command": { + "id": "0199a3f2-0000-7000-8000-000000000001", + "state": "applied", + "outcome": "stopped", + "settled_at": "2026-09-02T22:09:12Z" + } +} +``` + +Outcome response, 409: the claim was not held by this replica. The body carries the same `command` +object with its stored state, so the runner can stop and move on rather than retry. + +### How the hold works + +The route subscribes to one Redis Pub/Sub channel per declared session on the durable plane, then +loops: + +1. Claim once, without waiting. Return 200 if anything came back. +2. Wait on the subscription with a one second timeout, so the loop can re-check the shutdown flag. +3. On a message, or every second, try the claim again. +4. When the hold budget runs out, return 204. + +Three details are not optional: + +- **Add `control_channel(project_id, session_id)` to the Redis contract** + (`api/oss/src/dbs/redis/sessions/contract.py`), with the payload `{"type": "command-pending"}` and + nothing else. It is project-scoped like every other key in that file, and it carries no tenant data + because the claim re-queries Postgres, which is the authority. +- **Reuse the watch endpoint's shutdown release.** `api/oss/src/apis/fastapi/sessions/watch.py:50` + installs a hook on uvicorn's exit path because a held response blocks graceful shutdown for ever. + A held claim has exactly the same failure. Import `request_shutdown` and the same threading event, + or move both into a small shared helper. +- **A new session mid-hold ends the hold.** When the runner starts holding a session that was not in + the declared set, the loop aborts its in-flight request locally and re-issues the claim with the + new set. That is one in-process event, not a server concern. + +### What the runner does + +| Result | What the runner does | +|---|---| +| 200 with commands | Apply each through `applyCommand`, report each outcome, then claim again at once | +| 204 | Claim again at once | +| Read timeout with no response | Claim again after the backoff floor | +| Network error, 502, 503, 504 | Back off: 1 s, 2 s, 4 s, 8 s, 16 s, then 30 s, with 20 percent jitter. Reset on the first success | +| 401 or 403 | Log once at error level and retry every 60 s. This is a deployment misconfiguration and must be loud, not a tight loop | +| 429 | Back off as for a network error | +| API restart | The held connection closes. This is the network error case. Nothing is lost, and the next claim is a fresh query over durable state, not a resumed cursor | +| Empty session set | Do not call. Wait for the next session to be held | + +The client timeout must exceed the hold: set the fetch timeout to `hold_seconds + 10`. + +### After a reconnect, the runner asks again; it never resumes a position + +This is worth stating on its own, because getting it wrong loses commands silently. + +A claim is a **query over durable state**. The runner sends the sessions it currently holds and the +API answers with whatever is pending for them at that moment. There is no cursor, no offset, no +sequence number, no resume token and no server-side per-runner queue position. + +So after any break, whether the connection dropped, the API replica restarted, the runner process +restarted, or the loop was switched off and on, the runner simply issues the next claim with its +current session set. A command created while nothing was listening is `pending` in Postgres, and the +next claim returns it like any other. Nothing has to be replayed, and nothing can be skipped by +starting from the wrong place, because there is no place to start from. + +The one thing this requires: the session set must be rebuilt from what the process actually holds, +not cached from before the break. After a runner restart the set comes from the rebuilt pool and the +live execution registry, both of which reflect reality rather than history. + +--- + +## 6. The heartbeat fallback + +One field is added to the heartbeat response DTO `SessionHeartbeatResult` +(`api/oss/src/core/sessions/streams/dtos.py:180`): + +```python +class SessionHeartbeatResult(BaseModel): + stream: Optional[SessionStream] = None + replica_id: str + is_current_turn: bool = True + # Commands for THIS session, claimed by this beat under the same compare-and-set the + # claim route uses. Empty when there is nothing to deliver, which is the normal case. + commands: List[SessionCommandEnvelope] = Field(default_factory=list) +``` + +`SessionCommandEnvelope` is the same model the claim route returns, so the runner has one parser and +one applier. + +Rules: + +- The beat serves only commands for its own `(project_id, session_id)`, and only those whose + `target.turn_id` matches the beat's `turn_id` or is null. It never serves another session's + commands, because the beat is authenticated with the run's project-scoped credential. +- It claims them under the same statement as the claim route, so a command cannot be delivered by + both paths at once. One of the two wins the compare-and-set; the other sees zero rows. +- The runner deduplicates by `command_id` in the set described in section 4, so a command delivered + by the claim route and offered again by a beat is acknowledged again but applied once. + +**Know what this fallback cannot do.** It covers only a session with a live turn, because the +heartbeat stops when a turn ends or parks (`services/runner/src/server.ts:618` and +`services/runner/src/sessions/alive.ts:241`, verified). It is not a substitute for the session-scoped +loop, and it must not be treated as the delivery path for a parked session. It exists for two cases: +the primary adapter is switched off, and the primary adapter is failing while the run's own +heartbeat still works. + +The runner reads the new field in `sendHeartbeat` (`services/runner/src/sessions/alive.ts:96`) and +hands each entry to `applyCommand`. The existing fail-open rule at `alive.ts:92` is unchanged: a +non-2xx beat returns nothing. That is one more reason the primary path does not depend on a run's +credential. + +--- + +## 7. Stop, end to end + +### Case 1: the normal Stop + +1. The browser posts `POST /sessions/{session_id}/cancel` with `expected_execution_id` filled in + from its own state, and an optional `Idempotency-Key` header. It marks its own view "stopping" + and stops rendering. It does not abort anything server-side by itself. +2. The API authorizes the caller with `Permission.RUN_SESSIONS`, the same permission the current + cancel path uses (`api/oss/src/apis/fastapi/sessions/router.py:377`). +3. The API resolves the target once. It stamps `received_at` first, then reads + `get_running_owner`, falling back to `get_alive_owner`, both already imported by the streams + service (`api/oss/src/core/sessions/streams/service.py:39`), and reads the session row for + `turn_started_at`. Call the result `turn_id`. Three outcomes: if `expected_execution_id` was sent + and differs, stop with 409; if no expected id was sent and `turn_started_at > received_at`, stop + with a settled `superseded_by_newer_turn` command and 200 (section 4); otherwise continue. +4. **One transaction.** Insert the command with `state='pending'`, `kind='cancel'`, + `target_turn_id=turn_id`, `expected_turn_id=`, and set + `session_streams.stopping_turn_id = turn_id` on the same session's row. The DAO method takes an + optional `AsyncSession` so both writes share one session, the pattern `RecordsDAO.append` already + uses (`api/oss/src/dbs/postgres/sessions/records/dao.py:33`). +5. **Redis is not touched.** No tombstone, no `force_cancel_alive`, no `clear_running`. The current + execution keeps `alive` and `running` while it stops, which is what stops a second message from + starting underneath it. This is decision D-017. +6. The API delivers through the configured adapter: the direct call posts to the runner (section 9), + the long-poll adapter publishes on the session's control channel. Either way the API then returns + 202 with the command id and the target execution id. **Delivery failure does not fail the + request**, because the command is already durable. +7. The runner receives the command, on its held claim or on the direct route. +8. `applyCommand` checks the deduplication set, checks that it holds an execution with + `target.turn_id`, checks that the execution did not start after `created_at`, and then aborts it. + The abort must be a harness cancel that keeps the sandbox and the native harness session warm. + **This step is Work package A's deliverable, and it is not free today.** `shouldPark` returns + false whenever the signal is aborted (`services/runner/src/engines/sandbox_agent/engine.ts:26`, + verified), so the environment is destroyed. The review's proposed fix, which this design assumes: + thread a cancel reason to the runner so a user Stop is distinguishable from a disconnect abort, + and let `shouldPark` park when the result is a clean cancellation caused by a user Stop. Nothing + in this design can deliver a warm Stop without that change. +9. The runner posts `POST /sessions/control/commands/{command_id}/outcome` with + `result: "applied"` and `execution: {"id": turn_id, "state": "stopped"}`. +10. The API settles both, in one transaction: + - Command: `state='applied'`, `outcome='stopped'`, `settled_at=now()`, guarded on + `state='claimed' AND claimed_by=`. + - Stream row: clear `stopping_turn_id`. +11. The API releases ownership, in this order: + - `mark_turn_superseded(turn_id)`, so a late beat from the stopped execution cannot re-arm the + locks. + - `release_running(turn_id)`, owner-checked, so it can only release its own execution's key. + - **`alive` is left alone.** It expires on its own time to live, exactly as it does at the end + of a normal turn (`api/oss/src/core/sessions/streams/service.py:590`, verified). This is the + deliberate difference from today's cancel, which force-deletes `alive` and is a large part of + why Stop currently reads as a session teardown. Warm resume is the required outcome, so Stop + must leave the session in the state a finished turn leaves it in. +12. The API cancels the stopped execution's pending interactions, the same call the kill route + already makes (`api/oss/src/apis/fastapi/sessions/router.py:441`), scoped with `only_turn_id` so + it touches only this execution's gates. +13. The API publishes the existing watch notification `lifecycle: ended` on the session channel + (`api/oss/src/core/sessions/streams/service.py:202`), which every open browser already listens + to. +14. Browsers refetch through their current query paths and show the turn as stopped. + +Steps 1 to 8 are the five second budget. Steps 9 to 14 follow the runner's own cancel time. + +### Case 2: Stop when nothing runs + +At step 3 there is no running owner and no alive owner. + +- If the caller sent no `expected_execution_id`: the API inserts the command already settled, + `state='obsolete'`, `outcome='not_running'`, `settled_at=now()`, and returns 200. No Redis write, + no delivery. The caller gets a stable command id, so a retry with the same idempotency key returns + the same record. +- The stream row is not touched, because nothing is stopping. + +### Case 3: Stop with a stale `expected_execution_id` + +The caller sent an execution id that is not the current running owner. The API returns 409 with a +body naming the current execution id, or null when nothing runs. Nothing is inserted and nothing is +delivered. The browser learns that the run it was looking at already ended and refreshes. + +### Case 4: Stop while an interaction is pending and the sandbox is parked + +This is the case with no channel today. A parked approval means the runner is running no turn: the +coordinator seats the environment as `awaiting_approval` +(`services/runner/src/lifecycle/session-coordinator.ts:764`, verified) and the request handler's +`finally` has already released the alive watchdog (`services/runner/src/server.ts:618`, verified), +so the heartbeat has stopped. Redis holds `alive` but not `running`, because the last beat carried +`is_running: false` (`api/oss/src/core/sessions/streams/service.py:590`, verified). + +1. Step 3 finds no `running` owner and does find an `alive` owner. `target_turn_id` takes the alive + owner's value. +2. The command is created `pending` and delivered. **The session is in the runner's declared set**, + because the parked pool entry is one of `SessionPool.keys()`, so the held claim delivers it. With + the direct adapter the process is reachable regardless. +3. `applyCommand` finds no live execution for that turn. It resolves the parked entry instead, + settles the command `applied` with `execution.state = "not_running"`, and leaves the parked + environment in the pool so the session stays warm. It does not destroy the park: Stop ends the + work, not the session. +4. The API settles as in case 1. **Step 12 is the visible part here:** the pending interaction is + cancelled, so the approval card stops rendering as actionable. That closes the class of bugs where + an approval survives a Stop and its buttons do nothing. + +### Case 5: two Stops in a row + +The second request finds an open command for the same `(project_id, session_id, target_turn_id)` +and returns it unchanged, with the same command id. If the second request carries a different +`Idempotency-Key`, the open-command collapse still wins, because it runs before the insert. If the +first command has already settled and a new execution has started, the second Stop is a fresh +command against the new execution, which is what the user meant. + +### Case 6: a Stop that arrives after its turn ended + +The user presses Stop at t=0 while turn one runs. Turn one ends at t=0.1, turn two starts at t=0.2, +and the request is applied at t=0.3. Today `_displace_turns` would tombstone turn two before its +first output, and that tombstone lasts an hour because every read refreshes it +(`api/oss/src/dbs/redis/sessions/locks.py:147`, verified). The four guards of section 4 answer this +case in order. + +1. **Guard 1, at admission.** The API compares `received_at` with the row's `turn_started_at`. Turn + two started after the request arrived, so the API inserts a command that is already settled, + `state='obsolete'` with `outcome='superseded_by_newer_turn'`, targets nothing, touches no Redis + key, and returns 200 with `execution.state = "idle"`. **Turn two never hears about it.** This is + the guard that closes the case; the rest are for what it cannot see. +2. **Guard 2** covers the ordinary late Stop, where turn one simply ended and nothing replaced it. + The command names a turn that no longer exists, so the runner settles `obsolete` with + `not_running`. +3. **Guard 3** covers the residual window where turn two took over between the API's Redis read and + its insert, or where `turn_started_at` was null and guard 1 could not fire. The runner sees an + execution that started after the command's arrival time and settles `obsolete` with + `superseded_by_newer_turn` rather than aborting it. This check is exact, because the runner reads + its own memory. +4. **Guard 4** removes the whole class for first-party clients, which send `expected_execution_id` + and get a 409 naming the current execution. + +No guard writes a Redis tombstone, so nothing can be killed for an hour the way `_displace_turns` +can today. + +### Case 7: the runner is gone + +No claim arrives, or the command was claimed and never settled. The sweep applies the table in +section 4, keyed off heartbeat age rather than the 3600 second Redis time to live. It settles the +command `obsolete` with `outcome='lost'`, force-clears the Redis keys, cancels the pending +interactions, and publishes `ended`. The user sees a terminal state within about two minutes instead +of an hour of "stopping". + +--- + +## 8. The control-delivery port + +There are two ports, one on each side. They are named separately because they are implemented in +different languages by different components, and only one of them is the RFC's `deliver / +acknowledge / recover`. + +### API side, Python + +`api/oss/src/core/sessions/commands/interfaces.py`: + +```python +class ControlDeliveryPort(ABC): + """How the API reaches the runner that holds a session. Transport only. + + Durability, authorization, idempotency, the state machine, and terminal settlement + live in SessionCommandsService and must not move into an adapter. + """ + + @abstractmethod + async def deliver(self, *, command: SessionCommand) -> DeliveryReceipt: + """Make `command` reachable by whoever holds its session, promptly. + + Best effort: a failure here never fails admission, because the command is already + durable and both the sweep and the fallback recover it. The receipt says only what + the transport learned, never what happened to the execution. + """ + ... + + @abstractmethod + async def acknowledge(self, *, command_id: UUID, replica_id: str) -> None: + """Record that a replica took the command, for adapters that keep their own + delivery bookkeeping.""" + ... + + @abstractmethod + async def recover( + self, *, sessions: List[SessionScope], limit: int + ) -> List[SessionCommand]: + """Open commands for these sessions. The claim route, the direct-call retry and the + heartbeat fallback all go through this.""" + ... +``` + +```python +class DeliveryReceipt(BaseModel): + # What the transport learned. Not an execution outcome. + status: Literal["accepted", "unreachable", "not_held"] +``` + +`accepted` means a runner took the command and will report. `unreachable` means the transport +failed, so the sweep or a later claim will handle it. `not_held` means a reachable runner said it +does not hold that session, which lets the service settle the command at once instead of waiting for +the deadline. + +A later adapter must provide prompt, at-least-once delivery to whoever holds the named session. It +may reorder. It may deliver twice. It must not transform or interpret a command, must not settle +one, and must not be the only record that a command exists. Replacing it must change no route, no +DTO, and no state transition. + +### Runner side, TypeScript + +`services/runner/src/sessions/control-channel.ts`: + +```ts +/** One command as the API delivers it. The same shape arrives on every transport. */ +export interface ControlCommand { + id: string; + projectId: string; + sessionId: string; + kind: "cancel"; + target: { turnId: string | null; expectedTurnId: string | null }; + createdAt: string; +} + +export interface ControlOutcome { + /** The command's terminal state. */ + result: "applied" | "obsolete"; + execution: { + id: string | null; + state: "stopped" | "failed" | "not_running" | "superseded_by_newer_turn"; + error?: string; + }; +} + +/** The transport. `control-poll.ts` implements it over long polling; the direct route + * in `server.ts` feeds the same applier without implementing this at all. */ +export interface ControlChannel { + /** Block until a command arrives for one of `sessions`, or the hold expires. */ + receive(sessions: SessionScope[], signal: AbortSignal): Promise; + settle(command: ControlCommand, outcome: ControlOutcome): Promise; +} +``` + +`applyCommand(command)` sits above the channel, not inside it, so every path shares one applier, one +set of guards and one deduplication set. + +The runner also needs an execution registry, because the abort controller is a local variable inside +`runAndStreamWithApiBaseResolved` today (`services/runner/src/server.ts:450`, verified). Add a +module-level map from `${projectId}:${sessionId}` to `{ turnId, startedAt, abort(): void }`, +registered when the run starts and removed in the same `finally` that releases the watchdog +(`services/runner/src/server.ts:618`). `startedAt` is what guard 3 of section 4 compares. This +mirrors `inFlightSandboxes` (`services/runner/src/engines/sandbox_agent/environment.ts:239`). + +--- + +## 9. The direct-call adapter as an alternative first adapter + +This is the section the architecture review asked for as 8b. It sits here, directly after the port, +because that is what it is: the second adapter behind the same port, and a candidate for being the +**first** one built. + +The product review argues that with one runner, the authenticated API-to-runner hop that already +carries hard kill can carry Cancel today, and that long polling is machinery for a second runner that +does not exist. The RFC's own text agrees that direct managed-runner routing is a legitimate adapter +behind the port (`rfc.md`, "Control delivery must sit behind an internal port"). That argument is +correct on its own terms, and this design makes both adapters cheap so Mahmoud can pick either in +the morning without changing anything else. + +### What already exists + +- **The API side.** `kill_runner_sandbox` posts `{sessionId, projectId}` with + `Authorization: Bearer ` to `env.runner.internal_url` and swallows every + failure (`api/oss/src/core/sessions/streams/runner_client.py:30`, verified). It is 33 lines. +- **The runner side.** `POST /kill` sits behind the same token gate, reads a capped body, resolves + the pool scope and tears the session down (`services/runner/src/server.ts:704`, verified). + +### What the direct adapter adds + +**The runner: `POST /cancel`, beside `/kill`.** Same token gate, same capped body reader, same +scoping rule. Body: + +```json +{ + "commandId": "0199a3f2-0000-7000-8000-000000000001", + "projectId": "1f0a4b2c-0000-4000-8000-000000000002", + "sessionId": "sess-42", + "targetTurnId": "0199a3f1-0000-7000-8000-00000000000a", + "createdAt": "2026-09-02T22:09:01Z" +} +``` + +It builds a `ControlCommand` from that body and hands it to the same `applyCommand`. It answers 202 +when it holds the session and has accepted the command, and 404 when it does not. It does **not** +return the execution outcome: the runner reports that through the settle route, so settlement has +one path on every transport. Roughly 40 lines beside the existing kill branch. + +**The API: `cancel_runner_execution`, beside `kill_runner_sandbox`.** The same 30 lines with a +different path and body. The adapter maps the response: 202 to `accepted`, 404 to `not_held`, +anything else and every exception to `unreachable`. One file, +`api/oss/src/dbs/http/sessions/control_delivery_direct.py`, implementing `ControlDeliveryPort`. +`acknowledge` is a no-op, because the claim compare-and-set is the acknowledgement. `recover` runs +the same query the claim route runs, and the service calls it from the sweep. + +**The durable command is still inserted first.** The order is not negotiable and it is the whole +difference between this adapter and a bare remote call: + +1. Admit and insert the command, with `stopping_turn_id`, in one transaction. Commit. +2. Only then call the runner. +3. Whatever the call returns, the user's request has already succeeded. A `not_held` lets the + service settle at once; an `unreachable` leaves the command `pending` for the sweep or for a + later retry. **Neither changes the 202.** + +Inverting those two steps, calling first and recording afterwards, would give back every failure the +record exists to close, because a crash between the call and the insert leaves an aborted execution +with no terminal outcome written anywhere. + +### What it cannot do + +- **Reach a session it cannot resolve locally.** A Stop against a parked approval has no entry in the + execution registry, because no turn is running. The runner must fall back to the keep-alive pool, + which already has the lookup for exactly this: `SessionPool.awaitingApproval(sessionId)` + (`services/runner/src/engines/sandbox_agent/session-pool.ts:117`, verified). That is a few lines, + but it is not free, and it is needed by both adapters. Do not treat the parked case as covered + just because the process is reachable. +- **Survive a second runner replica.** `env.runner.internal_url` is one service address + (`api/oss/src/core/sessions/streams/runner_client.py:44`, verified). Behind a load balancer the + call lands on whichever replica answers, which is the right one only by luck. +- **Reach a user-operated runner.** It needs inbound reachability from the API to the runner. A + runner behind a firewall cannot be called at all. The RFC treats that deployment as a + consideration rather than a requirement, so this is a real but not yet binding limit. + +### Making the wrong-replica failure loud + +The silent-failure worry is fair, and there are two ways to close it. Build the first; the second is +optional. + +**Primary, and exact: treat a contradictory `not_held` as an error.** A mis-routed call is not +actually silent at the protocol level. The runner answers 404 `not_held` when it does not hold the +session, so the API always learns that delivery did not land. What makes it dangerous is that +`not_held` is also the **legitimate** answer when the session really has ended, so the two cases look +alike. They are easy to tell apart with data the API already has: + +> A `not_held` for a session whose `session_streams` row says `is_alive` **and** whose heartbeat age +> is under one interval means some process is running that session and it is not the one we just +> called. That is the wrong-replica failure, and nothing else produces it. + +On that condition, log at error level with the session id, the target turn id and the replica id +from the Redis `owner` key, count it on a metric, and settle the command `obsolete` with +`outcome='lost'` rather than `not_running`, so the user is told the Stop failed instead of being +told the work had already finished. This needs no new storage and no census. + +**Optional, preventive: refuse the configuration.** Two parts, both cheap: + +- A required flag. The direct adapter refuses to start unless + `AGENTA_SESSIONS_CONTROL_DIRECT_SINGLE_REPLICA=true` is set, so choosing it is a deliberate + statement about the deployment rather than a default someone inherited. Optionally let the operator + name the replica instead, `AGENTA_SESSIONS_CONTROL_DIRECT_REPLICA_ID=`, and refuse delivery + when the session's owner key names a different one. +- A replica census. The heartbeat handler already computes the owning `replica_id` on every beat + (`api/oss/src/core/sessions/streams/service.py:458`). Have it also run one `ZADD` into a sorted set + keyed by replica id and scored by timestamp. The sweep then reads `ZCOUNT` over the last 10 + minutes and, if the direct adapter is configured and the count exceeds one, logs an error every + pass naming the replicas it saw. One write per beat, one read per sweep, no key scan. + +Do not add a retry across the load balancer in the hope of hitting the right process. It converts a +diagnosable failure into a lottery, and it multiplies load exactly when a deployment is already +misconfigured. + +### What the durable command record adds beyond a bare direct call + +The direct call alone would be an HTTP request with no memory. The record buys four things, and each +one is a bug the current system has: + +1. **Recovery.** The runner can be restarting, deploying, or briefly unreachable. A bare call fails + and the Stop is gone; the user pressed a button and nothing happened. With the record the command + survives, the sweep settles it as `lost` with a terminal outcome the user sees, and a returning + runner picks it up on its next claim. +2. **Idempotency.** Two Stops, a retried request, or a browser that resends on reconnect all collapse + onto one command. A bare call would abort twice, and the second abort can land on a newer turn. + That is review hole H-3 in its cheapest form. +3. **One terminal outcome per execution.** The record is where `stopped`, `not_running`, + `superseded_by_newer_turn`, `failed` and `lost` are written down, and where the watchdog and the runner agree + on who wrote it. A bare call has nowhere to record that the execution really ended. +4. **Audit and the next command kinds.** Who stopped what, when, and what happened. Steer and Queue + need exactly this record, so building it now is not speculative: it is the part of version one + that version two does not have to redo. + +The honest counter-argument, stated plainly: for a single Stop that succeeds on the first try, the +record adds a table and two writes and changes nothing the user sees. Its value is entirely in the +failure cases. + +### Choosing the adapter + +One setting, `AGENTA_SESSIONS_CONTROL_ADAPTER`, with values `direct` and `long_poll`, read through +`env`. The service depends only on the port. Neither adapter changes a route, a DTO, or a state +transition. + +| | Direct call | Long poll | +|---|---|---| +| New code | One runner route, one API client, both small | A runner loop, an API route with a hold, a Redis channel | +| Reaches a parked session | Yes, with the pool lookup above | Yes, the parked session is in the declared set | +| Two or more runner replicas | Wrong process gets the call. Loud with the `not_held` rule above, silent without it | Correct, because the runner declares what it holds | +| Runner behind a firewall | Impossible | Works | +| Runner restarting | The call fails, the sweep settles or a later claim delivers | The claim resumes on reconnect | +| Held connections | None | One per runner process | + +**If `direct` is the default, PR 3b in section 10 is deferred** and the session-scoped loop is not +built at all. H-2 is then closed by the direct route plus the pool lookup rather than by the loop, +and the heartbeat fallback stays as the second path for a session with a live turn. Everything else +in this design is unchanged, which is the point of the port. + +### Recommendation + +**Build the direct adapter first.** Three reasons, in order of weight: + +1. **It removes the largest piece of new machinery from the first release.** No held connection, no + poll loop, no per-session Redis channel, no uvicorn shutdown interaction. The parts that carry the + correctness, the record, the state machine, the guards and the settlement rule, are identical + either way, and they are the parts worth reviewing carefully. +2. **The deployment it fails on does not exist yet.** Agenta runs one runner. The failure mode is + real, and the `not_held` rule above makes it loud rather than silent, which is what turns a + dangerous limitation into a known one. +3. **The port makes the switch small.** Long polling stays one file plus one runner module. When a + second replica or a user-operated runner becomes real, the change is a configuration value and a + module, not a redesign. + +The cost of being wrong is bounded and visible: if a second replica appears before the long-poll +adapter is built, Stop starts failing loudly on the wrong-replica condition and the fix is already +designed. The cost of building long polling first is a larger first release for a deployment that +does not exist. Take the smaller one. + +--- + +## 10. Migration sequence + +Eight pull requests. Each names the files it touches so parallel agents do not collide. Ordering +constraints are stated; anything not constrained can go in any order. + +| PR | Title | Files | Depends on | +|---|---|---|---| +| 1 | Add the session command record | `api/oss/databases/postgres/migrations/core_oss/versions/oss000000022_add_session_commands.py`, `api/oss/src/dbs/postgres/sessions/commands/{dbas,dbes,dao,mappings}.py`, `api/oss/src/core/sessions/commands/{dtos,interfaces,service,types}.py`, `api/oss/src/utils/env.py`, `api/entrypoints/routers.py` (wiring only), `api/oss/tests/pytest/unit/sessions/test_session_commands_dao.py` | none | +| 2 | Runner execution registry and applier | `services/runner/src/sessions/control-channel.ts`, `services/runner/src/sessions/execution-registry.ts`, `services/runner/src/sessions/applied-commands.ts`, `services/runner/src/server.ts` (register and unregister), runner unit tests | none | +| 3a | Direct-call adapter | `services/runner/src/server.ts` (the `/cancel` route and the parked-pool lookup), `api/oss/src/dbs/http/sessions/control_delivery_direct.py` (including the wrong-replica detector of section 9) | 1, 2 | +| 3b | Long-poll adapter | `api/oss/src/apis/fastapi/sessions/router.py` (`SessionControlRouter`), `api/oss/src/apis/fastapi/sessions/models.py`, `api/oss/src/middlewares/auth.py` (one prefix), `api/oss/src/dbs/redis/sessions/contract.py`, `api/oss/src/dbs/redis/sessions/control_delivery.py`, `services/runner/src/sessions/control-poll.ts` | 1, 2 | +| 4 | Public Cancel creates a command | `api/oss/src/apis/fastapi/sessions/router.py`, `api/oss/src/apis/fastapi/sessions/models.py`, `api/oss/src/core/sessions/commands/service.py`, migration `oss000000023` for `session_streams.stopping_turn_id` **and** `session_streams.turn_started_at`, `api/oss/src/dbs/postgres/sessions/streams/{dbas,dbes,dao}.py` (the `CASE` that stamps the start time), `api/oss/src/core/sessions/streams/service.py` (`_start_turn` and the heartbeat stamp) | 1 | +| 5 | Heartbeat command discovery | `api/oss/src/core/sessions/streams/{dtos,service}.py`, `services/runner/src/sessions/alive.ts` | 3a or 3b, and 4 | +| 6 | Command settlement in the watchdog | `api/oss/src/tasks/asyncio/sessions/command_sweep.py` or the equivalent file on `feat/session-execution-watchdog`, `api/entrypoints/routers.py` (lifespan) | 1, and agreement with the watchdog author | +| 7 | Point the clients at the command | `web/packages/agenta-entities/src/session/api/api.ts`, `web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts` (send `expected_execution_id`), `web/mobile/src/features/chat/StopButton.tsx`, `api/oss/src/core/sessions/streams/service.py` (the cancel branch becomes a wrapper) | 4, 5 | + +3a and 3b are alternatives, not a sequence. Build whichever Mahmoud picks; the other becomes optional +later work. + +Conflict notes: + +- PRs 1, 3b, 4 and 6 touch `api/entrypoints/routers.py`. Keep each edit to its own block and land + them in order. +- PRs 3b and 4 both touch `router.py` and `models.py`. Land 3b first; 4 adds a separate router class. +- PR 2 must land before 3a or 3b, because both need the registry and the applier. +- PRs 1 and 4 each add a migration and must not both claim `oss000000022`. +- PR 6 must be agreed with the agent on `feat/session-execution-watchdog` before either lands. Two + independent writers of an execution's terminal outcome is a worse bug than the one being fixed. +- **Work package A's `shouldPark` change is a hard dependency of the user-visible result.** Landing + PRs 1 to 7 without it gives a fast Stop that still destroys the sandbox. + +**Keeping the current Stop working.** `POST /sessions/streams/` with no inputs and no `force` keeps +its exact current behavior through PRs 1 to 6. Nothing about `CommandMode.cancel` changes. Released +browsers and the current mobile build keep working unchanged. + +**When it becomes a wrapper.** In PR 7. At that point `SessionStreamsService.command`'s cancel +branch (`api/oss/src/core/sessions/streams/service.py:288`) stops calling `_displace_turns` and +instead calls `SessionCommandsService.request_cancel(...)` with no expected execution id, then +returns the same `SessionStreamCommandResponse` shape it returns today. That gives every old client +the new behavior with no client change, and it is also the point at which the old teardown of +`alive` and the hour-long tombstone disappear. Do it in the same PR that flips the browser, so one +revert restores one consistent behavior. + +--- + +## 11. Test plan + +### Unit tests + +| Component | Test | Passes when | +|---|---|---| +| Commands DAO | Two concurrent claims of one pending command | Exactly one returns a row; the other returns none | +| Commands DAO | Insert with a repeated `Idempotency-Key` | The second insert returns the first row, and one row exists | +| Commands DAO | Settle with the wrong `replica_id` | Returns no row; the stored state is unchanged | +| Commands DAO | Settle a command that is already `applied` | Returns no row; the caller reads the terminal state | +| Commands DAO | Claim with a session set that excludes the command's session | Returns nothing | +| Commands service | Admission with a stale `expected_execution_id` | Raises the conflict type; no row inserted | +| Commands service | Admission with nothing running or parked | One row, `state='obsolete'`, `outcome='not_running'` | +| Commands service | Admission when `turn_started_at` is later than `received_at` | One row, `state='obsolete'`, `outcome='superseded_by_newer_turn'`, `target_turn_id` null, no Redis write | +| Commands service | Admission when `turn_started_at` is null | The guard does not fire; the command targets the current turn | +| Commands service | Admission when `turn_started_at` is earlier than `received_at` | Normal admission, `state='pending'` | +| Commands service | The stored `created_at` equals the `received_at` that was compared | The two values match exactly, not merely closely | +| Streams DAO | The same `turn_id` stamped by ten heartbeats | `turn_started_at` is written once and never moves | +| Streams DAO | A new `turn_id` stamped over an old one | `turn_started_at` moves to the new turn's time | +| Commands service | Admission twice with no idempotency key | One row; the second call returns the first | +| Commands service | Admission writes the command and `stopping_turn_id` | Both are visible after one commit, neither after a rollback | +| Command sweep | Claim expired, session beating, attempts left | Back to `pending` | +| Command sweep | Claim expired, session silent for 90 s | `obsolete`, `outcome='lost'`, keys force-cleared, `ended` published | +| Command sweep | Claim expired, session parked with an open interaction | Not settled as lost; the admission deadline applies instead | +| Command sweep | Redis `alive` still holds its 3600 s value | Settlement still happens, because the rule reads heartbeat age, not the key | +| Direct adapter | Runner answers 404 for a session whose row is not alive | Receipt is `not_held`; the command settles `obsolete` with `not_running` | +| Direct adapter | Runner answers 404 for a session that is alive and beating | Logged at error level, counted, and settled `obsolete` with `lost`, never `not_running` | +| Direct adapter | Runner unreachable | Receipt is `unreachable`; admission still succeeded and returned 202 | +| Direct adapter | The command row exists before the runner is called | A crash injected between the two leaves a `pending` command, never an aborted execution with no record | +| Long-poll adapter | `deliver` when Redis is down | Admission still succeeds; the failure is logged, not raised | +| Runner claim loop | 204, then 200, then a network error | Immediate re-claim, apply, then the backoff sequence with jitter | +| Runner claim loop | 401 | One error log, then a 60 second retry, no tight loop | +| Runner claim loop | Session set includes a parked pool entry | The parked session appears in the request body | +| Runner applier | A command for a `turnId` this process does not hold | Settles `obsolete` with `not_running`; nothing is aborted | +| Runner applier | The held execution started after the command's `created_at` | Settles `obsolete` with `superseded_by_newer_turn`; nothing is aborted | +| Runner applier | The same `command_id` delivered twice | Aborted once, acknowledged twice | +| Runner applier | The deduplication set survives a loop restart | A command applied before the restart is not applied again | +| Runner registry | The run's `finally` runs | The entry is removed even when the run threw | + +The runner suite is `cd services/runner && pnpm test` (vitest). The API unit tests sit under +`api/oss/tests/pytest/unit/sessions/`, next to `test_command_matrix_inputs_data.py`. + +### One API integration test + +`api/oss/tests/pytest/integration/sessions/test_stop_command_delivery.py`, against a real Postgres +and a real Redis, with a fake runner: + +1. Establish a session with `alive` and `running` held by `turn-A`, exactly as a heartbeat does. +2. Call the public Cancel route with `expected_execution_id = 'turn-A'`. Assert 202, one `pending` + row, and `session_streams.stopping_turn_id = 'turn-A'`. +3. Call the claim route as `replica-1`, declaring that session. Assert 200, one command, + `state='claimed'`. +4. Call the claim route again. Assert 204 within the hold. +5. Post the outcome with `result='applied'` and `execution.state='stopped'`. Assert 200. +6. Assert: the command is `applied` with `outcome='stopped'`; `stopping_turn_id` is null; the Redis + `running` key is gone; **the Redis `alive` key is still present**; `superseded:...:turn-A` exists; + the session's pending interactions are cancelled; one `lifecycle: ended` message was published on + the session watch channel. + +Step 6's `alive` assertion is the one that pins warm resume at the API layer. If a later change +starts clearing `alive` on Stop, this test fails. + +Add a second integration case for the parked path: park the session (no `running`, `alive` held, one +pending interaction), Stop it, and assert the command is delivered, the interaction is cancelled, and +`alive` still holds. + +### One live-stack wire test + +Add a cell to the agent release gate, next to the existing W5 steer cell +(`.agents/skills/agent-release-gate/resources/`), driving a deployed stack over the product +endpoints only: + +1. Start a turn with a prompt that runs for at least 60 seconds. +2. Wait for the first agent output frame, then record the wall clock and press Stop through + `POST /sessions/{id}/cancel`. +3. **Pass criterion one:** the runner reports the outcome, and the session's `running` flag goes + false, within **5 seconds** of the Stop request. Measure from the request, not from the frame. +4. **Pass criterion two:** `session_turns` for the stopped turn still names the same `sandbox_id` + and `agent_session_id` as before the Stop, and the session's `alive` flag is still true. +5. Send a second message on the same session. +6. **Pass criterion three:** the second turn reuses the same `sandbox_id` and `agent_session_id`. + That is warm resume, measured from stored rows rather than from timing. +7. **Pass criterion four:** the stopped turn's records end with a cancelled outcome, not an error + record. + +A second cell for the parked path: run a prompt that triggers an approval, wait for the gate, press +Stop, and assert that the outcome lands within 5 seconds, the interaction reads `cancelled`, and the +next message still resumes warm. That cell is the regression test for H-2 and it fails on today's +code for a reason no timing change can fix. + +Criteria 2, 3 and 4 depend on Work package A. Criterion 1 does not, and can be gated as soon as PR 7 +lands. + +--- + +## 12. Rejected alternatives + +**Shorten the heartbeat interval.** Dropping `HEARTBEAT_INTERVAL_SECONDS` from 30 to 2 would cut the +Stop delay with no new machinery. It fails on four counts. It multiplies heartbeat load by fifteen +for every live session, and each beat is a Postgres write plus four Redis operations +(`api/oss/src/core/sessions/streams/service.py:406`). It cannot deliver a Stop to a run whose +credential was dropped, because the beat itself is what fails (`alive.ts:92`). It cannot deliver a +Stop to a parked session at any interval, because the heartbeat has stopped (`server.ts:618`). And it +leaves the control signal encoded as the absence of a lock, which is what makes today's cancel a +session teardown rather than an execution cancel. + +**Route commands by owner replica instead of by declared session.** This was the first revision's +design and it is worse. The Redis `owner` key expires after 120 seconds +(`api/oss/src/dbs/redis/sessions/contract.py:40`), so a parked session's owner can lapse and its +commands become unroutable. It also cannot tell whether the named replica still holds the session, +which is exactly the question delivery needs answered. Letting the runner declare what it holds +turns a guess into a fact, and it removes a column from the durable record. + +**Subscribe the runner to Redis directly.** The runner could subscribe to a per-session Pub/Sub +channel and skip the claim. It is the least code. It fails on the boundary the codebase already +enforces: the API is the single Redis writer and the runner reaches the coordination plane only over +HTTP (`services/runner/src/sessions/alive.ts:13` and `sessions/contract.ts:25`, both explicit about +this). Handing the runner Redis credentials reverses a deliberate decision, and Pub/Sub has no +replay, so a disconnected runner loses every command sent while it was away. + +**A persistent WebSocket or bidirectional stream.** It removes the repeated request and can carry +richer runner status. It is deferred, not wrong. It needs connection lifecycle handling, ping and +pong, reconnect with backoff, and a message framing contract, none of which the command state +machine needs to be correct. Because delivery sits behind the port in section 8, it becomes a later +adapter rather than a rewrite. + +**Skip the durable record and make Stop a bare direct call.** This is the product review's position +and it is the strongest alternative. Note what is and is not rejected here. The **direct call** is +not rejected at all: it is section 9, it is a first-class adapter behind the port, and it is the +recommended first adapter. What is rejected is dropping the **record**, for the four reasons set out +in section 9: no recovery when the runner is unreachable, no idempotency against a double Stop +landing on a newer turn, no place to write the one terminal outcome the watchdog and the runner must +agree on, and no foundation for Steer and Queue. Insert first, then call. + +--- + +## 13. Open questions for Mahmoud + +1. **Which adapter is the default, `direct` or `long_poll`?** Recommendation: **`direct`** for + version one, with the wrong-replica detector from section 9 built in the same PR. Reason: you run + one runner, the hop is authenticated and in production today, it reaches a parked session once the + pool lookup is added, and it removes a held connection and a poll loop from the first release. The + port keeps long polling one file away for the day a second replica or a user-operated runner is + real. The condition on the recommendation: the detector is not optional, because without it the + two-replica failure is silent, and with it the choice is reversible on a metric rather than on a + bug report. + +2. **Who owns execution settlement, this design or the watchdog branch?** Recommendation: **the + watchdog owns it, and the command rules move into it.** Reason: one execution must reach exactly + one terminal outcome from exactly one writer, and two sweeps racing to write `lost` is a worse + bug than the one being fixed. This needs deciding before PR 6 and before the watchdog branch + lands. + +3. **Does Stop leave the Redis `alive` key in place?** Recommendation: **yes, leave it**, exactly as + a normal turn end does. Reason: force-deleting `alive` is what makes today's cancel read as a + session teardown, and warm resume is the required outcome. This is a deliberate deviation from + the phrase "Redis `running` and `alive` released" in the work package brief, so it needs an + explicit yes or no. + +4. **Do first-party clients always send `expected_execution_id`?** Recommendation: **yes, and treat + an omission as a bug.** Reason: it is the cheapest of the three H-3 guards and the only one that + works before the request reaches the server. The field stays optional in the contract for + external callers, as decision D-010 requires. + +5. **Do we cancel the pending interaction when Stop hits a parked session?** Recommendation: + **yes, cancel it, and keep the parked environment.** Reason: an approval card whose execution was + stopped is exactly the "actionable card whose buttons do nothing" bug, and the kill route already + makes this call (`api/oss/src/apis/fastapi/sessions/router.py:441`). Keeping the environment is + what makes the next message warm, and it is what distinguishes Stop from Delete. diff --git a/docs/design/session-control-and-live-events/status.md b/docs/design/session-control-and-live-events/status.md new file mode 100644 index 00000000000..da73284e795 --- /dev/null +++ b/docs/design/session-control-and-live-events/status.md @@ -0,0 +1,63 @@ +# Status + +> AGENT-GENERATED, low weight. Draft for discussion. Mahmoud makes final decisions. + +## Current state + +- Isolated branch created: `agent/session-execution-rfc`. +- Problem inventory created from 48 open GitHub issues. +- Current Stop, heartbeat, records, and watch paths checked against the repository. +- Confirmed process decisions recorded. +- Proposed architecture choices kept separate from confirmed decisions. +- Living RFC created with empty sections for track-by-track discussion. +- Current command endpoint and runner routing boundary verified. +- Sandbox-agent cancellation investigation promoted to the first parallel task. +- Five seconds recorded as the provisional Stop delivery target. +- Public resource API separated from the proposed internal command transport. +- Current interaction response path documented. +- Public APIs from Gumloop, OpenAI background Responses, and Claude Managed Agents compared. +- Each current operation mapped to its proposed behavior and degree of change. +- Stop and Delete distinction confirmed. +- Optional `expected_execution_id` guard recorded. +- One public session API for first-party and external clients recorded. +- Visible server-side pending inputs added to the interface discussion. +- Queued inputs made immutable. Clients can remove and replace them, but cannot edit or reorder. +- Detailed API mechanics delegated to established conventions unless they affect architecture. +- Durable acceptance defined independently from runner claim and execution start. +- Sender-only visibility explicitly excluded from the target requirements. +- Proposed snapshot and event routes explicitly marked as new contracts, not changed meanings of + current stream routes. +- Side-by-side endpoint migration accepted as the first draft. Final naming deferred. +- Existing record properties, violations, structural constraints, and repair options traced before + selecting a replay storage design. +- Corrected the cursor analysis: plain Postgres sequences do not guarantee commit visibility order. +- Added the repaired-records and separate-event-log options with trade-offs. Redis-only permanent + history excluded from the draft. +- Added a mandatory stable-ID producer spike before immutable record changes. +- Made single active execution and stale-writer fencing explicit requirements. +- Kept the public Stop execution guard optional. +- Added possible future user-operated runners as a control-transport consideration, not a + requirement. +- Implemented direct control delivery behind a replaceable adapter for version one. +- Recorded warm sandbox and harness resume as the required Stop outcome. +- Confirmed the minimal internal command lifecycle and its separation from public execution state. +- Left the Stop settlement timeout for the sandbox cancellation spike. +- Confirmed that the first version keeps current Redis execution ownership. +- Kept durable commands and direct delivery in scope; deferred long polling and full fencing. + +## Branch + +- Branch: `agent/session-execution-rfc` +- The branch is pushed to `Agenta-AI/agenta` after each design exchange. + +## Next discussion + +Start with **Stop and ownership**: + +1. Start the sandbox-agent capability investigation. +2. Confirm the user-visible Stop requirements and latency target. +3. Validate the direct runner-control transport and its failure behavior. +4. Define terminal settlement and watchdog responsibility. +5. Decide which current issues this track is expected to close. + +The **live-frame ingress** discussion can proceed independently after that or in parallel. diff --git a/docs/design/session-control-and-live-events/tonight-handoff.md b/docs/design/session-control-and-live-events/tonight-handoff.md new file mode 100644 index 00000000000..cbda2972594 --- /dev/null +++ b/docs/design/session-control-and-live-events/tonight-handoff.md @@ -0,0 +1,84 @@ +# Tonight handoff + +> AGENT-GENERATED, low weight. Draft execution handoff. Mahmoud makes final decisions. + +## Fixed direction + +- Keep current Redis execution ownership for version one. +- Add durable commands with `pending`, `claimed`, `applied`, and `obsolete` states. +- Use direct API-to-runner HTTP behind a replaceable control-delivery port for version one. +- Keep `expected_execution_id` optional on public Stop. +- Keep the Redis ownership lock until Stop settles. +- Keep durable storage and settlement independent of the delivery transport. +- Use heartbeat command discovery as delivery fallback. +- Require same-sandbox and native-session resume only for harnesses and environments that expose + resumable cancellation. Run this release-gate cell for every supported harness and + sandbox-provider pair; record an explicit cold-start result where resume is unavailable. +- Keep live-frame work independent from Stop work. +- Park the repaired-records versus separate-event-table decision for review. + +## Work package A: sandbox cancellation spike + +**Goal:** Identify which cancellation paths preserve warm resume and qualify the requirement by +capability. + +Answer: + +1. Which request cancels a prompt in each supported harness? +2. Does it preserve the native harness session? +3. What happens to a running tool and partial message? +4. Does the runner park or destroy the sandbox on every cancellation path? +5. Is a sandbox-agent patch required? +6. Does Daytona need a rebuilt snapshot? + +Deliver a code-traced report, a characterization test, the smallest patch proposal, and a live test +plan for start, Stop, and resume. Require the same sandbox and native session only where the harness +and environment report that capability. Do not redesign ownership, commands, or public endpoints. + +## Work package B: durable command and direct-delivery design + +**Goal:** Produce an implementation-ready design for reliable API-to-runner commands. + +Define the command schema, idempotency, direct-delivery acknowledgement, failure recovery, adapter +boundary, and how Redis ownership remains held until Stop settles. Keep long-poll claim semantics +as a deferred transport. Do not implement a new execution ownership model. + +## Work package C: current Stop implementation map + +**Goal:** Remove uncertainty before changing Stop. + +Trace the browser request, API stream mutation, Redis key changes, heartbeat response, runner abort, +sandbox cleanup, records, interactions, and frontend refresh. List every branch that means cancel, +kill, steer, or approval interruption. Deliver a sequence diagram and file-by-file change map. Do +not implement changes. + +## Work package D: stable record-ID spike + +**Goal:** Make the later immutable-history decision safe. + +Inventory every stable `record_id` producer and classify repeated IDs as exact retries, +progressive updates, or resume re-emissions. Add or propose regression tests for final tool state, +interaction responses, terminal events, and harness reconstruction. Do not select repaired records +or a separate event table. + +## First implementation after the spikes + +1. Add the durable command repository and service behind interfaces. +2. Add the direct API-to-runner adapter and authenticated runner route. +3. Let Stop create a durable command with an optional expected-execution guard. +4. Let the runner apply Stop through its active abort controller. +5. Preserve Redis ownership until cancellation settles. +6. Make heartbeat discover pending Stop as fallback. +7. Emit the durable cancellation outcome and publish the existing watch notification. +8. Prove Stop delivery within five seconds and warm resume on the live stack. + +## Deferred explicitly + +- Postgres execution authority. +- Ownership generations and full fencing. +- Multiple-runner routing guarantees. +- User-operated runner requirements. +- Final records versus event-table selection. +- Final public endpoint naming. +- WebSocket or gRPC control transport. +- Runner-initiated long-poll control transport. diff --git a/docs/docs/concepts/01-agents.mdx b/docs/docs/concepts/01-agents.mdx index 93b3dfeb5d2..4a5d61e6668 100644 --- a/docs/docs/concepts/01-agents.mdx +++ b/docs/docs/concepts/01-agents.mdx @@ -40,14 +40,14 @@ You can create an agent in two ways: - **Build it through conversation.** Describe the job in the playground. The playground build kit lets the agent update its own configuration as you clarify what you need. - **Configure it directly.** Edit the instructions and select the skills, tools, files, permissions, harness, and model yourself. -Both paths create the same configuration. You can also move between them: ask the agent to make a change, then inspect or edit the result before you commit it. +Both paths create the same configuration. You can also move between them: ask the agent to make a change, then inspect or edit the result. ## How agents improve over time Improve an agent by turning feedback from real work into configuration changes. Suppose the SEO agent recommends topics that your company has already covered. You can tell it where the content inventory lives and require it to check that inventory before proposing a topic. The agent can update its configuration in the playground, or you can make the change directly. -Agenta saves committed configuration changes as versions. Each version records the agent's instructions, skills, tools, permissions, harness, and model at that point. +Agenta keeps configuration changes as versions. Each version records the agent's instructions, skills, tools, permissions, harness, and model at that point. ## Learn about each part diff --git a/docs/docs/concepts/07-automations.mdx b/docs/docs/concepts/07-automations.mdx index 39139a08f11..cf08bd6b9a6 100644 --- a/docs/docs/concepts/07-automations.mdx +++ b/docs/docs/concepts/07-automations.mdx @@ -54,12 +54,6 @@ That destination might be a channel, an email, a document, a task, or a row in a If later runs should use the result, the agent can also save a copy in its shared files. Monday's report can then compare itself with the report from the week before. -## Each automation runs a version you choose - -A schedule or event trigger points to a committed agent version. Draft changes in the playground do not silently change an automation that is already running. - -This gives you a clear review point. Improve the agent and test the recurring message in the playground. Then commit the new version and choose when the automation should use it. - ## Continue with the guides - [Create an automation](/guides/create-an-automation) diff --git a/docs/docs/guides/01-write-your-agents-instructions.mdx b/docs/docs/guides/01-write-your-agents-instructions.mdx index 1c412c160a9..3b901923d4c 100644 --- a/docs/docs/guides/01-write-your-agents-instructions.mdx +++ b/docs/docs/guides/01-write-your-agents-instructions.mdx @@ -43,11 +43,7 @@ question. Never publish or send anything. Show me the draft and wait. ``` -Then: - -1. Click `Save`. The dialog closes and the playground header state reads `Draft`. -2. Click `Commit` in the **Configuration** header. -3. Pick `New version`, keep or edit the commit message, and click `Commit`. +Then click `Save`. The dialog closes and the agent uses the new instructions from its next message on. ## Add a correction @@ -63,7 +59,7 @@ question. In social copy: no emoji, and never open with a scene-setting sentence Open with the claim. ``` -Save and commit again. Each commit creates a version. You can review what changed and when on the **Registry** page. +Click `Save` again. The correction applies from the agent's next message on. ## Ask the agent to edit the instructions diff --git a/docs/docs/guides/03-manage-skills.mdx b/docs/docs/guides/03-manage-skills.mdx index 9d5740b82ad..98ee46932bd 100644 --- a/docs/docs/guides/03-manage-skills.mdx +++ b/docs/docs/guides/03-manage-skills.mdx @@ -43,7 +43,7 @@ Here is a complete example: then the date the money lands. ``` -Click `Create`, then `Commit` in the **Configuration** header to make the skill part of a version. +Click `Create`. The skill joins the agent's `Skills` list. ### Write the description @@ -71,7 +71,7 @@ The dialog fills itself in from what you dropped: - Everything after the frontmatter becomes the `SKILL.md` body. - Any files sitting beside `SKILL.md` come in as supporting files, keeping their relative paths. -The `SKILL.md` body is plain text, so you can review it before you click `Create`. Then click `Commit` in the **Configuration** header to make the skill part of a version. +The `SKILL.md` body is plain text, so you can review it before you click `Create`. If you have the text but not the folder, paste a whole `SKILL.md`, frontmatter included, anywhere in the dialog. It fills the same fields. diff --git a/docs/docs/guides/04-add-an-mcp-server.mdx b/docs/docs/guides/04-add-an-mcp-server.mdx index 55134bfeb51..6e2e54fb76a 100644 --- a/docs/docs/guides/04-add-an-mcp-server.mdx +++ b/docs/docs/guides/04-add-an-mcp-server.mdx @@ -32,7 +32,6 @@ The MCP form does not store the key directly. It references a project secret tha 1. In the **Configuration** column, find **MCPs**, between **Tools** and **Skills**. Its summary reads `None` when the agent has no MCP servers yet. 2. Click the `+` button on that row, labelled `Add MCP server`. 3. Fill in the fields below, then click `Create`. -4. Click `Commit` in the **Configuration** header to make the server part of a version. diff --git a/docs/docs/guides/06-create-an-automation.mdx b/docs/docs/guides/06-create-an-automation.mdx index c1f81e6433c..28e0198942f 100644 --- a/docs/docs/guides/06-create-an-automation.mdx +++ b/docs/docs/guides/06-create-an-automation.mdx @@ -1,7 +1,7 @@ --- title: "Create an automation" sidebar_label: "Create an automation" -description: "Prepare an agent for unattended runs, commit a version, and add a schedule or app trigger." +description: "Prepare an agent for unattended runs, then add a schedule or app trigger." --- ```mdx-code-block @@ -46,23 +46,10 @@ Open `Advanced`, then the `Permissions` section, and set `Policy`: Under `Ask`, the run waits at the first tool call. Under `Allow reads`, it waits when the agent needs to write. Set permissions that let the automation finish before you attach a trigger. ::: -There is no separate auto-approve list. When you select `Always allow` on a tool's approval card during a chat, Agenta writes that tool's own `Permission`, or adds a harness built-in to the harness allow list. Either way the grant reaches a trigger only after you commit it. +There is no separate auto-approve list. When you select `Always allow` on a tool's approval card during a chat, Agenta writes that tool's own `Permission`, or adds a harness built-in to the harness allow list. For the wider set of controls, see [Control what an agent can do](/guides/control-what-an-agent-can-do). -## Commit a version - -A trigger runs one pinned variant and revision, so it runs what you committed, not your current draft. - -Click `Commit` in the `Configuration` header, keep `New version`, write a commit message, and click `Commit`. - -The commit dialog with What's changing on the left and the New version chooser and commit message on the right - ## Attach the trigger Triggers live in `Sidebar > Settings > Triggers`, not in the agent's configuration. @@ -70,7 +57,7 @@ Triggers live in `Sidebar > Settings > Triggers`, not in the agent's configurati - **`Scheduled runs`** runs the agent on a clock. Click `Schedule`. See [Schedule an automation](/guides/schedule-an-automation). - **`Event triggers`** runs the agent when an event fires in a connected app. Click `Subscribe`. See [Trigger an automation from an app](/guides/trigger-an-automation-from-an-app). -Both forms carry a `Version` field that pins the variant and revision you choose, and a `Run in playground` button that runs the agent with the message you configured. The schedule form saves with `Create schedule`, the trigger form with `Create`. +Both forms carry a `Run in playground` button that runs the agent with the message you configured. The schedule form saves with `Create schedule`, the trigger form with `Create`. ## Where automations are listed diff --git a/docs/docs/guides/07-schedule-an-automation.mdx b/docs/docs/guides/07-schedule-an-automation.mdx index 05610d265eb..835ddbf2eef 100644 --- a/docs/docs/guides/07-schedule-an-automation.mdx +++ b/docs/docs/guides/07-schedule-an-automation.mdx @@ -1,7 +1,7 @@ --- title: "Schedule an automation" sidebar_label: "Schedule an automation" -description: "Run an agent on a recurring schedule by setting its cadence, version, and message." +description: "Run an agent on a recurring schedule by setting its cadence and message." --- ```mdx-code-block @@ -10,10 +10,6 @@ import { Stream } from "@cloudflare/stream-react"; A schedule runs an agent at set times without anyone in the chat. First, [prepare the agent for automation](/guides/create-an-automation). -## Commit before you schedule - -A schedule runs one pinned variant and revision. Commit the agent first, so the schedule can pin the version you want it to run. In the `Configuration` header, click `Commit`, keep `New version`, and confirm. - ## Open the schedule dialog Go to `Sidebar > Settings > Triggers`, then click `Schedule` on the `Scheduled runs` section. @@ -48,11 +44,7 @@ A green line under the fields restates the schedule it parsed and the next time If the schedule should only run between two dates, expand `Active window` and set the start and end. -### 3. Which version runs? - -Choose `Pinned`, then select the variant and revision. Future changes to the agent do not affect this schedule until you update its pinned version. - -### 4. What should the agent do? +### 3. What should the agent do? Write the message the agent receives on each run. Name the task and where to send the result. For example: `Summarize support tickets from the previous day and post the digest to #ops.` diff --git a/docs/docs/guides/08-trigger-an-automation-from-an-app.mdx b/docs/docs/guides/08-trigger-an-automation-from-an-app.mdx index df4901ed11d..88a3444dcfa 100644 --- a/docs/docs/guides/08-trigger-an-automation-from-an-app.mdx +++ b/docs/docs/guides/08-trigger-an-automation-from-an-app.mdx @@ -16,10 +16,6 @@ Go to `Sidebar > Settings > Triggers`, then click `Connect app` on the `Connecti The trigger picker shows your connections in its left rail, under `YOUR CONNECTIONS`, and each app card has a `+ Connect another account` button for adding a second account. -## Commit before you subscribe - -An event trigger runs one pinned variant and revision. Commit the agent first, so the trigger can pin the version you want it to run. In the `Configuration` header, click `Commit`, keep `New version`, and confirm. - ## Pick the app and the event Go to `Sidebar > Settings > Triggers`, then click `Subscribe` on the `Event triggers` section. @@ -44,11 +40,7 @@ Type a `Trigger name`. It is what identifies this trigger in the `Event triggers Fill in every field marked with an asterisk. -### 3. Which version runs? - -Choose `Pinned`, then select the variant and revision. Every run uses that version until you update the trigger. - -### 4. What the agent gets +### 3. What the agent gets Write the message the agent receives when the event fires. `EVENT FIELDS` lists the data the event carries. Click a field to add its live value, such as the issue, branch, or record that started the run. diff --git a/docs/docs/guides/09-choose-a-harness-and-model.mdx b/docs/docs/guides/09-choose-a-harness-and-model.mdx index 326779e7eb1..7d1c6f6cd9f 100644 --- a/docs/docs/guides/09-choose-a-harness-and-model.mdx +++ b/docs/docs/guides/09-choose-a-harness-and-model.mdx @@ -51,9 +51,3 @@ To manage connections for the whole project instead of from one agent, go to `Si /> To run an agent against a Claude or ChatGPT subscription rather than a metered API key, see [Use your own Claude or ChatGPT subscription](/guides/use-your-own-subscription). - -## Commit - -Picking a model leaves a draft on the agent configuration. To publish it, click `Commit` in the `Configuration` header, keep `New version`, write a message, and click `Commit`. - -Deployments and triggers run a pinned revision, so commit before an automation should use the new model or harness. diff --git a/docs/docs/guides/10-use-your-own-subscription.mdx b/docs/docs/guides/10-use-your-own-subscription.mdx index 84684cb3cd4..7e8a69a8bbd 100644 --- a/docs/docs/guides/10-use-your-own-subscription.mdx +++ b/docs/docs/guides/10-use-your-own-subscription.mdx @@ -48,7 +48,3 @@ The login lives in the runner container, so the agent has to run there. Open `Ad :::warning Every local run can read the files mounted into the runner, including this login. Use a subscription only on a deployment you run for yourself. See [Sandbox isolation and security](/self-host/agent-execution/sandbox-isolation-and-security). ::: - -## 4. Commit - -Click `Commit` in the `Configuration` header so deployments and triggers run the version that uses the subscription. diff --git a/docs/docs/reference/api/append-turn.RequestSchema.json b/docs/docs/reference/api/append-turn.RequestSchema.json index 1e1378e7f05..b73ede980d1 100644 --- a/docs/docs/reference/api/append-turn.RequestSchema.json +++ b/docs/docs/reference/api/append-turn.RequestSchema.json @@ -1 +1 @@ -{"title":"Body","body":{"content":{"application/json":{"schema":{"properties":{"session_id":{"type":"string","title":"Session Id"},"turn_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Turn Id"},"stream_id":{"type":"string","format":"uuid","title":"Stream Id"},"turn_index":{"type":"integer","title":"Turn Index"},"harness_kind":{"type":"string","enum":["pi_core","claude","pi_agenta","codex"],"title":"HarnessKind","description":"The coding agent program a run drives. A backend declares which it supports.\n\n``pi_core`` is plain Pi; ``pi_agenta`` is Pi with Agenta's forced skills, prompt, and\npolicy. Both drive the same ``pi`` ACP agent in the runner; ``claude`` drives Claude Code."},"agent_session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Session Id"},"sandbox_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sandbox Id"},"references":{"anyOf":[{"items":{"properties":{"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key"}},"type":"object","title":"SessionReference","description":"A reference element that carries its own family.\n\nSessions persist references as a flat list (`session_turns.references`,\n`session_streams.references`), so the map key that named the family upstream is\ngone by the time a reader sees the row — leaving \"first UUID in the list\" as the\nonly way to guess which element is the workflow.\n\n``key`` is the name `evaluation_runs.references` already uses for the same flat-list\ndiscriminator (`dbs/postgres/evaluations/utils.py`), and the same one tracing carries\nin `OTelReference.attributes[\"key\"]`.\n\nIt is a plain string rather than ``ReferenceKey`` on purpose: a turn append is\nfire-and-forget, so rejecting an unrecognized family would drop the whole turn, which\nis the failure this field exists to prevent. Producers inside the API use\n``ReferenceKey``; readers treat anything else as untyped."},"type":"array"},{"type":"null"}],"title":"References"},"trace_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Trace Id"},"span_id":{"anyOf":[{"type":"string","pattern":"^[0-9a-fA-F]{16}$"},{"type":"null"}],"title":"Span Id"},"start_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Start Time"},"end_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"End Time"}},"type":"object","required":["session_id","stream_id","turn_index","harness_kind"],"title":"SessionTurnAppendRequest"}}},"required":true}} \ No newline at end of file +{"title":"Body","body":{"content":{"application/json":{"schema":{"properties":{"session_id":{"type":"string","title":"Session Id"},"turn_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Turn Id"},"stream_id":{"type":"string","format":"uuid","title":"Stream Id"},"turn_index":{"type":"integer","title":"Turn Index"},"harness_kind":{"type":"string","enum":["pi_core","claude","codex"],"title":"HarnessKind","description":"The coding agent program a run drives. A backend declares which it supports.\n\n``pi_core`` is Pi; ``claude`` drives Claude Code; ``codex`` drives Codex."},"agent_session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Session Id"},"sandbox_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sandbox Id"},"references":{"anyOf":[{"items":{"properties":{"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key"}},"type":"object","title":"SessionReference","description":"A reference element that carries its own family.\n\nSessions persist references as a flat list (`session_turns.references`,\n`session_streams.references`), so the map key that named the family upstream is\ngone by the time a reader sees the row — leaving \"first UUID in the list\" as the\nonly way to guess which element is the workflow.\n\n``key`` is the name `evaluation_runs.references` already uses for the same flat-list\ndiscriminator (`dbs/postgres/evaluations/utils.py`), and the same one tracing carries\nin `OTelReference.attributes[\"key\"]`.\n\nIt is a plain string rather than ``ReferenceKey`` on purpose: a turn append is\nfire-and-forget, so rejecting an unrecognized family would drop the whole turn, which\nis the failure this field exists to prevent. Producers inside the API use\n``ReferenceKey``; readers treat anything else as untyped."},"type":"array"},{"type":"null"}],"title":"References"},"trace_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Trace Id"},"span_id":{"anyOf":[{"type":"string","pattern":"^[0-9a-fA-F]{16}$"},{"type":"null"}],"title":"Span Id"},"start_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Start Time"},"end_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"End Time"}},"type":"object","required":["session_id","stream_id","turn_index","harness_kind"],"title":"SessionTurnAppendRequest"}}},"required":true}} \ No newline at end of file diff --git a/docs/docs/reference/api/append-turn.StatusCodes.json b/docs/docs/reference/api/append-turn.StatusCodes.json index 7db6605d51d..047eb937193 100644 --- a/docs/docs/reference/api/append-turn.StatusCodes.json +++ b/docs/docs/reference/api/append-turn.StatusCodes.json @@ -1 +1 @@ -{"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"properties":{"count":{"type":"integer","title":"Count","default":0},"turn":{"anyOf":[{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"project_id":{"type":"string","format":"uuid","title":"Project Id"},"session_id":{"type":"string","title":"Session Id"},"turn_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Turn Id"},"stream_id":{"type":"string","format":"uuid","title":"Stream Id"},"turn_index":{"type":"integer","title":"Turn Index"},"harness_kind":{"type":"string","enum":["pi_core","claude","pi_agenta","codex"],"title":"HarnessKind","description":"The coding agent program a run drives. A backend declares which it supports.\n\n``pi_core`` is plain Pi; ``pi_agenta`` is Pi with Agenta's forced skills, prompt, and\npolicy. Both drive the same ``pi`` ACP agent in the runner; ``claude`` drives Claude Code."},"agent_session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Session Id"},"sandbox_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sandbox Id"},"references":{"anyOf":[{"items":{"properties":{"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key"}},"type":"object","title":"SessionReference","description":"A reference element that carries its own family.\n\nSessions persist references as a flat list (`session_turns.references`,\n`session_streams.references`), so the map key that named the family upstream is\ngone by the time a reader sees the row — leaving \"first UUID in the list\" as the\nonly way to guess which element is the workflow.\n\n``key`` is the name `evaluation_runs.references` already uses for the same flat-list\ndiscriminator (`dbs/postgres/evaluations/utils.py`), and the same one tracing carries\nin `OTelReference.attributes[\"key\"]`.\n\nIt is a plain string rather than ``ReferenceKey`` on purpose: a turn append is\nfire-and-forget, so rejecting an unrecognized family would drop the whole turn, which\nis the failure this field exists to prevent. Producers inside the API use\n``ReferenceKey``; readers treat anything else as untyped."},"type":"array"},{"type":"null"}],"title":"References"},"trace_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Trace Id"},"span_id":{"anyOf":[{"type":"string","pattern":"^[0-9a-fA-F]{16}$"},{"type":"null"}],"title":"Span Id"},"start_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Start Time"},"end_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"End Time"}},"type":"object","required":["project_id","session_id","stream_id","turn_index","harness_kind"],"title":"SessionTurn"},{"type":"null"}]}},"type":"object","title":"SessionTurnResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"properties":{"detail":{"items":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"}}}}}} \ No newline at end of file +{"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"properties":{"count":{"type":"integer","title":"Count","default":0},"turn":{"anyOf":[{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"project_id":{"type":"string","format":"uuid","title":"Project Id"},"session_id":{"type":"string","title":"Session Id"},"turn_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Turn Id"},"stream_id":{"type":"string","format":"uuid","title":"Stream Id"},"turn_index":{"type":"integer","title":"Turn Index"},"harness_kind":{"type":"string","enum":["pi_core","claude","codex"],"title":"HarnessKind","description":"The coding agent program a run drives. A backend declares which it supports.\n\n``pi_core`` is Pi; ``claude`` drives Claude Code; ``codex`` drives Codex."},"agent_session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Session Id"},"sandbox_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sandbox Id"},"references":{"anyOf":[{"items":{"properties":{"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key"}},"type":"object","title":"SessionReference","description":"A reference element that carries its own family.\n\nSessions persist references as a flat list (`session_turns.references`,\n`session_streams.references`), so the map key that named the family upstream is\ngone by the time a reader sees the row — leaving \"first UUID in the list\" as the\nonly way to guess which element is the workflow.\n\n``key`` is the name `evaluation_runs.references` already uses for the same flat-list\ndiscriminator (`dbs/postgres/evaluations/utils.py`), and the same one tracing carries\nin `OTelReference.attributes[\"key\"]`.\n\nIt is a plain string rather than ``ReferenceKey`` on purpose: a turn append is\nfire-and-forget, so rejecting an unrecognized family would drop the whole turn, which\nis the failure this field exists to prevent. Producers inside the API use\n``ReferenceKey``; readers treat anything else as untyped."},"type":"array"},{"type":"null"}],"title":"References"},"trace_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Trace Id"},"span_id":{"anyOf":[{"type":"string","pattern":"^[0-9a-fA-F]{16}$"},{"type":"null"}],"title":"Span Id"},"start_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Start Time"},"end_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"End Time"}},"type":"object","required":["project_id","session_id","stream_id","turn_index","harness_kind"],"title":"SessionTurn"},{"type":"null"}]}},"type":"object","title":"SessionTurnResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"properties":{"detail":{"items":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"}}}}}} \ No newline at end of file diff --git a/docs/docs/reference/api/append-turn.api.mdx b/docs/docs/reference/api/append-turn.api.mdx index bcae015eed9..b5523f72664 100644 --- a/docs/docs/reference/api/append-turn.api.mdx +++ b/docs/docs/reference/api/append-turn.api.mdx @@ -5,7 +5,7 @@ description: "Append Turn" sidebar_label: "Append Turn" hide_title: true hide_table_of_contents: true -api: eJztWdtuIzcS/ZUCsUCygG72jD0Z5SUazyzGmCzGsD15WEtrUc1qiTGb7PBiuWMI2I/YL9wvCYrsllrW2HEc52Vhv7ibZF1Yl1NV6lvm+dyx4QU7Q+ek0Y5NOsyUaLmXRh8LNmS8LFGLSx+sZh1m8ZeAzr8zomLDW5YZ7VF7euRlqWQW6fo/O6NpzWULLDg9lZa4eokuridpl1LQm69KZEPmvJV6zjrMS69oodYJjgVbdRgpUBNwXX3O2fBilzQ3tuCeDVkIkqjWJ3RQiq0mG+bnwTacnbfIi3uU2ebYUi4SbemmBd60WEjtcY6W3ZUZj606bMGtRucur6T+qmTUoSDXlPIyMxZZh2WKB0EPpbzkc9Se06Ihfq2bfUx8PxHbDhPoMitLcgtpsEDIjJB6DpEBlNbMLS+Agw0ahJXX6HowghnPrlALEJgpbtHBciGzBUgPLpSlsd71xnqsp9Nau+kUpINScanhRH4PcSPpmLZOJCylX8Aorn3jIDc2QwHuSirlOqRIUfoOcC3GujRKZlUP3hm/SEqBXyA4XmDkPJ3C6OikvoLUcdMGrdGS5GSm6bS+DhzFdzgyAntk+Eh2uR2D94XUQyEUrwLbUeq4FjNz8ye4niUONT+LOVrUWcqbDT/psXC7iXWN1smUe0+R/VNNThdRYf7kKxDtqsOeL1uTMa6weqpKn7BiK8rUtG9mP2Pmd7HmtDH3TuaMYO0KQIUFud4vuIeMWyvRgfQOzFJDzgupqpgdDahCSXZ1fsPCAXfAIVfcg6Kdb6dNPBKWuN7m5LQz1uvNBFVb23/vgDMxAwpewhVWSS3NCxRxOSkEoUzEIN1Yz41GmFVx38sCKf+RC7TgEF3KJ7OE//3nv6CQXxNejFkurfPw5cvx+ybnSPUxo7v4BY610aqCJa/AG5gHdA1qNPaSifPS2KtcmWWNIFdYJYigPR1THK+5CrGUXNqwbQ3gijStIDiMILJBBrJmN6qkhSTfFVJzbyx8OxUz1y+N83OLrr/h7vrBS+V6ZUVm5FpsmJGBvOUZXb128VhLDdPP56jWYdLj3ls5Cx7dxZjic8wm03iv43hdXkNiilCw3C+QNOYaptM1l0/RAkZDGWxpHA6BA4UBpNobPZZLi12uRTc3do4+Ot0iRXEEcw1BW8zMXMtfUTQ+X5qgBAhrymT4hVEYOXeSZ8a6NnvOpQqWUFY6yCUqAXgjnXfkytLiNWrfgxNrRMjQOpDaSZFAeXRyTL4gT25f6Ps6pBxQ3HnguvILUhaVQ4qZoCkbRcTkOi+5tbx6MJFPN4BIZJZn+JxdAfFroLzkv99wlNx7tAQQ/74YdN/ybj7q/mNyu3e4+tvDEFnyTfvBrb+kLHzkLQT32I3nH5RAbOE8HWOxhXtuER+0qAV8BVmpU5QWBbUwrVrbbre2Gqc7DdFkB5upexrFhDhNTShbrVZtOd4GjAuuNNqlgrg/GNC/bSw/C1mGzuVBwWl9ODZST+plMxMS0b1t31E8QQUl50F5NhzULeO2M+6wpZxBccn9s7rsKLGFkaeoCKX4K4R8SWxrIQIV/gVC3ie2tZDGXLPqGdGgMda7qk7Vxl7PKqWx1lpKY7BnldKYay3luZuz0hrK/D84Rp0kqgYKX+bCl7nwZS58mQtf5sKXufBlLnyZC1/mwv/rubDVM3aeb0j8im6PgHiiXE+Dqzhavt7f3x0ef+JKiogS8MFaY58+OQr0XCp6uqdqK5Nt7T6iwDXN5WpyN3k2F/7RJAXJ/4WbP9Rq/xOd43PcZOL9R6Mx4Jx2qcTrMqSpuKnUcYEmNd9uhne8cUS2vPG/Gzpkm6R+fa7drqxdlDx0vyneJxc8FB4fz89Pdhim+LjTCSRQPk8fygr0C0Pfz6i8JEBYsCHr11Hu+rGe92PcW2rPol+DVXSIlzI6Nb0uvC/dsN/H0MuUCaKX+ucel+nghHhkwUpfRSajk+NPWH2MIMuGF5P2gTOKxRRd28fWHuGlpKaow6jk0rWCXxgrf00hQ54llRIVGYGi/HTzVfDDDS9KhXe/8rXipZnX2Kucf3eQH77uHrzZe9N9fXC43529yrPufvb28FV+eMhzfrgFBY8mac9dg7sTVWtw2u34N3q2O/bNarvvvmj11S262CBv3v+I3rGRXWf1pF3HHm2tpjq1NGoVErY/2H/dHbzp7r893zsYHuwN97/rDd7s/Yu1a8H9p2Jq56ad2Wlyo3q/2xy3twgleRZBoQmtZlbdjvNNeJNSRcRI5pEXP6x3SI+N7Qe9vd4g/gZhnC+4bovYSsot7dYRT3jTj/1Y/I3HRkhO+br+6dLVUUX/CWoWlNXDC3Z7O+MOv1i1WtHyLwEtJeGkw665lXxGFrogLF406Xhbe/koFY1uREw6rkJKvzsFhHAgUYyyDEv/4NlJC3hOPp+dsw6b1Z/qCyOIxvIlhTFfsiFj9K0/9rt0IK7dMsX1PBDmD1niSX+/AZCiEGU= +api: eJztWdtuI7kR/RWCyMMG0M2asWdH+xKtZ4IxJsEYtmYfYilWqVktcd1N9vJiudcQkI/IF+ZLgiK7pZY09nodbZ7kF3eTrAurTt3Uj9zB3PLBDb9Ga6VWlk9aXBdowEmtLgQfcCgKVOLWeaN4ixv8xaN1P2pR8sEjT7RyqBw9QlFkMgl03Z+tVrRmkwXmQE+FIa5Oog3rUdqtFPTmygL5gFtnpJrzFnfSZbRQ6cQuBF+1OClQEYAqv6R8cLNPmmqTg+MD7r0kqvUJ5bOMryYb5iNvas7WGYT8CWW2OTaUC0RbuimBDw0WUjmco+G7MsOxVYsvwCi09vZOqm9KRuVzck0hbxNtkLd4koEX4UETj8ZtPkVen4lViwu0iZEFuYKkLpAlWkg1ZzBH5Vhh9NxAzoAZr5gw8h5thw3ZDJI7VIIJTDIwaNlyIZMFk45ZXxTaONsZq7GaTiuNplMmLbuUP7DpNKo2nVbs2Hl4Z+daYNgmhRu79NohIwSNbrfx8JR7n3PnMNxsGzEWlJjph/+B63XkUPEzmKJBlUQMb/hJh7ndB/k9GitjHLxG9k8VOV0k8/NXX4FoVy1+uMiJxrjD8rUqfcaSryhq4r6e/YyJ24/7q9rce4gesrUrGGaYk+vdAhxLwBiJlklnmV4qlkIuszKgtk5wrCC7WrdhYRlYBizNwLGMdr6b1nikuLadzclpa6zWmzFtbG3/ucWsZm6BLIeC3WEZ1VKQowjLUSHmi0jMpB2ruVbIZmXYdzJHiksEgYZZRBuWjV6y//zr3yxDuKc4HvNUGuvY168XH5hU4QypPuZ0F7fAsdIqK9kSSuY0m3u0dTTX9pKR81KbuzTTyyqy77CMUU17pDab4j1kPqT1W+O3rcEgI01L5i1almoTyCyRkTXbQSUlJPkulwqcNuy7qZjZbqGtmxu03Q132/VOZrZTlGRGUGLDjAzkDCR09crFYyUVm34ZYbaGSQecM3LmHdqbMeFzzCfTcK+LcF1gRQZSsYhQZsAtkDQGxabTNZfPwQJascKbQlscMGAEAxbrYPBYKg22QYl2qs0cXXC6QUJxSLKKeWUw0XMlf0VR+3ypfSaYMLqIhl/oDAPnVvTMWFVmT0Fm3iBzC2lZKjETDB+kdZZcWRi8R+U67NJo4RM0lkllpcBAOry8IF+QJ7cv9EMFKcsId46BKt2ClMXMImHGK4pGEXJyFZdgDJTPBvLVJiESmYEED1mhiV+dygv47eJfgHNoKEH886bXfg/tdNj+6+Tx5Gz1p+dTZAGbVgCMu6UofOEtBDhsh/PPSiC2bBSP8dBOHVrERyUqAd/IrNS1SYOC2olGrW22PltNzE5zMtnLzdTJDENAXMWGkK9Wq6YcZzyGBVtoZWNB7Pd69G87l1/7JEFrU5+xq+pwaHBe1Vcm2keiJ1uw83CCCkoKPnN80Kvat21n7LClmEFxC+6gLjuPbNnQESp8If4IIV8j20qIwAz/ACEfIttKSG2uWXnAbFAb68eyCtXaXgeVUltrLaU22EGl1OZaSzl0c1YYTZH/O0eay0hVp8LjjHac0Y4z2nFGO85oxxntOKMdZ7TjjPZ/m9Ea/VvrcAPbN3R7QYonyvVktgpj3tt+f3+Q+wkyKUKWYB+N0eb1U5xABzKjpyeqdqaTrd0XFLi60VtNdoNnc+G/6agg+T+38+fa3r+jtTDHTSQ+fTQYg41ol0q8KnycUOtKHRZoanLNxnTPG+dkywf3m9Ah20T1q3PNdmXtouihp03xIbrgOXh8Go0u9xhGfOx0AjEpj+IHpBzdQtN3JSovMSEs+IB3K5Tbbqjn3YB7Q+1Z8Ks3GR2CQganxteFc4UddLvoO0mmveiERhU6IOPBCfFIvJGuDEyGlxefsfwUkiwf3EyaB64JixFd28fWHoFCUlPU4lRy6VreLbSRv0bIkGdJpUhFRiCUX22+ln18gLzIcPfrVwMv9ezE36Tw/Wl69rZ9+u7kXfvt6Vm/PXuTJu1+8v7sTXp2BimcbaWCF5M0Z6De7nTTGGL2O/6Nns2OfbPa7LtvGn11gy40yJv336N3aGTXUT1p1rEXW6uuTg2NGoWE93v9t+3eu3b//ejkdHB6Muh/3+m9O/kHb9aCp0+F0E51M7LDuANU7/eb4+YWZUlIQlKooRW2eWsH5xt4k1J5yJHcIeR/We+QHhvb9zonnV74PUBbl4NqitgKyi3t1oinfNMN/Vj4vcWElBzjdf0zoq1QRf8p1Swoqgc3/PFxBha/mmy1ouVfPBoKwkmL34ORMCML3VAuXtTh+Fh5+TwWjXbImHQ88zH8dgoI5YFIMUwSLNyzZyeNxHP55XrEW3xWfcLOtSAaA0uCMSz5gHP6Bh76XToQ1h55BmruKecPeORJf/8FZSK+sg== sidebar_class_name: "post api-method" info_path: reference/api/agenta-api custom_edit_url: null diff --git a/docs/docs/reference/api/call-tool.RequestSchema.json b/docs/docs/reference/api/call-tool.RequestSchema.json index 9c0440f6c13..2a8e38d143c 100644 --- a/docs/docs/reference/api/call-tool.RequestSchema.json +++ b/docs/docs/reference/api/call-tool.RequestSchema.json @@ -1 +1 @@ -{"title":"Body","body":{"content":{"application/json":{"schema":{"properties":{"data":{"properties":{"id":{"type":"string","title":"Id"},"type":{"type":"string","title":"Type","default":"function"},"function":{"properties":{"name":{"type":"string","title":"Name"},"arguments":{"title":"Arguments"}},"type":"object","required":["name","arguments"],"title":"ToolCallFunction","description":"Mirrors OpenAI function call: {name, arguments}."}},"type":"object","required":["id","function"],"title":"ToolCallData","description":"OpenAI tool_calls array item — passed verbatim from the LLM."}},"type":"object","required":["data"],"title":"ToolCall","description":"Request envelope — wraps the raw OpenAI tool call."}}},"required":true}} \ No newline at end of file +{"title":"Body","body":{"content":{"application/json":{"schema":{"properties":{"data":{"properties":{"id":{"type":"string","title":"Id"},"type":{"type":"string","title":"Type","default":"function"},"function":{"properties":{"name":{"type":"string","title":"Name"},"arguments":{"title":"Arguments"}},"type":"object","required":["name","arguments"],"title":"ToolCallFunction","description":"Mirrors OpenAI function call: {name, arguments}."}},"type":"object","required":["id","function"],"title":"ToolCallData","description":"OpenAI tool_calls array item — passed verbatim from the LLM."},"context":{"anyOf":[{"properties":{"provider":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider"},"integration":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Integration"},"connection":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Connection"},"tool":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tool"},"toolkit_version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Toolkit Version"},"toolkit_versions":{"anyOf":[{"additionalProperties":{"type":"string"},"type":"object"},{"type":"null"}],"title":"Toolkit Versions"}},"type":"object","title":"ToolCallContext","description":"Trusted routing the caller adds beside the model's arguments (contracts section 6).\n\nThe runner reads every field from its private resolved policy, so none of it is\nmodel input. ``connection`` and ``tool`` are absent for ``gateway.search``. The\ngateway routes refuse a call whose context is missing or incomplete; there is no\ndefault connection to fall back to."},{"type":"null"}]}},"type":"object","required":["data"],"title":"ToolCall","description":"Request envelope — wraps the raw OpenAI tool call."}}},"required":true}} \ No newline at end of file diff --git a/docs/docs/reference/api/call-tool.api.mdx b/docs/docs/reference/api/call-tool.api.mdx index 3b91047a34f..ca33aa5c3e6 100644 --- a/docs/docs/reference/api/call-tool.api.mdx +++ b/docs/docs/reference/api/call-tool.api.mdx @@ -5,7 +5,7 @@ description: "Call a tool action with a connection." sidebar_label: "Call Tool" hide_title: true hide_table_of_contents: true -api: eJydV81u2zgQfhVizqqdDXrSab1pFzWaNIHj3UtgBGNpbLOhRJWknHgNAfsQfcJ9ksWQskz/F8khsIbzp5lvPo7W4HBuIX2CsdbKwiSBnGxmZOWkLiGFG1RKoHBaK4EZC8WrdAuBItNlSV7SgwR0RQb5YZhDChkq9cw2kIChHzVZ94fOV5CuIdOlo9LxT6wqJTNv1f9uOdwabLagAvlXZdink2T5KUd3RCpz/u9WFUEK1hlZziEBJ51iwTCHJmlPT2uNWc6vPcNaOUhhVpf+tdi4+30QusTirNtvfN4kgGZeF1Q6b7Q5HHTCpssQ9PQ7Za6tmDSUc1t8mNjLJMpca8X9+XOT5H7z7qQx2lhxX1E5GIrNywhuTyrW7DsRneumdzEbmUNUkyOpfOI27afRhmc8PHNoK9AYXAnpqBD//ftTVGgt5WJJZopOFmJmdCHcgsTt7d3lpDw0juRykMcoIFFQuSSlK/KxXw1W1gcz+CqiVH2VOHoTh3OmJi+wlS5tgML11ZWH6E6wxzrLyNpZrcSoVYbkvejnVE6hH8vV/QzSp0MozrQpkBFd15JHodMoa6WgmexPinXoarvrczeikwVZh0V1DPlduBwdfWDVeMo6y2gkT6V+LlU/rg1XMn+3jxu2bRIoyFqcv9vNXWseSpe9OIPZu509bj0cA3ykxj069NQkHUee6p7R6ixljfg8ZkLP4JzMZnKfzxMuT57wN0YAVIT2UyY3rcqlId9JYet5b+xHZGvlLpGQaPt+lHummL0IpyP6OSz1mf5sszhCP4EGtvzjb9LBnEqHQuZUOulWiQhjmAgsc5/Fkcwvk6JnjCOk2HFR44nt4/X1IXX9jUrmnpjEZ75A3s9bOTmUnrmY6+2hgtLZzukvTI4sHc3JBMi3Mn+fRF241SFBP+R2fg6A0RBf2hR8McSGgGRZ1S6+1IdewLB3b5GbA4h4yL9dhjzXJqTf6kXt3LYodOh0KT6FFpzD7Jfx+OHAYcBHQW6heaOrtGWTCt0CUugzGG0/C7esJbMkY33baqP4HCvpexYeF85VNu33qe5lStd5Dz3meyiD4oR9ZLWRbuWdDB6GX2n1hTAnA+nTJFZ4ZKgF8OyqdQXHSn4lLkFY0WBQu4U28h9sdyTJGF8EK35HBvFou6R+fsOiCkS5IVRmvQgOIcxsu3XFW2IbdIPbxiNlpne2vzDxg4fhAUfsHPHQYeYir+EYkr26bssJCVDhRw4cYfF7d8II4SaFMFe933pXLOK2Fhgn7ul7HFb3ndyi1f2XvwraUjHc+5VC6QfS575uoRSY3fLbtoy1YKSlT7BeT9HSX0Y1DYt/1GQYHZMElmgkTrmUT8wBiw1O1vBCq+2d8qHd7Jeo6oCLPeJigAaLQZZR5c7qTqJheLh/HEMC0/aTpvCrCBh85QnGV0jBfxGxdbh4WbYGheW89vsGBJ/89z9dSJ+u +api: eJylWNtuGzcQ/ZUBX9oCG9kNij6oL3WdFDGSNIat5sUWrNHuSGLMJTckV44iCOhH9Av7JcWQKy1XN6eqHwwtL8Mhz5kzQy6Fx6kT/TsxMEY5McxEQS63svLSaNEXl6gUIHhjFGDOjfAk/QwQcqM1hZaeyISpyCJ/XBWiL3JU6oHniExY+lyT87+ZYiH6S5Eb7Ul7/olVpWQeZp19crzcUrh8RiXyr8qyTS/J8VeBfk+rLPi/X1Qk+sJ5K/VUZMJLr7jhqhCrrOk9PGrA7bztCdbKi76Y1Dpsiydvfu8srbE8avYP7l9lAu20Lkn7MGndebFpXG08FGb8iXLfnJi0VDAsYZnUyjDx3BjF+Py+dnIbvPfSWmMdfKhIX1zBejPA8PRhybYz2Jhe9Z71RhYiOZM9rrximLbdaJZnPjzw0g7QWlyA9FTCP3/9DRU6RwXMyY7RyxIm1pTgZwTv3r3v8SEG0nyJpNGLDxPRv9vGo7JmLguy3TFddFbZpkXXSolVsoPr9fxVJqT2NI10PtXcVWIibqAJllMNXrYWGCSOrRMtMVZrG4/SP8zJuv/h2CCagY+NmV3Lrmsai0LyPlBddxDcXnKLif/Bh/1htc3Vy4ZU23Qd2Np5KsCa2ks9DURk2pIFLAoHY3KyoNBcmoLUd66NIfieqWox9w5chAt+/qF3r+/1YEZga63JgiUsHNCc7AImklQRGS+9g8rKOXoCS86oORVQGSXzRQbOgDaawExAepDuXofFQeqq9j0YjVqKjUaAuoDRiHHgD0uAY0faw8RYGI2m6OkJFz1HaPPZaNSDwYzuddMcNk4OLE1qR6z0nAOeZsYRNIEI0kEpnePjMRakzk1ZKfL0Cx+LJe7X5l43oprkCvAGJmxvjPkjeNPbg+tzKhRywR7x2UHyJqYeID0nZSoKYvNksXIBPYtPkGhT2Cdr4CpdztuaQoOrjHaRqS/Pz0NO6ix2W+c5OTepFdw0g0V2arpjVw6lu0NBmomJsSVyCqtrWRyXp5AanUdfu2Oq6mVJzmNZ7Ut1m+UK9PSCh6ZRtpmZ5OCT9IXbg4QWJ9u45LmrTJTkHE5PNvO+mR6PLn/kQD/Z2G1r4Zha3UaM9kRJtimKDqFnjTpao9xwf1r6+CQ1hFT9cLzC4siDUCJGQiVsPzTlshnyXJB3XGgtb4X9Dbla+eeqDmhw31tsNEKU1hvfIki7XuyRnygDrf6E0vliStojyIK0l56lPUCcBdFmL/Z4/nxpFhRjjyhutGgVhO2nly93pesjKlkEYYLXXDGerlsFeZRBubi4c7sDlMk7vd8QOaEa48Js2B5BKCATFN6ZfFNrlW56jIBJED93NQiHAWsBCpk2reKvQgPT3n9JzOxQZF1oPAchn010vxmXwNlCFBE6fBSvIgTHOPtmMLjeMRj5UZKfGb7CVcbxlAr9TPTFGZPRneUxyzqyXNoF2GqruB8rGTCLnzPvK9c/O6O6lytTFz0MnO+hjAOHbCOvrfSLYOTi+uotLd4QhgL+bpgOuGWqRfJ0h20OHCv5lvgI4p1MXNR+Zqz8is2lSDLHZ3EW75FJfNPeSl9/QS5e0lsmq15Ch7jMpL1mpdfCZtE1b7u3lfZW0prr3C7a5vSKkKwdCv3ud6doP9jFJxYglXpiOpfPqD8X11c7itXp4m1g7pM9xm6RbaHcgisyQWUQAOEJy183PczX1uXz3o+9c25ikpWYHmNIJoP4ctDxLXk5+OZHiQY4BuOsUiiDPATflw2xY55xvNtGP2fM+/6dWC7H6OhPq1Yrbv5ck2WuDjMxRytxzEd5x4o0W7N2KR5p0Wa4F83DwhxVHVm6JaMcLnHGRZ5T5Y+OHSahef3hdiAyMW5eVMpQGAmLT6wn+CT6IjzI+PXVK7QthUI9rUP1I6JN/vsXMfk5aA== sidebar_class_name: "post api-method" info_path: reference/api/agenta-api custom_edit_url: null diff --git a/docs/docs/reference/api/complete-turn.StatusCodes.json b/docs/docs/reference/api/complete-turn.StatusCodes.json index 7db6605d51d..047eb937193 100644 --- a/docs/docs/reference/api/complete-turn.StatusCodes.json +++ b/docs/docs/reference/api/complete-turn.StatusCodes.json @@ -1 +1 @@ -{"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"properties":{"count":{"type":"integer","title":"Count","default":0},"turn":{"anyOf":[{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"project_id":{"type":"string","format":"uuid","title":"Project Id"},"session_id":{"type":"string","title":"Session Id"},"turn_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Turn Id"},"stream_id":{"type":"string","format":"uuid","title":"Stream Id"},"turn_index":{"type":"integer","title":"Turn Index"},"harness_kind":{"type":"string","enum":["pi_core","claude","pi_agenta","codex"],"title":"HarnessKind","description":"The coding agent program a run drives. A backend declares which it supports.\n\n``pi_core`` is plain Pi; ``pi_agenta`` is Pi with Agenta's forced skills, prompt, and\npolicy. Both drive the same ``pi`` ACP agent in the runner; ``claude`` drives Claude Code."},"agent_session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Session Id"},"sandbox_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sandbox Id"},"references":{"anyOf":[{"items":{"properties":{"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key"}},"type":"object","title":"SessionReference","description":"A reference element that carries its own family.\n\nSessions persist references as a flat list (`session_turns.references`,\n`session_streams.references`), so the map key that named the family upstream is\ngone by the time a reader sees the row — leaving \"first UUID in the list\" as the\nonly way to guess which element is the workflow.\n\n``key`` is the name `evaluation_runs.references` already uses for the same flat-list\ndiscriminator (`dbs/postgres/evaluations/utils.py`), and the same one tracing carries\nin `OTelReference.attributes[\"key\"]`.\n\nIt is a plain string rather than ``ReferenceKey`` on purpose: a turn append is\nfire-and-forget, so rejecting an unrecognized family would drop the whole turn, which\nis the failure this field exists to prevent. Producers inside the API use\n``ReferenceKey``; readers treat anything else as untyped."},"type":"array"},{"type":"null"}],"title":"References"},"trace_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Trace Id"},"span_id":{"anyOf":[{"type":"string","pattern":"^[0-9a-fA-F]{16}$"},{"type":"null"}],"title":"Span Id"},"start_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Start Time"},"end_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"End Time"}},"type":"object","required":["project_id","session_id","stream_id","turn_index","harness_kind"],"title":"SessionTurn"},{"type":"null"}]}},"type":"object","title":"SessionTurnResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"properties":{"detail":{"items":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"}}}}}} \ No newline at end of file +{"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"properties":{"count":{"type":"integer","title":"Count","default":0},"turn":{"anyOf":[{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"project_id":{"type":"string","format":"uuid","title":"Project Id"},"session_id":{"type":"string","title":"Session Id"},"turn_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Turn Id"},"stream_id":{"type":"string","format":"uuid","title":"Stream Id"},"turn_index":{"type":"integer","title":"Turn Index"},"harness_kind":{"type":"string","enum":["pi_core","claude","codex"],"title":"HarnessKind","description":"The coding agent program a run drives. A backend declares which it supports.\n\n``pi_core`` is Pi; ``claude`` drives Claude Code; ``codex`` drives Codex."},"agent_session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Session Id"},"sandbox_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sandbox Id"},"references":{"anyOf":[{"items":{"properties":{"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key"}},"type":"object","title":"SessionReference","description":"A reference element that carries its own family.\n\nSessions persist references as a flat list (`session_turns.references`,\n`session_streams.references`), so the map key that named the family upstream is\ngone by the time a reader sees the row — leaving \"first UUID in the list\" as the\nonly way to guess which element is the workflow.\n\n``key`` is the name `evaluation_runs.references` already uses for the same flat-list\ndiscriminator (`dbs/postgres/evaluations/utils.py`), and the same one tracing carries\nin `OTelReference.attributes[\"key\"]`.\n\nIt is a plain string rather than ``ReferenceKey`` on purpose: a turn append is\nfire-and-forget, so rejecting an unrecognized family would drop the whole turn, which\nis the failure this field exists to prevent. Producers inside the API use\n``ReferenceKey``; readers treat anything else as untyped."},"type":"array"},{"type":"null"}],"title":"References"},"trace_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Trace Id"},"span_id":{"anyOf":[{"type":"string","pattern":"^[0-9a-fA-F]{16}$"},{"type":"null"}],"title":"Span Id"},"start_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Start Time"},"end_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"End Time"}},"type":"object","required":["project_id","session_id","stream_id","turn_index","harness_kind"],"title":"SessionTurn"},{"type":"null"}]}},"type":"object","title":"SessionTurnResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"properties":{"detail":{"items":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"}}}}}} \ No newline at end of file diff --git a/docs/docs/reference/api/complete-turn.api.mdx b/docs/docs/reference/api/complete-turn.api.mdx index 60ca20c5b35..0c1d993d617 100644 --- a/docs/docs/reference/api/complete-turn.api.mdx +++ b/docs/docs/reference/api/complete-turn.api.mdx @@ -5,7 +5,7 @@ description: "Complete Turn" sidebar_label: "Complete Turn" hide_title: true hide_table_of_contents: true -api: eJy1WOluI7kRfpUCESAboHXY2M1mtX/i8UwwxiQYw9bsj1iKRDVLaq7ZJJeH5V5DwD7EPmGeJCiyW4evmXg9/uMWj7q+qo9F3rHAV56Nrtglei+N9mxaMGPR8SCNPhNsxEpTW4UBZyE6zQrm8JeIPrwxomGjO1YaHVAH+uTWKlmmnYOfvdE05ssKa05f1pHcINGn8axvJgX9Co1FNmI+OKlXrGBBBkUDrVVwJtimYGTATGqBt3t7pA64Qre3aRydhrO0bFMwvkIdZofquG4+Ltno6r7iTbEd0VEptpnupJ6QHDg0CLWYBVnjYy4sjat5YCMmeMBeWrUT9k4LGNPQhtzKO83iZyxDG2DpUBAse3Yf+L+ne/ogXBSA0xa1i4wW25CmneTgIqYBb432GZLj4ZD+CfSlk5ZQJJGxLNH7ZVRw0S5mxUtBL03Mm57E7jStKJjAJY8qsNGwxf0QtntiHfKAYsbDc+A+CslziJ9msXASCOtoxddQ8imLbZUIJMxeXcnbLLZV0oVr0XymHPb0xCjFFwXrTdPWRhevV9XSRWurpQvYq2rpwrXV8nqiszzrDNX6E+R3KG239zzvam16OYG+mjOZaLM1wSGv/0+HLtOmP0LuFXcavZ9dS/2oZtSxJhq1clYal5hL8Sjow8pZOhp4ojOSt+fZ+yz3A4kt7jHiuEIojZB6BUkAWGdWjtfAwUUNwskb9H04gQUvr1ELEFgq7tDDupJlBTKAj9YaF3x/oid6Pm+tm89BerCKSw3n8kdIE9nGPHUuYS1DBeko4n/2sDSuRAH+WirlCzKktqEArsVEW6Nk2fThjQlVNgpCheB5jUnyfA4np+etC1KnSRe1Rkeac5jm89YdOE2/4dQI7H/tU9VzLRbm9g9IvcwSWnkOl+hQl/mw2MmTAWv/8JC6Qeel0S/V/VO7nRxRcfViF2jvV6Cea2xeatIHbB7tV+5xzUUX7geVcwJbKAAV1gR9qHiAkjsn0YMMHsxaw5LXUjWpOrq+FCzF1YedCA/cA4el4gEUzXwz7/KRuMT3dyvnxURvJzNVHUz/pQBvUgXU3MI1NtkszWsUaTgbBNHmzSD9RK+MRlg0aZ5OYKp/5AIdeESf68ms4b+//Q4K+Q3xxYQtpfMBPn06e9vVHJk+YeRLqHCijVYNrHkDwcAqou9Yo4uXzJLXxl0vlVm3DHKNTaYImtOpxPGGq5jaspmLh9EArsjSBqLHRCI7ZqBo9pJJWkjCrpaaB+Pgm7lY+IE1Pqwc+sFOuh/EIJXv24bCyLXYCaMABcdLcr2FeKKlhvnHMaptmvR5CE4uYkB/NaH8nLDpPPl1ltzlLSXmDAXHQ4VkMdcwn2+lfEgRMBpsdNZ4HAEHSgPg1hIJE2JL6bDHtegtjVthSKA7pCxOZK4haoelWWn5K4oO87WJSoBwxubAV0ZhklxkZCa6DfuSSxUdsaz0sJSoBOCt9METlNbhDerQh3NnRCzReZDaS5FJ+eT8jLAgJA8d+rFNKQ+UdwG4bkJFxqLySDkTNVWjSJzc1iV3jjfPFvLFjhBpm+MlvmZXQPI6Krf88w2H5SEgdfjsP1fD3g+8tzzp/WN6d/TXzZ+ep0jLd+0Hd2F7FXu1vvmSxLYXtcPb3qup+OKb4F7PWBxeC3e9170r4kF39Pg18RHbvoDiaef2NrhJV8tvj48fXh5/4kqKxBLwzjnjXn5zFBi4VPT1xKmtTHkw+wUHXNdcbqb3i2fn8D9NNpDwr/3quVb7X+g9X+GuEp9emoIBY5qlI17bmG/F3UmdBuimFvab4QdonFIsb8NnU4dik81v1+23K1uIMkJPh+JthuC59Hg/Hp8/EJjz4zAxutcJGOc3pRpDZeixiQ6YTAkVG7FBm+d+kE70QfcUlSrAUaOWEI5O0WJuZYI3/6xCsH40GGDsl8pE0c+ddJ/LvHBKMsroZGiSkJPzsw/YvE90y0ZX0/0Fl5SVOc8Ol22x4VZSe1QwOnyp1YmhMk7+mpOHMCaT8i4KB+X7xe4h7d0tJ8/uP4ztZc7e7Wj4WPu9f+fpaIodD4+/7Q2/7x3/MD76bvTd0ej4b/3h90f/zlm3NPtJly8VdBQ97Nv2p6iAeZnytfO1u0YdBn4XbzKqTuXLAvL679sZsmPbbrNh/6g/TNdj40PN9Z6K+/lyYN8WBCqGQWoW0gOES3yRU2n7kubbYPrkSptO04JVlHijK3Z3t+AePzm12dDwLxEd5ce0YDfcSb6gWF0RYVRdptzlnjpXow69VNa0XMWcGfdYjlI07zgpS7Th2bXTvdo4/3g5ZgVbtA+vtRG0x/E1lTtfsxFj9HabmjJakMbumOJ6FYmYRizLpL//AdPBkwk= +api: eJy1WNtuIzcS/ZUCsQ8J0LrYSDYb5SWOZxZjzAZj2Jo8rKVYpWZJYtxNdnix3DEE7EfsF+6XLIrsllryZSaOxy9Wk6zrqSpW8V54XDoxuhKX5Jwy2olpJkxFFr0y+kyKkchNWRXk6doHq0UmLP0eyPmfjKzF6F7kRnvSnn9iVRUqj5SD35zRvObyFZXIvyrLfL0iF9eTvGsl+cvXFYmRcN4qvRSZ8MoXvNBoBWdSbDLBClwrLemuQ6O0pyXZDtE4WA1n8dgmE7gk7a/3xaGuPyzE6OpQ8CbbruhQFGIz3XE9YT6wrxBpee1VSY+ZsDC2RC9GQqKnXjy1Y/ZWSxjz0obNSpRm/hvlvnGwsiQZlo7ee/Z3ZE8fuIsdcNqgdpHQEhuWtOPsbaC44CqjXYLkeDjkf5JcblXFKDLLkOfk3CIUcNEcFtlLQc9NSERPYncaT2RC0gJD4cVo2OC+D9sBW0voSV6jfw7cRyF5DvHTxBZOPGMdKvklhHxMbBshkhizVxfyJrFthLTumtefSIeOnBCU/Cxn/VQ3udH661WltN7aSmkd9qpSWndtpbwe68SvsoZz/Ynit89tR3ueqBqdXl5AX82YVGiTNt4Sln/SoMtI9FeK+wqtJueub5R+VDLpUHIZrdR1bmysXAUGmUoY8+hY8y7xes+ssoMqOF4R5EYqvYR4n0BlzdJiCQg2aJBW3ZLrwwnMMb8hLUFSXqAlB+uVylegPLhQVcZ615/oiZ7NGo1mM1AOztUPMJsl1Wazhh2cxm84NZLiNivc2eXP/pe+4RxqOTd3f4HrZeLQ8LO0IEs6T4V7x095Kt3DC+OWrFNGv1T2Lw05G1KE5YtNYNovUAZuqH6pSu+pfrR3OMj7i9bdDyL6BLZQABVUMvR+hR5ytFaRA+UdmLWGBZaqqGPUtj0iVOxX53csHKADhEWBHgre+WrWxiPntevvTs6yid5uprKxt/11Bs6AXxGUWMEN1UktjSXJuJwUglAlYlBuopdGE8zruM+3IecloSQLjsjFZWvW8L///BcKwlvO44lYKOs8fPx49gaUjmdY9YlgW/yKJtroooY11uANLAO5Nptbf6nEeW3szaIw6yazb6hOWc17rDbM6BaLEFukaxv2vQFYsKY1BEcOFsZGMsdk7M1eVElLxdiVSqM3Fr6aybkbVMb5pSU32HF3g+BV4fpVzW5ELXfM2EHeYs6mNxBPtNIw+zCmYhsmffTeqnnw5K4mHJ8TMZ1Fu86iuQhVgUpDilCw6FfEGqOG2WzL5X30gNFQBVsZRyNA4DAArCoujozYQlnqoZa9hbFL8hF0SxzFschqCNpSbpZa/UGyxXxtQiFBWlMlx69MQZFzlpCZ6MbtC1RFsAR+pRwsFBUS6E457xjKytItad+Hc2tkyMk6UNopSZH05PyMsWAk9w36oQkpBxx3HlDXfsXKUuGIYyZozkYZa3KTl2gt1s8m8sWuIDKZxZxe84Zmfm0pr/DTl3+F3hN32+LXq2Hve+wtTnr/nN4f/X3zt+dLZIW7VgCt345Fr9bDXjLbZmjan7xeTcRnT2Wd/i3bH9F2fdDBuLbXqTw+sj2i22eUeKbcTmabOOZ9c3z8cJD7BQslY5WAt9Ya+/IpTpJHVfCvJ27twuR7u59xwbWN3mZ6mDw7g/9lkoKMf+mWz7W9P5NzuKRdJj59NDoDxrzLV7yuQppQ25s6LvDU5LuN6QM0TtmXd/6TocO+Seo357rtyhaihNDTrniTIHguPN6Nx+cPGKb42A+M9qUAxul9pyS/MvzwwxdMKgkrMRKDJs7dIN7og/ZZKGaA5UYtIhxswYexUhHe9LnyvnKjwYBCPy9MkP3YsmIfVTo4ZR55sMrXkcnJ+dl7qt/FcitGV9PugUuOyhRn+8e22GCluD3KBF++3OoEvzJW/ZGChzFmlRIVu4Pj/WL3qPX2Dtmyw0eqTuR0JpXhY+13d/5oy5Q4Hh5/0xt+1zv+fnz07ejbo9HxP/rD747+naJuYbpBFztx5KvoYd/W3eIExjzGa2tr3BbZgeN3/malypi+whOWP253WI9tuy2G/aP+MI6qxvkSdUfEYbzs6bcFgZNhEJuF+BhgY71IobR91XKNM100pQmnaSZWHHijK3F/P0dHH22x2fDy74Esx8c0E7doFc7ZV1dcMFZtpNynnjplo/a9mNZ8vAgpMg6qHIdoojjJc6r8s2enndw4/3A5FpmYN4+gpZFMY3HN6Y5rMRKC31FjU8YH4tq9KFAvAxemkUg8+e//pDdqNw== sidebar_class_name: "post api-method" info_path: reference/api/agenta-api custom_edit_url: null diff --git a/docs/docs/reference/api/fetch-tool-action.StatusCodes.json b/docs/docs/reference/api/fetch-tool-action.StatusCodes.json index e8aa4931040..206faeec9e0 100644 --- a/docs/docs/reference/api/fetch-tool-action.StatusCodes.json +++ b/docs/docs/reference/api/fetch-tool-action.StatusCodes.json @@ -1 +1 @@ -{"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"properties":{"count":{"type":"integer","title":"Count","default":0},"action":{"anyOf":[{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories","default":[]},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"read_only":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Read Only"}},"type":"object","required":["key","name"],"title":"ToolCatalogAction"},{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories","default":[]},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"read_only":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Read Only"},"schemas":{"anyOf":[{"properties":{"parameters":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"number"},{"type":"boolean"},{"additionalProperties":"circular(FullJson-Output)","type":"object"},{"items":"circular(FullJson-Output)","type":"array"},{"type":"null"}],"title":"FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Parameters"},"inputs":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"number"},{"type":"boolean"},{"additionalProperties":"circular(FullJson-Output)","type":"object"},{"items":"circular(FullJson-Output)","type":"array"},{"type":"null"}],"title":"FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Inputs"},"outputs":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"number"},{"type":"boolean"},{"additionalProperties":"circular(FullJson-Output)","type":"object"},{"items":"circular(FullJson-Output)","type":"array"},{"type":"null"}],"title":"FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Outputs"}},"type":"object","title":"JsonSchemas"},{"type":"null"}]},"scopes":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Scopes"}},"type":"object","required":["key","name"],"title":"ToolCatalogActionDetails"},{"type":"null"}],"title":"Action"}},"type":"object","title":"ToolCatalogActionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"properties":{"detail":{"items":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"}}}}}} \ No newline at end of file +{"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"properties":{"count":{"type":"integer","title":"Count","default":0},"action":{"anyOf":[{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories","default":[]},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"provider_action_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider Action Id"},"read_only":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Read Only"}},"type":"object","required":["key","name"],"title":"ToolCatalogAction"},{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories","default":[]},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"provider_action_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider Action Id"},"read_only":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Read Only"},"schemas":{"anyOf":[{"properties":{"parameters":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"number"},{"type":"boolean"},{"additionalProperties":"circular(FullJson-Output)","type":"object"},{"items":"circular(FullJson-Output)","type":"array"},{"type":"null"}],"title":"FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Parameters"},"inputs":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"number"},{"type":"boolean"},{"additionalProperties":"circular(FullJson-Output)","type":"object"},{"items":"circular(FullJson-Output)","type":"array"},{"type":"null"}],"title":"FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Inputs"},"outputs":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"number"},{"type":"boolean"},{"additionalProperties":"circular(FullJson-Output)","type":"object"},{"items":"circular(FullJson-Output)","type":"array"},{"type":"null"}],"title":"FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Outputs"}},"type":"object","title":"JsonSchemas"},{"type":"null"}]},"scopes":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Scopes"}},"type":"object","required":["key","name"],"title":"ToolCatalogActionDetails"},{"type":"null"}],"title":"Action"}},"type":"object","title":"ToolCatalogActionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"properties":{"detail":{"items":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"}}}}}} \ No newline at end of file diff --git a/docs/docs/reference/api/fetch-tool-action.api.mdx b/docs/docs/reference/api/fetch-tool-action.api.mdx index 50263c0aec3..3460df5aab5 100644 --- a/docs/docs/reference/api/fetch-tool-action.api.mdx +++ b/docs/docs/reference/api/fetch-tool-action.api.mdx @@ -5,7 +5,7 @@ description: "Get Action" sidebar_label: "Get Action" hide_title: true hide_table_of_contents: true -api: eJztV99v2zYQ/leMe2oBLfKCPflpRtq1XrrFSLy9BIZBU2eLHS2qJBXUE/i/D0dKFmU5XlYYxR7yZFi8X/zuuzteDZZtDUweYaGUNLBMQJWomRWqmGUwgQ1anq+sUnLFOH2FBEqm2Q4talKsoWA7hAmUWj2JDPXqL9xDAqKgb8zmkIDGL5XQmMHE6goTMDzHHYNJDXZfkq6xWhRbSMAKK+nDvDE2usU9OJccvIjC4jbEdxFHs87ewFe48EXcTPlJD5tKylWGlglpWh9fKtT7npMNk+aUl7VSEhllJMMNq6Rt42m9/lJJOXrXmHduSUZNqQqDhsxcj8f0k6HhWpQ+uRN4qDhHYzaVHN03wpAAV4XFwpI4K0spuEcs/WxIp45CKzXxx4rggasqKDUR++yhjoC58RLRDcYuaYD3zor93cazrG+YcnIGbo9z0qD8vNjvdO6SPgKx074iJa75UlRSAgHamnoXmXAJcGZxq3QTrbC4M8NAXNJ+YFqzfYxKpx5B87h0CUi1Vd8a5CfSdcQClq1UIfcnDbW0OmfpHlk2uiMLrruFWn9GbnvUfYRQPT4TkT51mxtmmVTbUBre22uO/6c5bpuPOVeT8ViIpViWCcKMyXlP/gW3a/tFL8zduv8lvstpX8CF5pVk+g11xF+NKn64q2xZ2bdwTF4y0qTyJVohqedQPNKFQbmcU553kDoaD2VlX8G9FLizAKdLQHntV2Qvhexdg+ep2dDKkPGHpqkMbfmOo8pjxF/YZM/F9hDMXmhsHR5XZzy2A+4MGAO7h6eXc6T30/X18KX2J5MiCy/X91or/e3PtPAC7U2xvoBUvHf6X2pg+fwI/KRCgJTAndmeG+O/oTFsi12qnxf1YIwWdNo2TS8el72f3/ZrZGaQkxvC8qv9V6YQNiH8Ri5KfZeikKHnoQhEOkuSj4vFfGAw8KNPjA9oR9N2WduhzRVtclu0fnezOUwgpY3OpDxwLm13N5PW8Rrn0mjdMml9tHy5NDzUTVp3q5KDBAzqp3Y7rLQkf6wUnh3hb25taSZpitUVl6rKrtgWC8uumAiCS7LBKy3s3huZzme3uP+ILEPt30iRgG8igaZ9sUNqWSluu3KewLSyudLib9ZA5NeuPGg5T5mNihkz9cGNpvMZHEPdO6LqY9yTrfXkjyE5unZ3W0gAd772wCLb/Xw4IaoQhsHN+OrHqzF9KpWxO1ZELnrJPnrkNvcnGqelZMIXmg+lbojwCJ4I4F+zRAWiSEsGSGAy2Oo7PtDxcB1vKEGH0f68TCBXht63UNdrZvAPLZ2jz2HdpSRnwrC1jBZevwAc78hPTFZ0KU+SJ6YF6ZzWP0Lj0BzhzX1Tv29H3Uzso9RSp9jHPtuYeqgQrb+j72PIv7P7KKm+tedtUTYLG0w5x9JGqoNJRJk7tKUP7xfg3D+rClrl +api: eJztWN9v2zYQ/leMe2oBzcqCPflpRtq1XrolSLy9BEZAU2ebHS2qJBXUM/i/D0dSFiU5bhoEfVmegoj3i999d7zzHixbG5jcwVwpaWCRgapQMytUOStgAiu0fHNvlZL3jNNXyKBimm3RoibFPZRsizCBSqsHUaC+/wd3kIEo6RuzG8hA45daaCxgYnWNGRi+wS2DyR7sriJdY7Uo15CBFVbSh+tobHSJO3AuO3gRpcV1iO9FHM1aewNf4cIv4mbKj3pY1VLeF2iZkKbx8aVGves4WTFpjnlZKiWRUUYKXLFa2iaexutvtZSjd9G8cwsyaipVGjRk5vzsjP4UaLgWlU/uBG5rztGYVS1HN1EYMuCqtFhaEmdVJQX3iOWfDensk9AqTfyxInjgqg5KMWKfPdQJMBdeIrnBmcsi8N5ZubtaeZZ1DVNOTsDtcc4iyo+L/UnnLusikDrtKlLi4peylhII0MbUu8SEy4Azi2ulY7TC4tYMA3FZ84FpzXYpKq16As3dwmUg1Vo9N8hPpOuytlQjw0XxXIuHOo0EnxVkXyMr7lUpd0fNNrQ9ZfcGWTG6IguuRUktPyO3ndK4g1CdPtOJPnWzC2aZVOsQmff2yqH/KYea5mlO9ZT0WUulWFEICozJ6478E+7a9LtOmNtl90t6l+O+gAvNa8n0G+rovxtV/nRV26q2b6FfHGQkUuUpWoE0p1Ds6cKgHE+mtoXU0fNW1fYV3JcCdxbgdBkor/2K7EshexXxPPb2NDJk/DY2laEt33FU1Uf8iU38VGy3wewLPYuH4fCEx+YBPQHGwO5hdHSO9H45Px9Omn8zKYoweb/XWunnj5lhgu68kl0BqXjn9HtqYPH4E/tJhQApgVuzPjUm/IHGsDW2qX5c1IMxmtNp0zS9eFr2fj6wXxMzg5xcEJZf7TeZQtiE8KNckvo2RSFDj0MRiHSSJB/n8+uBwcCPLjE+oI2zAAWGdqNoE12j9bun3cAEctpITc4D5/JmGDH5Pl1DXZ6siybf95ZHl4fZxeT7dtVzkIFB/dBst7WW5I9VwrMj/LuxtjKTPMd6zKWqizFbY2nZmIkguCAbvNbC7ryR6fXsEncfkRWo/QyWCPgmEmjaFTukllXisi3nCUxru1Fa/MsiRH5t3AQt5ymzUiljpj640fR6Bn2oO0dUfYx7sjWe/DFkvWu3t4UMcOtrDyyy7a+HE6IKYRjcnI1/Hp/5qVEZu2Vl4qKT7N4QHe9PNM4ryYQvNB/KPhLhDjwRwE/LRAVoJ1P6Ohn8KtHygY6HPydEStBhsv8vMtgoQ/Mz7PdLZvAvLZ2jz2FdpyQXwrClTBZ2v2D0d/wHJmu6lCfJA9OCdI7r99A4NEd4cxPr9+2ofRO7KDXUKXepzyamDipE6x/ouw/5D3afJNW39k1TlHEhhCnnWNlEdfASUeYObenD+zk49x99i52v sidebar_class_name: "get api-method" info_path: reference/api/agenta-api custom_edit_url: null diff --git a/docs/docs/reference/api/fetch-tool-integration.StatusCodes.json b/docs/docs/reference/api/fetch-tool-integration.StatusCodes.json index 151d73bfcba..6a388252b94 100644 --- a/docs/docs/reference/api/fetch-tool-integration.StatusCodes.json +++ b/docs/docs/reference/api/fetch-tool-integration.StatusCodes.json @@ -1 +1 @@ -{"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"properties":{"count":{"type":"integer","title":"Count","default":0},"integration":{"anyOf":[{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories","default":[]},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"actions_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Actions Count"},"auth_schemes":{"anyOf":[{"items":{"type":"string","enum":["oauth","api_key"],"title":"ToolAuthScheme"},"type":"array"},{"type":"null"}],"title":"Auth Schemes"}},"type":"object","required":["key","name"],"title":"ToolCatalogIntegration"},{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories","default":[]},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"actions_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Actions Count"},"auth_schemes":{"anyOf":[{"items":{"type":"string","enum":["oauth","api_key"],"title":"ToolAuthScheme"},"type":"array"},{"type":"null"}],"title":"Auth Schemes"},"actions":{"anyOf":[{"items":{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories","default":[]},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"read_only":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Read Only"}},"type":"object","required":["key","name"],"title":"ToolCatalogAction"},"type":"array"},{"type":"null"}],"title":"Actions"}},"type":"object","required":["key","name"],"title":"ToolCatalogIntegrationDetails"},{"type":"null"}],"title":"Integration"}},"type":"object","title":"ToolCatalogIntegrationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"properties":{"detail":{"items":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"}}}}}} \ No newline at end of file +{"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"properties":{"count":{"type":"integer","title":"Count","default":0},"integration":{"anyOf":[{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories","default":[]},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"actions_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Actions Count"},"auth_schemes":{"anyOf":[{"items":{"type":"string","enum":["oauth","api_key"],"title":"ToolAuthScheme"},"type":"array"},{"type":"null"}],"title":"Auth Schemes"}},"type":"object","required":["key","name"],"title":"ToolCatalogIntegration"},{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories","default":[]},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"actions_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Actions Count"},"auth_schemes":{"anyOf":[{"items":{"type":"string","enum":["oauth","api_key"],"title":"ToolAuthScheme"},"type":"array"},{"type":"null"}],"title":"Auth Schemes"},"actions":{"anyOf":[{"items":{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories","default":[]},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"provider_action_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider Action Id"},"read_only":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Read Only"}},"type":"object","required":["key","name"],"title":"ToolCatalogAction"},"type":"array"},{"type":"null"}],"title":"Actions"}},"type":"object","required":["key","name"],"title":"ToolCatalogIntegrationDetails"},{"type":"null"}],"title":"Integration"}},"type":"object","title":"ToolCatalogIntegrationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"properties":{"detail":{"items":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"}}}}}} \ No newline at end of file diff --git a/docs/docs/reference/api/fetch-tool-integration.api.mdx b/docs/docs/reference/api/fetch-tool-integration.api.mdx index 1b8ea28cc02..0af7ecf80ca 100644 --- a/docs/docs/reference/api/fetch-tool-integration.api.mdx +++ b/docs/docs/reference/api/fetch-tool-integration.api.mdx @@ -5,7 +5,7 @@ description: "Get Integration" sidebar_label: "Get Integration" hide_title: true hide_table_of_contents: true -api: eJztV1Fv2zYQ/ivCPW0AYXnBnvw0I+1aI90aJO5eAsNgpLPEjhJVkgqqGfrvw5GSTFm2t6bB9pInwyLvu+N3H493e7A8M7B4gLVS0sCGgapQcytUuUphATu0Sb61SsmtKC1mfgkYVFzzAi1qst5DyQuEBVRaPYkU9fZPbICBKOkbtzkw0PilFhpTWFhdIwOT5FhwWOzBNhXZGqtFmQEDK6ykD7cdWHSDDbQtG7wEkbyIo9UBb+JrV0u5TdFyIU3v6EuNuhl52nFpTrl6VEoiJ75S3PFa2j6o3vWvtZTRmw6+bTcEaipVGjQEczWf00+KJtGictQv4L5OEjRmV8vortsMDBJVWiwtbedVJUXizhN/NmSzD0KrNKXYCu8hUbU36iJ23KIO2Ll2O4ITzFsWpsB5LJuPOyeEMTpl5wLxjmzWUX1+2++03rIxDaHTsSFlr/tS1lICsdpDvQkgWgYJt5gp3UUrLBZmGkjL+g9ca96E1BzMA34eNi0DqTL13CA/kG3LoNbyuRCftCQEntBJzXbI8gSrz/clsKVHibwSCLa2+dYpyhN3QD1DIQMs64LqjCJbYMAr4e5u4IZK0LK2+b0DntB+McLa5tF9F1B7sFSPnzGxo6v6AL5kONEdeb/mlkuVBfXAeX3V9Kum/29ND+c+E9urRl9Ooxp5ulWlbE4C9W/6JaQ75Gn0kRC+uxh5oX6bcjqlvGQhHHqUC35HdfOE78sehl6mbcn456uraevzB5ci9Y3aW62Vfn7f41u6kfrGG6RKRqv/QlBD3dmcl+4HlfQPCxQmu3T9fkNjeBYUjfNbHRnRmlZdc1bVvqcbEkMf6N7ZrwHMJDHXxOVX+4/CIW58+N2+QASHFPkMnafCS+qiUt6v17cTQK+PsTDeoY1WowGlQJsrGmEytG5esTksIKZRxsSJV1/czysm3oejSxsH/a2J90cDRwsMDOqnfvhxbxrEvBJODf5vbm1lFnGM9SyRqk5nPMPS8hkXfuOGMJJaC9s4kOXt6gab98hT1K6WBRvuD8/SeNuQSl6Jm8Nt9o+H0uKvng03t+TeqnUS2alQIUsXXLS8XcExtaMlum08ceLqPbllYEfHPpyWHsrC3TWwyItfhhWSBnHo3cxnP83m9KlSxha8DFxMk3v0InUkkHbjSnJRBp2GT/wDuMSDe3oo9SSJPvnAYDGZXA/5p+XjkXPDIFeGHh3Y7x+5wU9ati199sMhZTQVhj/KYDx0r/LxRPnEZU3BO0U8cS3I5rT90amHygc/3HWX88cI2Gk2ep2UTeizj2l0etLwf+j7mFpXPfP+HnS9DCyTBCsb2E+KPfE3XPp3b9fQtn8DueTCEA== +api: eJztV02P2zYQ/SvCnFqAsNxFTz7V2KSJsWmz2HV6WRgGVxpLTGlRIalFVEP/vRhSH5Rlu81m0V72ZFjkvBm+eRzOHMDyzMDiAdZKSQMbBqpEza1QxSqFBezQJvnWKiW3orCY+SVgUHLN92hRk/UBCr5HWECp1ZNIUW//xBoYiIK+cZsDA41fKqExhYXVFTIwSY57DosD2LokW2O1KDJgYIWV9OG2BYtusIamYb2XIJIXcbQa8Ca+dpWU2xQtF9J0jr5UqOuRpx2X5pSrR6UkcuIrxR2vpO2C6lz/WkkZvWnhm2ZDoKZUhUFDMFfzOf2kaBItSkf9Au6rJEFjdpWM7trNwCBRhcXC0nZellIk7jzxZ0M2hyC0UlOKrfAeElV5ozZixy3qgJ1rtyM4wbxhYQqcx6L+uHNCGKNTdi4Q78hmLdXnt/1O6w0b0xA6HRtS9tovRSUlEKsd1JsAomGQcIuZ0m20wuLeTANpWPeBa83rkJrBPODnYdMwkCpTzw3yA9k2DCotnwvxSUtC4Amd1Gz7LE+wunxfAlt6lMgrgWArm2+dojxxA+oZChlgUe2pziiyBQa8FO7uBm6oBC0rm9874AntFyOsbB7dtwE1g6V6/IyJHV3VB/Alw4nuyPs1t1yqLKgHzuurpl81/X9ruj/3mdheNfpyGu27GE/5VqTPRexbGC+3aJUSvkaeblUh65OwXc9wCfcOeRp9JITvLnY+sm9TZqvElyy0fQ90we+oLp/wfdlD3ys1DRn/fHU1ba3+4FKkvhF8q7XSz++rfMs4Uvd4g1TJaPVfyKuva5vzV+ODSrqHC/Ymu3S9f0NjeBYUpfNbHRnRmlZd81dWvmfsE0Mf6F7brwHMJDHXxOVX+4/CIW58+O2+QARDinyGzlPhJXVRKe/X69sJoNfHWBjv0Ear0QC0R5srGpEytG4esjksIKZRycSJV1/cVRITH8LRqImD/tnEh6OBpgEGBvVTN1y5NxNiXgqnBv83t7Y0izjGapZIVaUznmFh+YwLv3FDGEmlha0dyPJ2dYP1e+Qpalcrgw33w7M33tankpfiZrjN/nFSWvzVseHmotxbNU4iOxUqZOmCi5a3KzimdrREt40nTlydJ7cM7OjYw2npId67uwYW+f6XfoWkQRx6N/PZT7O5q+7K2D0vAhfT5B69eC0JpN24lFwUQSfjE/8ALvHgnjZKPQzPCH1dTCbjIf+0fDzSbhjkytCjBofDIzf4Scumoc9++KSMpsLwRxmMn+7VP55Yn7isKHiniCeuBdmctj86dV/54Ie79nL+GAE7zUank6IOfXYxjU5PGv4PfR9T66pn3t2DtleCZZJgaQP7SbEn/vpL/+7tGprmb5Oa43U= sidebar_class_name: "get api-method" info_path: reference/api/agenta-api custom_edit_url: null diff --git a/docs/docs/reference/api/fetch-turn.StatusCodes.json b/docs/docs/reference/api/fetch-turn.StatusCodes.json index 7db6605d51d..047eb937193 100644 --- a/docs/docs/reference/api/fetch-turn.StatusCodes.json +++ b/docs/docs/reference/api/fetch-turn.StatusCodes.json @@ -1 +1 @@ -{"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"properties":{"count":{"type":"integer","title":"Count","default":0},"turn":{"anyOf":[{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"project_id":{"type":"string","format":"uuid","title":"Project Id"},"session_id":{"type":"string","title":"Session Id"},"turn_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Turn Id"},"stream_id":{"type":"string","format":"uuid","title":"Stream Id"},"turn_index":{"type":"integer","title":"Turn Index"},"harness_kind":{"type":"string","enum":["pi_core","claude","pi_agenta","codex"],"title":"HarnessKind","description":"The coding agent program a run drives. A backend declares which it supports.\n\n``pi_core`` is plain Pi; ``pi_agenta`` is Pi with Agenta's forced skills, prompt, and\npolicy. Both drive the same ``pi`` ACP agent in the runner; ``claude`` drives Claude Code."},"agent_session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Session Id"},"sandbox_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sandbox Id"},"references":{"anyOf":[{"items":{"properties":{"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key"}},"type":"object","title":"SessionReference","description":"A reference element that carries its own family.\n\nSessions persist references as a flat list (`session_turns.references`,\n`session_streams.references`), so the map key that named the family upstream is\ngone by the time a reader sees the row — leaving \"first UUID in the list\" as the\nonly way to guess which element is the workflow.\n\n``key`` is the name `evaluation_runs.references` already uses for the same flat-list\ndiscriminator (`dbs/postgres/evaluations/utils.py`), and the same one tracing carries\nin `OTelReference.attributes[\"key\"]`.\n\nIt is a plain string rather than ``ReferenceKey`` on purpose: a turn append is\nfire-and-forget, so rejecting an unrecognized family would drop the whole turn, which\nis the failure this field exists to prevent. Producers inside the API use\n``ReferenceKey``; readers treat anything else as untyped."},"type":"array"},{"type":"null"}],"title":"References"},"trace_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Trace Id"},"span_id":{"anyOf":[{"type":"string","pattern":"^[0-9a-fA-F]{16}$"},{"type":"null"}],"title":"Span Id"},"start_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Start Time"},"end_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"End Time"}},"type":"object","required":["project_id","session_id","stream_id","turn_index","harness_kind"],"title":"SessionTurn"},{"type":"null"}]}},"type":"object","title":"SessionTurnResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"properties":{"detail":{"items":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"}}}}}} \ No newline at end of file +{"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"properties":{"count":{"type":"integer","title":"Count","default":0},"turn":{"anyOf":[{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"project_id":{"type":"string","format":"uuid","title":"Project Id"},"session_id":{"type":"string","title":"Session Id"},"turn_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Turn Id"},"stream_id":{"type":"string","format":"uuid","title":"Stream Id"},"turn_index":{"type":"integer","title":"Turn Index"},"harness_kind":{"type":"string","enum":["pi_core","claude","codex"],"title":"HarnessKind","description":"The coding agent program a run drives. A backend declares which it supports.\n\n``pi_core`` is Pi; ``claude`` drives Claude Code; ``codex`` drives Codex."},"agent_session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Session Id"},"sandbox_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sandbox Id"},"references":{"anyOf":[{"items":{"properties":{"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key"}},"type":"object","title":"SessionReference","description":"A reference element that carries its own family.\n\nSessions persist references as a flat list (`session_turns.references`,\n`session_streams.references`), so the map key that named the family upstream is\ngone by the time a reader sees the row — leaving \"first UUID in the list\" as the\nonly way to guess which element is the workflow.\n\n``key`` is the name `evaluation_runs.references` already uses for the same flat-list\ndiscriminator (`dbs/postgres/evaluations/utils.py`), and the same one tracing carries\nin `OTelReference.attributes[\"key\"]`.\n\nIt is a plain string rather than ``ReferenceKey`` on purpose: a turn append is\nfire-and-forget, so rejecting an unrecognized family would drop the whole turn, which\nis the failure this field exists to prevent. Producers inside the API use\n``ReferenceKey``; readers treat anything else as untyped."},"type":"array"},{"type":"null"}],"title":"References"},"trace_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Trace Id"},"span_id":{"anyOf":[{"type":"string","pattern":"^[0-9a-fA-F]{16}$"},{"type":"null"}],"title":"Span Id"},"start_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Start Time"},"end_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"End Time"}},"type":"object","required":["project_id","session_id","stream_id","turn_index","harness_kind"],"title":"SessionTurn"},{"type":"null"}]}},"type":"object","title":"SessionTurnResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"properties":{"detail":{"items":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"}}}}}} \ No newline at end of file diff --git a/docs/docs/reference/api/fetch-turn.api.mdx b/docs/docs/reference/api/fetch-turn.api.mdx index 374f8632f2b..fa3698f9378 100644 --- a/docs/docs/reference/api/fetch-turn.api.mdx +++ b/docs/docs/reference/api/fetch-turn.api.mdx @@ -5,7 +5,7 @@ description: "Fetch Turn" sidebar_label: "Fetch Turn" hide_title: true hide_table_of_contents: true -api: eJy1WOtuGzcWfpUDYoGmwFhyg8UCVf+s6qQbIy1qxEr/WKpEDc9oWHNIlhc7U0FAH2KfcJ9kcciZkRTFrjfr/NJozv328XC2LPCNZ5Mbdo3eS6M9WxTMWHQ8SKMvBZuwCkNZL0N0mhXMcscbDOhIaMs0b5BNGBGXUrCCSc0mzPJQs4I5/D1Kh4JNgotYMF/W2HA22bLQWhLzwUm9YQWrjGt4YBMWY9ISZFDEMItOw6Vgu92C1HlrtEdPGl6en9OPQF86aclXNmHXsSzR+yoqeNcxs4KVRgfUgdi5tUqWKbTxb55ktgdeWUeBB5ktlCZmoc5ZqQNu0B14d5E4Ciaw4lEFNjnfFSkVyZRuf65Sjj5S65AHFEsejrkeTIngAc+CbJDtioFNR6UYJWXwJauFaWC7gkUrvoSR91ltZ0Sgwi9g5FVW2xnp07Vuqb+eZic10VOS9X2bmmufr2e10mdrsNIn7Fmt9OkarDyf6qzPOvMblqFz+amTe5WlOp98BpcHdPQyHQR1Mj2mPFMwA5QUJI28+R8Duk5CR75pgR8eBYhsM7HtClZzp9H75a3Un7SMOjaExFYuS+MScikeBT1YueQb1IEnOCN9B5G9yXrfktriI0Sc1QilEVJvICkA68zG8QY4uKhBOHmHfgRTWPPyFrUAgaXiDj3c17KsQQbw0Vrjgh/N9VyvVp13qxVID1ZxqeFKfgeJkH3MpCsJ9zLUME3vvvJQGVeiAH8rlfIFOdLYUADXYq6tUbJsR/C9CXV2CkKN4HmDSfNqBdOLqy4EqRPRRa3RkeWcptWqCwcu0n+4MAJHlPgktjzuwYda6rEWSqHAcZd6rsXafPg/tF5nDZ0+hxU61GU+LPb6ZMDGnx5Sd+i8NPpzbf/SiVMgKm4+OwSS/QLQc4vt57r0Flu2o0nNdLMmNDrFmnd9uk8mZwpDKQAVNlT6UPMAJXdOogcZPJh7DRVvpGrTdPQ7FFjKqw97FR64Bw6V4gEUUV6s+n4kLPGjPeeqmOuBmKHqiPx1Ad6kCWi4hVtss1u0iYn0OjsE0WZhkH6uN0YjrNtEpxOY5h+5QAce0ed5Mvfwnz//DQr5HeHFnFXS+QDv31++6meOXJ8ziiXUONdGqxbueQvBwCai71Gjz5fMmu+Nu62Uue8Q5BbbDBFE02nE8Y6rmNaypYvH2QCuyNMWoscEIntkoGyeJZe0kFS7RmoejIMXK7H2Y2t82Dj04712P45BKj+yLaWRa7FXRgkKjpcUelfiuZYaVj/PUA1tMuIhOLmOAf3NnPpzzharFNdlCpd3kJg7FBwPNZLHXMNqNWh5mzJgNNjorPE4AQ7UBsCtJRCmilXS4RnX4qwyboMhFd0hdXECcw1ROyzNRss/UPQ1vzdRCRDO2Jz42ihMmotcmbnu0l5xqaIjlJUeKolKAH6QPngqpXV4hzqM4MoZEUt0HqT2UmRQnl5dUi2okscBfde1lAfquwBct6EmZ1F5pJ6JmqZRJEzu5pI7x9tHB/ndHhBJzPESn3MrIH09lFv+1wuH5SEgbfjs15vzs2/5WTU9+2Gx/eYfu789DpGW79cP7sIy7cHPuTdfk1qYZTaGWjy/iddadAY+gaz7297N4c54tPwd7l5HW9RH29HiBKhplfqEb0+AeJIcboO7HUn8/eXL08vjL1xJkVACXjtn3OffHAUGLhU9PXBqK1MeUZ9wwPXL5W7x8fDsA/7RZAep/o3fPLZq/4Te8w3uJ/Fh1pQMmBGVjnhtY74V9yd1ekE3tXC4DJ9U44Jy+SH8ZetQbrL7Hd/hujKUKFfo4VS8yiV4rD3ezGZXJwpzfxw3xg/0/QNm+ftHg6E29FVkgyHDQc0mbNz1uB+n03y87S4vu9T+7q7/XBKdIm5uZapt/luHYP1kPMY4KpWJYpTX6BGXmXFBOsroZGiTkunV5Vts3ySsZZObxSHDNbVkbrJjtqEw3ErajYr+0800hto4+UfunO4DTp2ldqnglTmsd97n6RQ4XZkOSTQ7vEyt0lvqbzDHYe+jpTtQkyaHBeTNPwcKFXrYdNn56JvRebqZGh8arg9MHJXqyLkhfmrCcTqk08XfpTnNZbzpocp34ES/k/4euihYbXwgtu12zT2+d2q3o9e/R3RUmkXB7riTfE2JutkyIT09CzapuPJ44tEAL+zFu24CvgZWfNrTvnyaakcLDf1j3Y48XJYTPNR9a2w76rQs0YYDuRM0ox4aWvtfr2dst/svW+Hrtg== +api: eJy1WN1u28gVfpWDQS+yAC15g6JAtTdVk2xjpMUasbI3lmodcQ7FWZMz3PmxwxUI9CH6hH2S4syQlGTFXjd1riTy/P99c4Y74XHrxOxaXJFzymgnVpkwDVn0yugLKWaiIJ+XNz5YLTLRoMWaPFkW2gmNNYmZYOKNkiITSouZaNCXIhOWfg3KkhQzbwNlwuUl1ShmO+HbhsWct0pvRSYKY2v0YiZCiFq88hUzLILVcCFF161YnWuMduRYw+vzc/6R5HKrGvZVzMRVyHNyrggVfOyZRSZyoz1pz+zYNJXKY2jTXxzL7A68aiwH7lWykJuQhHpnlfa0JXvg3ZvIkQlJBYbKi9l5l8VURFO6/amIOXqg1hJ6kjfoj7keTYlET2de1SS6bGTToaoEJ2X0JamFuRddJkIjv4WRT0ltb0RSRd/AyNuktjcypGvTcn89z05souck669tbK59vl7UypCt0cqQsBe1MqRrtPJyqpO+xppfKPe9y8+d3Msk1fvkErg8omOQ6SGolxkw5YWCGaEkY2nC+n8M6CoKHfmmJX1+EiCSzcjWZaJEq8m5m1ulv2iZdKgZiRt1kxsbkavCIBOEsY6DaN4nXR9YVfYABRclQW6k0lvALWkPjTVbizUg2KBBWnVHbgJz2GB+S1qCpLxCSw7uS5WXoDy40DTGejdZ6qVer3uP1mtQDi7VD7BeJ9fW614dvInP8MZIimR2+IDKjxNOQvTo5rgfHivvU+Wcx8iOO8ahlhvz+f/QepU09PosFWRJ5wm49/qUp9qdHhh3ZJ0y+mtt/9yLcyBV2H51CCz7DWDgltqvdekDtaLjqUl0s2FkOJ37j0O6Tzp6DmMpgCqqufS+RA85WqvIgfIOzL2GAmtVtbFrh30GGs6r83sVDtABQlGhh4opr9ZDP/Jcu8mec50t9UhMsHFE/i4DZ8CXBDU2cEttcou3IhlfJ4cgNEkYlFvqrdEEmzbS+TTkuSSUZMERufjamnv4z7/+DRXhHc/xUhTKOg+fPl28BaUjD7u+FByLL2mpja5auMcWvIFtIDdM85AvlTTfG3tbVOa+n+xbatNUM43dhjXdYRXiinRjw3E2ACv2tIXgyEFhbBRzLMbZPIsuaam4drXS6I2FV2u5cdPGOL+15KZ77W4avKrcpGk5jajlXhknyFvMOfS+xEutNKx/WlA1tskEvbdqEzy56yX351Ks1jGuixguQlOh0pA6FCz6kthj1LBej1o+xAwYDU2wjXE0AwRuA8CmYXDkihXK0hlqeVYYuyUfi26JuziCrIagLeVmq9VvJIea35tQSZDWNCnxpakoas5SZZa6T3uBqgqWwJfKQaGokkCflfOOS9lYuiPtJ3BpjQw5WQdKOyUpis4vL7gWXMnjgH7oW8oB950H1K0v2VmqHHHPBM3TKCMm93OJ1mL75CB/3AMii1nM6SVPaNY3QHmDv3/4N+g98bYt/nl9fvZnPCvmZz+udt//qfvD0xDZ4H4VQOtv4k76kjvsFauFRWITpOXLm3inZW/gC8i6v3ldH+5vR4vY4R50tNE82FRWJ0DNa80XfHsGxLPkeDPrOpb44+vXpxe5n7FSMqIEvLPW2K+/xUnyqCr+98ipXZn8iPqMA25Y9LrVw+HZB/x3kxzk+tdu+9Ta+w9yDre0n8THWWMyYMFUPuJ1E9INdTip4wu+NfnDxfSkGm84l5/977YO5ya53/MdritjiVKFHk/F21SCp9rj/WJxeaIw9cdxY/zI3yJgkb5F1ORLw18otuQTHJRiJqZ9j7tpPM2nu/4i0cX2t3fDp4tgK+bGRsXapsfS+8bNplMKk7wyQU7ivooTVIlxxTryYJVvo5L55cUHat9HrBWz69UhwxW3ZGqyY7axMNgo3o2y4TPKPPjSWPVb6pz+Y0qZpLpY8MIc1jsuwcinwOnKdEji2cE8tspgKZJF9iDsfbR8H6nj5AhPWP9lpHChx01XnE++n5zHW6JxvkZ9YOKoVEfOjfFzE07jIR0v4TbOaSrj9QBVrgcn/p0Nd8JVJkrjPLPtdht09MlWXcevfw1kuTSrTNyhVbjhRF3vhFSO/0sxK7BydOLRCC/i1cd+Ar4DkX3Z06F8mmvHCw0/iX5HHi+uER7KoTV2PXWe59T4A7kTNOMeGlv7b+8Wouv+Cwk6wuQ= sidebar_class_name: "get api-method" info_path: reference/api/agenta-api custom_edit_url: null diff --git a/docs/docs/reference/api/list-tool-actions.StatusCodes.json b/docs/docs/reference/api/list-tool-actions.StatusCodes.json index 9032711e0c0..47b810d7bb0 100644 --- a/docs/docs/reference/api/list-tool-actions.StatusCodes.json +++ b/docs/docs/reference/api/list-tool-actions.StatusCodes.json @@ -1 +1 @@ -{"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"properties":{"count":{"type":"integer","title":"Count","default":0},"total":{"type":"integer","title":"Total","default":0},"cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"},"actions":{"items":{"anyOf":[{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories","default":[]},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"read_only":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Read Only"}},"type":"object","required":["key","name"],"title":"ToolCatalogAction"},{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories","default":[]},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"read_only":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Read Only"},"schemas":{"anyOf":[{"properties":{"parameters":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"number"},{"type":"boolean"},{"additionalProperties":"circular(FullJson-Output)","type":"object"},{"items":"circular(FullJson-Output)","type":"array"},{"type":"null"}],"title":"FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Parameters"},"inputs":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"number"},{"type":"boolean"},{"additionalProperties":"circular(FullJson-Output)","type":"object"},{"items":"circular(FullJson-Output)","type":"array"},{"type":"null"}],"title":"FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Inputs"},"outputs":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"number"},{"type":"boolean"},{"additionalProperties":"circular(FullJson-Output)","type":"object"},{"items":"circular(FullJson-Output)","type":"array"},{"type":"null"}],"title":"FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Outputs"}},"type":"object","title":"JsonSchemas"},{"type":"null"}]},"scopes":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Scopes"}},"type":"object","required":["key","name"],"title":"ToolCatalogActionDetails"}]},"type":"array","title":"Actions","default":[]}},"type":"object","title":"ToolCatalogActionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"properties":{"detail":{"items":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"}}}}}} \ No newline at end of file +{"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"properties":{"count":{"type":"integer","title":"Count","default":0},"total":{"type":"integer","title":"Total","default":0},"cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"},"actions":{"items":{"anyOf":[{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories","default":[]},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"provider_action_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider Action Id"},"read_only":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Read Only"}},"type":"object","required":["key","name"],"title":"ToolCatalogAction"},{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories","default":[]},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"provider_action_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider Action Id"},"read_only":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Read Only"},"schemas":{"anyOf":[{"properties":{"parameters":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"number"},{"type":"boolean"},{"additionalProperties":"circular(FullJson-Output)","type":"object"},{"items":"circular(FullJson-Output)","type":"array"},{"type":"null"}],"title":"FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Parameters"},"inputs":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"number"},{"type":"boolean"},{"additionalProperties":"circular(FullJson-Output)","type":"object"},{"items":"circular(FullJson-Output)","type":"array"},{"type":"null"}],"title":"FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Inputs"},"outputs":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"number"},{"type":"boolean"},{"additionalProperties":"circular(FullJson-Output)","type":"object"},{"items":"circular(FullJson-Output)","type":"array"},{"type":"null"}],"title":"FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Outputs"}},"type":"object","title":"JsonSchemas"},{"type":"null"}]},"scopes":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Scopes"}},"type":"object","required":["key","name"],"title":"ToolCatalogActionDetails"}]},"type":"array","title":"Actions","default":[]}},"type":"object","title":"ToolCatalogActionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"properties":{"detail":{"items":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"}}}}}} \ No newline at end of file diff --git a/docs/docs/reference/api/list-tool-actions.api.mdx b/docs/docs/reference/api/list-tool-actions.api.mdx index 58ccae1c88c..d7a507bc2d8 100644 --- a/docs/docs/reference/api/list-tool-actions.api.mdx +++ b/docs/docs/reference/api/list-tool-actions.api.mdx @@ -5,7 +5,7 @@ description: "List Actions" sidebar_label: "List Actions" hide_title: true hide_table_of_contents: true -api: eJztWN1v2zYQ/1eMe2oBLXKDPelpRtq1XrLFS7y9BIZBS7TNjhJVfgT1DP7vxZGSRUm26iUpsIc8Gabui7/73ZHHPWiyUZA8wFwIrmARgSipJJqJYppBApwpvdRC8CVJcVFBBCWRJKeaSlTcQ0FyCgmUUjyyjMrlP3QHEbAC14jeQgSSfjFM0gwSLQ2NQKVbmhNI9qB3JeoqLVmxgQg00xwXZpWx0TXdgbXRwQsrNN34+F7E0bSx1/P1xVB58FD/aVysCVctH6TY3a4dJG1vaLJaKQznYBeN/z+d2dBrSjTdCMmoeo5rIiVxsWuaqz4AdiimqyaCMDDOcqafE5PLHZWDeNw4Jy08jFRC/uA0XHknod+14XyZUU0Y/y+ZqDyshOCUFBBBRtfEcH0QrV3+ajgfva/sWwxGUlWKQlGXr8vxGH8yqlLJSuQnJHBv0pQqtTZ8dFcJQwSpKDQttNt7WXKWOjrHnxXq7IPYSonFrZn3kArjlTrpCVBxEsEWxjYCLTThg2pzJ9FRq9L4zPxEULehZN9wu7HY3iE2iIHad/UeVfk+LfYHfrdROxVP28b7wARi0hRauJ2uvW5NH6nTAOyHhY2Ai414apA3qGuRjiRbioLvjhqqCT5k6Y6SbHSLFmyzC7H6TFPdKqIH8K3cZWIREknwK6IJF5tJWoH2muP/bY7rNjhYk+HlIZQiWcYQM8JnLfkzdnf0XMlX7ZVwL8d9QcpkajiRb7A1/6ZE8dOt0aXRb6FLXjRSpfIcLZ/UIRQ7utArlyHlWQOpxYOqNPoV3JcCd+rhtBEIp/2K7Eshe1vheexsqGXQ+H3VVPq2XMcRZRfxM5vsUGz33uwLHVuHW97idKOfHMarsMsPQNPzog5XQmtR8efLy/4N8m/CWebHnQ9Sunv1E6+P/mrcOtTaAlykJ25p3y+JAaBuhA8Q85mrzdCp/jtVimxok/nTog6M0Ry/1j3UiYddwB3n+mtgppeUK8Tyq/4ucRAbH34lF9CnSZHP0GkoPK8GC+jTfD7rGfT8aBPjhik9akiYU70V+ACwodqN/HoLCcT4EqDi1NMurkd+Fe/D6d/GwZSu4n1nZrdxdYePIQJF5WP9kGAkRx+kZI4T/u9W61IlcUzNRcqFyS7IhhaaXBDmBRdoIzWS6Z0zMplNr+nuEyUZla6EAgHXSTw522LN2Fyy66amE5gYvRWS/esZV02BW69lHVHWIuTJxAU3msym0AW49QlrjqSOYrUn9xmizrab3UIENHcVB5qS/JfDFyQIYujdjC/eXYxxqRRK56QIXHRS3LnrVgggfeOSE+YKzAWzr9L/AC794C61SAAkRk0BiCDpPQE1LMDP/beb5k0JC2ArFHY92O9XRNG/JLcWl/3QjbnNmCIrHozd7vJ/mMofCTe4BceeE7Kt15VzFOpXj7OM108V5wh33hcalQX+kQx1ju+6k7hD/4Y3d1WLeTtqTvF2QmueFy3A6phaCTwa+4/z3WWHOwO2dR1Xgx5M0pSWYT56Rxbid+heHz/MwdpvLx0lHg== +api: eJztWEtz2zYQ/iuaPSUzrKl4euKpGidNVLu1a6u9eDQamIQkpCDB4OGJquF/zyxAEiApMartnOqTRuC+8O23C2D3oMlGQXIPCyG4gmUEoqSSaCaKeQYJcKb0SgvBVyTFRQURlESSnGoqUXEPBckpJFBK8cgyKlf/0B1EwApcI3oLEUj6xTBJM0i0NDQClW5pTiDZg96VqKu0ZMUGItBMc1y4qY1NLukOqipqvbBC042L70Uczb29ga8vhsrWQ/PHu1gTrjo+SLG7XltIut7QZL1SGM6hWnr/f1qzodeUaLoRklH1HNdESmJj1zRXQwCqsZgufARhYJzlTD8nJps7KkfxuLJOOngYqYT8wWm4cE5Cv2vD+SqjmjD+XzJRe3gQglNSQAQZXRPDdSvauPzVcD55X9uvMBhJVSkKRW2+zqdT/MmoSiUrkZ+QwJ1JU6rU2vDJbS0MEaSi0LTQdu9lyVlq6Rx/VqizD2IrJRa3Zs5DKoxT6qUnQMVKBFuYVhFooQkfVVtYiZ5ancZn5ieCpg0le89tb7G7Q2wQI7Vv6z2q831c7A/8XkXdVDxtG+8DE4iJL7RwO317/Zo+UKcB2PfLKgIuNuKpQV6hbhX5hu4gX7HsqRbbbj6zlibzDO1LSrKVKPjuoNmmgMbs3lKSTa7RQuVREg+faao7RXoP7qiwmV6GRBX8gmjCxcZFZr29cuh/yqGmjY/2lPDyE0qRLGMYGOE3HfkT9nrwXMwfuivhXg77gpTJ1HAi3+DR8psSxU/XRpdGv4V+caCRmiqnaDnSjKHY04VBOY6m1kNa4UFbGv0K7kuBO3dwVhEIq/2K7Eshe13jeejsaWTQ+F3dVIa2bMcRZR/xE5v4WGx3zuwLHYvtLXV5/CCZtc/D8BQZgWbgRbVX2qpCxZ/Pz4c34L8JZ5l7rn2Q0r4Lnnj9dVf7zqHZFeAiPXLL/H5JjAB1JVyAmM9cbcZuDb9TpciG+swfF7VgTBb4temhVjzsAva6oL8GZgZJuUAsv+rvEgexceHXcgF9fIpcho5D4Xg1WkCfFoubgUHHjy4xrpjSE0/CnOqtwAHGhmo7stBbSCDGSYaKU0e7uLmdqHgfTi+qOJgyqHjfmzlUcf0GiSECReVjMwgxkqMPUjLLCfd3q3Wpkjim5izlwmRnZEMLTc4Ic4JLtJEayfTOGpndzC/p7hMlGZW2hAIB20kcObti/tlfsktf0wnMjN4Kyf51jKtfsVunVVmirEXIk5kNbjK7mUMf4M4nrDmSWoo1nuxniHrb9ruFCGhuKw40Jfkv7RckCGLo3EzP3p1N7dVRKJ2TInDRS3HvLl0jgPSNS06YLTAbzL5O/z3Y9IO9NCMBwF9QcTUZjLA8C/DzcPbkZ2JYAFuhsOvBfv9AFP1L8qrCZTc0wNxmTJEHHowN7OOinSo8Em5wC5Y9R2Q706FTFJqpzUnGm1HLKcK9+YhXWeIfyVDn8K57iWv7N7y5rVvM24k/xbsJbXhedABrYuok8GDsP853nx32DNg2dVw/JGGWprQM8zE4shC/tnt9/LCAqvoG64Vn6A== sidebar_class_name: "get api-method" info_path: reference/api/agenta-api custom_edit_url: null diff --git a/docs/docs/reference/api/list-tool-integrations.StatusCodes.json b/docs/docs/reference/api/list-tool-integrations.StatusCodes.json index da372a3ff13..a3d69781b5d 100644 --- a/docs/docs/reference/api/list-tool-integrations.StatusCodes.json +++ b/docs/docs/reference/api/list-tool-integrations.StatusCodes.json @@ -1 +1 @@ -{"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"properties":{"count":{"type":"integer","title":"Count","default":0},"total":{"type":"integer","title":"Total","default":0},"cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"},"integrations":{"items":{"anyOf":[{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories","default":[]},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"actions_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Actions Count"},"auth_schemes":{"anyOf":[{"items":{"type":"string","enum":["oauth","api_key"],"title":"ToolAuthScheme"},"type":"array"},{"type":"null"}],"title":"Auth Schemes"}},"type":"object","required":["key","name"],"title":"ToolCatalogIntegration"},{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories","default":[]},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"actions_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Actions Count"},"auth_schemes":{"anyOf":[{"items":{"type":"string","enum":["oauth","api_key"],"title":"ToolAuthScheme"},"type":"array"},{"type":"null"}],"title":"Auth Schemes"},"actions":{"anyOf":[{"items":{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories","default":[]},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"read_only":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Read Only"}},"type":"object","required":["key","name"],"title":"ToolCatalogAction"},"type":"array"},{"type":"null"}],"title":"Actions"}},"type":"object","required":["key","name"],"title":"ToolCatalogIntegrationDetails"}]},"type":"array","title":"Integrations","default":[]}},"type":"object","title":"ToolCatalogIntegrationsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"properties":{"detail":{"items":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"}}}}}} \ No newline at end of file +{"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"properties":{"count":{"type":"integer","title":"Count","default":0},"total":{"type":"integer","title":"Total","default":0},"cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"},"integrations":{"items":{"anyOf":[{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories","default":[]},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"actions_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Actions Count"},"auth_schemes":{"anyOf":[{"items":{"type":"string","enum":["oauth","api_key"],"title":"ToolAuthScheme"},"type":"array"},{"type":"null"}],"title":"Auth Schemes"}},"type":"object","required":["key","name"],"title":"ToolCatalogIntegration"},{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories","default":[]},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"actions_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Actions Count"},"auth_schemes":{"anyOf":[{"items":{"type":"string","enum":["oauth","api_key"],"title":"ToolAuthScheme"},"type":"array"},{"type":"null"}],"title":"Auth Schemes"},"actions":{"anyOf":[{"items":{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories","default":[]},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"provider_action_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider Action Id"},"read_only":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Read Only"}},"type":"object","required":["key","name"],"title":"ToolCatalogAction"},"type":"array"},{"type":"null"}],"title":"Actions"}},"type":"object","required":["key","name"],"title":"ToolCatalogIntegrationDetails"}]},"type":"array","title":"Integrations","default":[]}},"type":"object","title":"ToolCatalogIntegrationsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"properties":{"detail":{"items":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"}}}}}} \ No newline at end of file diff --git a/docs/docs/reference/api/list-tool-integrations.api.mdx b/docs/docs/reference/api/list-tool-integrations.api.mdx index 39f1036c412..a2595c5980b 100644 --- a/docs/docs/reference/api/list-tool-integrations.api.mdx +++ b/docs/docs/reference/api/list-tool-integrations.api.mdx @@ -5,7 +5,7 @@ description: "List Integrations" sidebar_label: "List Integrations" hide_title: true hide_table_of_contents: true -api: eJztWEtv4zYQ/ivCnFqAiNygJ5/qZrfdIGk3SLy9BIbBSGOLW0rU8hGsa+i/F0NKMiU/mk026CUnw+TMfPP4OBxqC5avDUzvYa6UNLBgoGrU3ApVXeYwBSmMXVql5FJUFtdhxwCDmmteokVN2luoeIkwhVqrR5GjXv6NG2AgKlrjtgAGGr84oTGHqdUOGZiswJLDdAt2U5OusVpUa2BghZW0cNMaS65wA03DehSDXGdFZ/+LQ70ZAKy4NAMEXm0+rryfQyyy2a5UTkpoFjv0uwAywFXaLh82rw2stE1+HUaccYtrpV8b+qKDibGlKIV9CbBnDuqTyNceZBCy00bp1w44gMS4KyflMkfLhTTfgN4iPCglkVfAIMcVd9L2oh3kb07K5F1rvyFnNJpaVQYN2TmfTOgnR5NpUdNpI0q4LENjVk4mt60wMMhUZbGyPva6liLzhzP9bEhnG/lWazrUVgSETLmgNCpPlBUvEYUwaRhYZbk8qTb3EiO1towvrA+DQfuZbkFYLM3Q7DBM6kAnmovvKawt+nGxP2m/YcN6PC+Wd5EJSkw4a623fThje90C15pvYO+oknqU8ftFw0CqtXquk9ek2zBwWj7XxCctyQLPfK2WPd2e1RdmwUoSKElmnS2Wnto4Kv+RFDLAypV0xSnSBQa8Fv5+WsTUVXLmbHHnDe+l/aSHzhbJXetQs9NUD58xs4OmcQ/hWvSkG6FfcMulWl/uaO5R3zj9xun/m9N93Ed8e+Po9+OoRp4vVSU3Bw1108UpS7fI8+QjWXhxMwpE/TbmtEz5no2wH5YWx8tyOXydxIU54MlpPNPPWE1D2j+fn++PZH9xKXIvnrzX2g+qz5zHwqw5IONQQKrsyMRzlF99GzqRsmuVdfcMlGZ96jT+gcbwddRDjov6ZCRz2vVTW+3CrNnXiRboGNqvkZm9ylxQLr/a/+QR5Sa438pFbNqVKFToeCoCww6CdSIf5vObPYOBH0NiXAtjkxEdS7SFouf0Gq1/O9sCppDSu9qkWSBg2r2dTbqNn9FNGo++KTAwqB+7d7e/0yDltfDlD38La2szTVN0Z5lULj/ja6wsP+MiCC7IRua0sBtvZHZzeYWbD8hz1P7IRAJ3u2tpKNbXjtfianeaw+WhtPgnkKt9QRVBq/GcWKmYEjPvXDK7uYRxLgdbdLx45tnUIfltYKOwd9HSRVn6wwUWeflLv0NcoBwGmMnZT2cTWqqVsSWvIohD1RzdSW0aiK5pLbmoolkjVPoefKXBXz5Ua+JAV21gMN37bDLAI04XylA3g+32gRv8pGXT0HJ4mFINc2H4g4yepv4e3n0qeeTSkZueJseE++8bT5GOvkk8Rbz7jPAk093b/ynCowf7TmVBf7QgncMpGhWy79/ww23bYn5MgB0ucEf+ahB/59OgoL4JF93paickmGUZ1nFC9u4MCqDvHL+/n0PT/AtCO6+e +api: eJztWEtv4zYQ/ivCnFpAsNygJ5/qZrddI2k3SLy9BIZBS2OLW0rU8hGsa+i/F0NKMiU/mnU26CUnw9TMfMOZjzND7sCwjYbJI8ylFBoWMcgKFTNclrMMJiC4NksjpVjy0uDGf9EQQ8UUK9CgIu0dlKxAmECl5BPPUC3/xi3EwEtaYyaHGBR+sVxhBhOjLMag0xwLBpMdmG1FutooXm4gBsONoIW7xlh0g1uo67hD0chUmrf2v1hU2x7AmgndQ2Dl9uPa+dnHIpvNSmmFgHqxR3/wID1cqcxytX1tYKlM9Gt/xykzuJHqtaGvW5gQW/CCm5cAO+agOot860B6W7ZKS/XaG/YgIe7aCrHM0DAu9DegNwgrKQWyEmLIcM2sMJ1oC/mbFSJ619ivyRmFupKlRk12rsZj+slQp4pXdNqIEjZNUeu1FdF9IwwxpLI0WBq396oSPHWHM/msSWcX+FYpOtSGe4RUWq80SE8QFScRbGFcx2CkYeKs2txJDNSaNL4wPzH0ys9kB9xgoftm+9ukCnSmuLiaEjdJPy32J32v434+LtvLu8AEBcaftcbbbjtDe+0CU4pt4eCoknoQ8cdFHYOQG3mpk7ekW8dglbjUxCclyAJLXa6WHd0uqgtTbyXylCSz1uRLR20cpP9ECGPA0hbU4iTpQgys4q4/LULqSjG1Jn9whg/CftZDa/LooXGo3mvK1WdMTa9oPIJvi450A/RrZpiQm9me5g71jdNvnP6/Od3t+4Rvbxz9fhztBmgf8iXPLrXYTc+ebtEsI/sKWbaUpdgeNdtOL+fs3iPLoo9k4cXFznv2bcxsmPg9C203jC1Op33Wv/2EiT/iyXk83c1wdU3aP19dHY58fzHBMycevVfKDcIXznt+lu2RvS8gZHpiojrJtq7MnQnZrUzbPgaF3pw77X+g1mwT1KjToi4Y0Zy+uqmwsn6W7fJEC3TMzdfAzEFmrimWX81/8ohi491v5AI27VPkM3Q6FJ5hR8FakQ/z+d2BQc+PPjFuuTbRgI4FmlzSdX2Dxt3NTQ4TSOjerpPUEzBpS4tOduE1vU7C0TqBGDSqp/Ze73omJKziLv3+b25MpSdJgnaUCmmzEdtgadiIcS+4IBupVdxsnZHp3ewGtx+QZajckQkEHvZtry/W5Y5V/GZ/mn1zkor/48nV3NByr1U7TqxlSImpcy6a3s1gGMveJzpeLHVsapHcZ4gH297vlhpx4Q4XGGTFL90X4gLF0MOMRz+Nxq66S20KVgYQx7I56HlNGIiuSSUYL4NZxmf6EVymwTU3yjXsGwmtTg6eZXp4xOlcaqpmsNutmMZPStQ1LfuLL+Uw45qtRHD1dX1+/xTzxIQlNx1NTgl37yfPkQ7ePJ4j3j5TPMt0+7bwHOHBg8BeZUF/FCed4yEaJLKr3/DDfVNifowgPp7glvxlb/+tT72EuiKct6ermcBgmqZYhQE56Bm0ga5y/P5+DnX9LynU0QM= sidebar_class_name: "get api-method" info_path: reference/api/agenta-api custom_edit_url: null diff --git a/docs/docs/reference/api/query-turns.RequestSchema.json b/docs/docs/reference/api/query-turns.RequestSchema.json index 7a573216cb4..8c9f5f6ae52 100644 --- a/docs/docs/reference/api/query-turns.RequestSchema.json +++ b/docs/docs/reference/api/query-turns.RequestSchema.json @@ -1 +1 @@ -{"title":"Body","body":{"content":{"application/json":{"schema":{"properties":{"query":{"anyOf":[{"properties":{"session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Session Id"},"stream_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Stream Id"},"harness_kind":{"anyOf":[{"type":"string","enum":["pi_core","claude","pi_agenta","codex"],"title":"HarnessKind","description":"The coding agent program a run drives. A backend declares which it supports.\n\n``pi_core`` is plain Pi; ``pi_agenta`` is Pi with Agenta's forced skills, prompt, and\npolicy. Both drive the same ``pi`` ACP agent in the runner; ``claude`` drives Claude Code."},{"type":"null"}]},"references":{"anyOf":[{"items":{"properties":{"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key"}},"type":"object","title":"SessionReference","description":"A reference element that carries its own family.\n\nSessions persist references as a flat list (`session_turns.references`,\n`session_streams.references`), so the map key that named the family upstream is\ngone by the time a reader sees the row — leaving \"first UUID in the list\" as the\nonly way to guess which element is the workflow.\n\n``key`` is the name `evaluation_runs.references` already uses for the same flat-list\ndiscriminator (`dbs/postgres/evaluations/utils.py`), and the same one tracing carries\nin `OTelReference.attributes[\"key\"]`.\n\nIt is a plain string rather than ``ReferenceKey`` on purpose: a turn append is\nfire-and-forget, so rejecting an unrecognized family would drop the whole turn, which\nis the failure this field exists to prevent. Producers inside the API use\n``ReferenceKey``; readers treat anything else as untyped."},"type":"array"},{"type":"null"}],"title":"References"}},"type":"object","title":"SessionTurnQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"properties":{"newest":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Newest"},"oldest":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Oldest"},"next":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Next"},"limit":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"},"order":{"anyOf":[{"type":"string","enum":["ascending","descending"]},{"type":"null"}],"title":"Order"},"interval":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"},"rate":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Rate"}},"type":"object","title":"Windowing"},{"type":"null"}]}},"type":"object","title":"SessionTurnQueryRequest"}}},"required":true}} \ No newline at end of file +{"title":"Body","body":{"content":{"application/json":{"schema":{"properties":{"query":{"anyOf":[{"properties":{"session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Session Id"},"stream_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Stream Id"},"harness_kind":{"anyOf":[{"type":"string","enum":["pi_core","claude","codex"],"title":"HarnessKind","description":"The coding agent program a run drives. A backend declares which it supports.\n\n``pi_core`` is Pi; ``claude`` drives Claude Code; ``codex`` drives Codex."},{"type":"null"}]},"references":{"anyOf":[{"items":{"properties":{"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key"}},"type":"object","title":"SessionReference","description":"A reference element that carries its own family.\n\nSessions persist references as a flat list (`session_turns.references`,\n`session_streams.references`), so the map key that named the family upstream is\ngone by the time a reader sees the row — leaving \"first UUID in the list\" as the\nonly way to guess which element is the workflow.\n\n``key`` is the name `evaluation_runs.references` already uses for the same flat-list\ndiscriminator (`dbs/postgres/evaluations/utils.py`), and the same one tracing carries\nin `OTelReference.attributes[\"key\"]`.\n\nIt is a plain string rather than ``ReferenceKey`` on purpose: a turn append is\nfire-and-forget, so rejecting an unrecognized family would drop the whole turn, which\nis the failure this field exists to prevent. Producers inside the API use\n``ReferenceKey``; readers treat anything else as untyped."},"type":"array"},{"type":"null"}],"title":"References"}},"type":"object","title":"SessionTurnQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"properties":{"newest":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Newest"},"oldest":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Oldest"},"next":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Next"},"limit":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"},"order":{"anyOf":[{"type":"string","enum":["ascending","descending"]},{"type":"null"}],"title":"Order"},"interval":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"},"rate":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Rate"}},"type":"object","title":"Windowing"},{"type":"null"}]}},"type":"object","title":"SessionTurnQueryRequest"}}},"required":true}} \ No newline at end of file diff --git a/docs/docs/reference/api/query-turns.StatusCodes.json b/docs/docs/reference/api/query-turns.StatusCodes.json index 114beca41ff..4fa28945d0c 100644 --- a/docs/docs/reference/api/query-turns.StatusCodes.json +++ b/docs/docs/reference/api/query-turns.StatusCodes.json @@ -1 +1 @@ -{"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"properties":{"count":{"type":"integer","title":"Count","default":0},"turns":{"items":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"project_id":{"type":"string","format":"uuid","title":"Project Id"},"session_id":{"type":"string","title":"Session Id"},"turn_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Turn Id"},"stream_id":{"type":"string","format":"uuid","title":"Stream Id"},"turn_index":{"type":"integer","title":"Turn Index"},"harness_kind":{"type":"string","enum":["pi_core","claude","pi_agenta","codex"],"title":"HarnessKind","description":"The coding agent program a run drives. A backend declares which it supports.\n\n``pi_core`` is plain Pi; ``pi_agenta`` is Pi with Agenta's forced skills, prompt, and\npolicy. Both drive the same ``pi`` ACP agent in the runner; ``claude`` drives Claude Code."},"agent_session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Session Id"},"sandbox_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sandbox Id"},"references":{"anyOf":[{"items":{"properties":{"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key"}},"type":"object","title":"SessionReference","description":"A reference element that carries its own family.\n\nSessions persist references as a flat list (`session_turns.references`,\n`session_streams.references`), so the map key that named the family upstream is\ngone by the time a reader sees the row — leaving \"first UUID in the list\" as the\nonly way to guess which element is the workflow.\n\n``key`` is the name `evaluation_runs.references` already uses for the same flat-list\ndiscriminator (`dbs/postgres/evaluations/utils.py`), and the same one tracing carries\nin `OTelReference.attributes[\"key\"]`.\n\nIt is a plain string rather than ``ReferenceKey`` on purpose: a turn append is\nfire-and-forget, so rejecting an unrecognized family would drop the whole turn, which\nis the failure this field exists to prevent. Producers inside the API use\n``ReferenceKey``; readers treat anything else as untyped."},"type":"array"},{"type":"null"}],"title":"References"},"trace_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Trace Id"},"span_id":{"anyOf":[{"type":"string","pattern":"^[0-9a-fA-F]{16}$"},{"type":"null"}],"title":"Span Id"},"start_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Start Time"},"end_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"End Time"}},"type":"object","required":["project_id","session_id","stream_id","turn_index","harness_kind"],"title":"SessionTurn"},"type":"array","title":"Turns"}},"type":"object","title":"SessionTurnsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"properties":{"detail":{"items":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"}}}}}} \ No newline at end of file +{"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"properties":{"count":{"type":"integer","title":"Count","default":0},"turns":{"items":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"project_id":{"type":"string","format":"uuid","title":"Project Id"},"session_id":{"type":"string","title":"Session Id"},"turn_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Turn Id"},"stream_id":{"type":"string","format":"uuid","title":"Stream Id"},"turn_index":{"type":"integer","title":"Turn Index"},"harness_kind":{"type":"string","enum":["pi_core","claude","codex"],"title":"HarnessKind","description":"The coding agent program a run drives. A backend declares which it supports.\n\n``pi_core`` is Pi; ``claude`` drives Claude Code; ``codex`` drives Codex."},"agent_session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Session Id"},"sandbox_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sandbox Id"},"references":{"anyOf":[{"items":{"properties":{"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key"}},"type":"object","title":"SessionReference","description":"A reference element that carries its own family.\n\nSessions persist references as a flat list (`session_turns.references`,\n`session_streams.references`), so the map key that named the family upstream is\ngone by the time a reader sees the row — leaving \"first UUID in the list\" as the\nonly way to guess which element is the workflow.\n\n``key`` is the name `evaluation_runs.references` already uses for the same flat-list\ndiscriminator (`dbs/postgres/evaluations/utils.py`), and the same one tracing carries\nin `OTelReference.attributes[\"key\"]`.\n\nIt is a plain string rather than ``ReferenceKey`` on purpose: a turn append is\nfire-and-forget, so rejecting an unrecognized family would drop the whole turn, which\nis the failure this field exists to prevent. Producers inside the API use\n``ReferenceKey``; readers treat anything else as untyped."},"type":"array"},{"type":"null"}],"title":"References"},"trace_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Trace Id"},"span_id":{"anyOf":[{"type":"string","pattern":"^[0-9a-fA-F]{16}$"},{"type":"null"}],"title":"Span Id"},"start_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Start Time"},"end_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"End Time"}},"type":"object","required":["project_id","session_id","stream_id","turn_index","harness_kind"],"title":"SessionTurn"},"type":"array","title":"Turns"}},"type":"object","title":"SessionTurnsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"properties":{"detail":{"items":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"}}}}}} \ No newline at end of file diff --git a/docs/docs/reference/api/query-turns.api.mdx b/docs/docs/reference/api/query-turns.api.mdx index 2eb475f14eb..93e5d48dc3a 100644 --- a/docs/docs/reference/api/query-turns.api.mdx +++ b/docs/docs/reference/api/query-turns.api.mdx @@ -5,7 +5,7 @@ description: "Query Turns" sidebar_label: "Query Turns" hide_title: true hide_table_of_contents: true -api: eJztWW1vG7kR/isEUaBXYPViJ3Yue1/qOCli5O6i2soVqKRa1HJW4plL7vHF8p4hoD+iv7C/pBhyVy+WIvt8un5yvsTiy8PhzDPDmdl76tjU0nRAr8BaoZWlo4TqEgxzQqsLTlP6iwdTXTtvlKUJNfCLB+veaV7R9J5mWjlQDv9kZSlFFvZ1frZa4ZjNZlAw/Ks0iOoEWPwVMMMmVX3OaTp4uMBGca4F31zlqhJoSq0zQk3pIlmOKC8lXYwS6oSTOFBfiFxwukhwA7DiEbiE5toUzNGUei/4fvgAWKPPmFFg7fWNUI8cAMoXqO5SXGfaAE1oJpnn+EcprtkUlGM4qDnc0bXjPsYTPuEBCeVgMyNKVDVNaX8GJNNcqCkJAKQ0empYQRgxXhFuxC3YNjkjE5bdgOKEQyaZAUvmM5HNiHDE+rLUxtn2UA3VeFxLNx4TYUkpmVCkJ74jYSLKGKd6gsyFm5GzMPZnS3JtMuDE3ggpbYKCFKVLCFN8qEotRVa1yTvtZlEo4mZALCsgII/H5Oy8V19BqDBpvFJg8OSopvG4vg45D7/JuebQ3mGoBTI1BwMqi4RamUQ4KOw2JW/BWBFZ+xy6/VRvR65JP302a3HvIqGHI2pk6A1UzxXpE1R0sUiaeT35GTJHtxztslH3Fj/PyNIUBCQUaF83Y45kzBgBlghniZ4rkrNCyCpwsAlHpES9WreCsIRZwkgumSMSZ74ZN8EixKj2auU4GarlZIwAG9N/SYjVgWcFK8kNVFEsxQrgYTgKRHwZNxNhh2qqFZBJFeadKAC9DBgHQyyAjazVc/Lff/+HSGC36JVDmgtjHfny5eJ9w2wUfUjxLm4GQ6WVrMicVcRpMvVgG99s9CUi8lybm1zqee2nN1BFR8Q5FRwJbpn0IQhfG7+pDcIkSloRbyG46sr/UJutIJLiAm1XCMWcNuSbMZ/YTqmtmxqwnRW67XgnpG2XFaqRKb4CQwU5wzK8em3ioRKKjD/3QS5p0mbOGTHxDuxgiPwc0tE43OsiXJfVgScylBjmZoASM0XG4yXKp6ABrUjpTaktpIQRpAFhZYmhDi2WCwMtpngr12YKLhjdALI4hExFvDKQ6akSvwJvbD7XXnLCjS6j4mdaQkBOomWGqlZ7zoT0BmOZsCQXIDmBO2GdRVOWBm5BuTbpGc19BsYSoazgMfSd9S7QFmjJzQt9V1PKEuSdI0xVbobCgrSAnPEKvZFj5Gv8khnDqr2OfLkKiE/w57436u/hmd4ZXedCcT3H2LHnFVcwB+ueGMk4c9BCj9p7iR8j5CKhWvJDg3+OkIuEKrh7KvSjEfhHxFokVIpC7AYVysEUzF6U78NuvLfhYJ6WZTCbgeJxDGNy/WO0VwcBHp8g5cDcMvlsiS8aAHyMmYOdQMoXk0dwLnHvPsb+Y8nFHVT9DUy/jLktXSwWMdEVBjhNnfEQBmyplY3MPu528b/Nh+7KZxlYm3tJLuvFIZd7VoqcaR83PdD4SvzzsAItmzMvHU27eNeQp6dfTXMyDCjAr9lhPec8wpKzQFFf8j/ikC8Rtj6Eg4Q/4JD3EbY+pFHXpDpg7dAo611V1w+Nvg56SqOt5SmNwg56SqOu5SmHzlxLo9Fta5H3o6329uKupvrbqCYfYuyuGNGRDqgnDDK7atGnXmij3oyyKawR9wWIeGZYtqNIfSlN/z+lKQ3brn9/RyNchTzoazDFJ/ru9/RJIkKN91I0vxTNL0XzS9H8UjTvKJoTihaCQ2YFiNeE8pI9nnCUzDkwGCD+Nei23rJWftb62+j+6HTxp/0hsmSr9IMZdx3y4EPmzVcIS/pxGQXFD3/EB8XrA3ZE1lW9NljPGTeSv/XcayOLepAdbX9GwFRqizebmdaTuyp2WRwuQqX5+vh4u5b8iUnBQ9AgH4zR5vmFJAfHhNxTEkqdbcw+4b1blv+jr+vkex0FRLUVdrov8/4BrGVTWCn460uDMkgfZ0OPovSxSF72HHAACze3nhtvmeMcdYltmUeYhLqJ4tfr1rOXpYmihb6uivfRBPv48bHf720BRn5sEiN0Kki//hxXgJtp/EqHr02MDzOa0k5NetsJz3snfm9DXzCYsgXjeiNxJStFsGz8OXOutGmnA76dSe15O+bUbSbiwhFiZN4IVwWQs97FJ6g+hsBL08FofcEVEjJSbHPZ0iysFJgoJRSfYUx6vJtpI36NvEHzokhxF2oCqX65+gD54Y4VpYSND4rrafaKPWslF32Vs29P8tPXrZM3R29ar09Oj1uTV3nWOs7enr7KT09Zzk4fBoR0rSpaT5EHaynw2mkhl139/i3Hhpxz6XFbndamo0qPu8evW903reO3/aOT9OQoPf623X1z9E+6aozuWxP7m08Vqu5cdpfNx43O4qpR2G0afd1FcM5cr/tmLMXwAd/OdtenMM6xLLh1w4um+Nwk6YqbWL4WIcpRB6z463IGnXJloW77qN0NTQVtXcHU2hGbbrUh3ZKuGDE6IcEKTRsTgmr0uEFDPEubHlxSk3KU0Bk6Zzqg9/cTZuGLkYsFDtekHYwSesuMYBNU0wBtPmsc6r4mxHmM/a0Q+HC59NGBHrwD6Mlxx1mWQen2rh2txY/e56s+Teik/q5faI57DJsj49mcphSZFTQSPDqM3VPJ1NRj6E5pxMR//wOeEyWN +api: eJztWdtuIzcS/RWC2IcEaF2sGXsynZc4nlmMMdmM19YkQCStRTWrJcZsssOL5Y4hYD9iv3C/ZFFkty6WRnYc5WntF6ubrMNi1aliFfueOja1NB3QK7BWaGXpKKG6BMOc0Oqc05T+5sFU184bZWlCDfzmwbrvNa9oek8zrRwohz9ZWUqRBbnOr1YrfGezGRQMf5UGUZ0Ai08BMwip6lNO08HDCTaqcy345ixXlUBTap0RakoXyfKN8lLSxSihTjiJL+oNkXNOFwkKACsegUtork3BHE2p94Lvhw+ANfqMGQXWXt8I9cgCoHyB5i7FdaYN0IRmknkefmgOd3RtiQ8R9SOCJpSDzYwo0bw0pf0ZkExzoaaETUE5Uho9NawgjBivCDfiFmybnJIJy25AccIhk8yAJfOZyGZEOGJ9WWrjbHuohmo8rjUaj4mw5EJ8S8bjqNp4XMORs/BMzjSHMIwKr43iY3uH0RbImhwMqCw6d2Ue4aCw2/S4BWNFZNBzXP9TLY5+l376bAah7CKhhyNNZMsNVM9V6SNUdLFImnE9+RUyR7dIf9mYe4s3p2TpCgISCqSOmzFHMmaMAEuEs0TPFclZIWQVuNGkBlKiXa1bQVjCLGEkl8wRiSNfjZvADfmivZo5ToZqORijcWP464RYTdwMSMFKcgNVVEuxAnh4HRUivozCRNihmmoFZFKFcScKQPYD42CIBbDhtdFz8t9//4dIYLcYLUOaC2Md+fz5/B0RKsxB1YcU9+JmMFRayYrMWUWcJlMPtomZxl4iIs+1ucmlntfxcwNVjB0cQ7XJGG6Z9CEhXhu/aQ3CJGpaEW/BklybIGZRDK3ZCiopLtB3hVDMaUO+GvOJ7ZTauqkB21mh2453Qtp2WaEZmeIrMDSQMyzDrdcuHiqhyPhTH+SSJm3mnBET78AOhsjPIR2Nw77Ow3YZKSUTikSGEsPcDFBjpsh4vET5GCygFSm9KbWFlDCCNCCsLDEFocdyYaDFFG/l2kzBBacbQBaHVKaIVwYyPVXid+CNz+faS0640WU0/ExLCMhJ9MxQ1WbPmZDeAHEzYUkuQHICd8I6i64sDdyCcm1yYTT3GRhLhLKCQxA9vThHX6AnNzf0bU0pS5B3jjBVuRkqC9ICcsYrjEaOma+JS2YMq/YG8uUqIT4hnvveqH+GI3Nndp0LxfUcc8eeE1XBHKx7YibjzEELI2rvJn6MkIuEaskPDf4pQi4SquDuqdCPZuAfEWuRUCkKsRtUKAdTMHtRfgjSuG/DwTztxGc2A8XjO8zJ9cNorw0CPB5ByoG5ZfLZGp83AHgYMwc7gZQvJo/gXKLsPsb+vOTiDqr+AaZfxjqTLhaLWHQKA5ymzngIL2yplY3M7nW7+G/zoLvyWQbW5l6Sy3pyqLGeVa5m2kehBxZfqX8WZqBnc+alo2kX9xpq5vSLZU6GCQX4NTts5JxFWHIaKOpL/lcs8jnC1otwkPAXLPIuwtaLNOaaVAes4xtjfV/VtXxjr4Ou0lhruUpjsIOu0phrucqhK9fSaAzbWuX9aCvZiyjVdGIbnd1DjN3dGwbSAe2ESWZXX/jUDW30flE3hb3bvgQR1wzTdjSM/49tIg0aXf/5Tv807OxBv88Un+i7P3N/EBFqvJcG9qWBfWlgXxrYlwZ2RwObUPQQHPKERrwmlZfs8cO/ZM6BwQTxr0G39Za18tPW30f3RyeLv+1PkSVblQLMuOtQkx6yhr1CWNKP0ygofvgl3iteL7Ajs656p8F6/bZRiK3XQRsVzYNKZft6HcuaLd5sVj1PvuGwy0ZtEbq+173edl/3E5OCh6RB3hujzfObOg6OCbmnPZM62xh9wnm3bMVHX7bJDzoqiGYr7HRfFfwPsJZNYWXgL08NxiB9HA33BaWPDeuy/8cX2ES59Tp1yx1naEu8InmESWibqH49b716WbooeujLpngXXbCPHx/6/YstwMiPTWKEWwPSrz9TFeBmGr9e4WkT88OMprRTk952wvHeid+hMBYMlmzBud5InMlKETwbH2fOlTbtdMC3M6k9b4filbWZiBNHiJF5I1wVQE4vzj9C9SEkXpoORusTrpCQkWKb05ZuYaXAQimheAxj0ePdTBvxe+QNuhdVilJoCaT65erD3Ps7VpQSNj60rZfZK/astT/0Vc6+Oc5PXreO3xy9ab0+Pum1Jq/yrNXL3p68yk9OWM5OHiaEdK1DWS+RB2sl8NpqoZZdPf+RZUPNuYy4rVvP5naT9rq9163um1bvbf/oOD0+SnvftLtvjn6hq0vKfXPiXeNTlapvEbvLi8CNW77VpV23uXTrLkJw5no9NkP/wvAA365214cwz7EshHXDizBMkwckXXETW8kiZDnqgBXfLUcwKFce6raP2t3Q4GvrCqbWltgMqw3tlnTFjNEJBVa4QDEhqcaIGzTEs7S5D0tqUo4SOsPgTAf0/n7CLHw2crHA1zVpB6OE3jIj2ATNNECfz5qAuq8JcRZzfyskPpwufQygB+cARnKUOM0yKN3euaO1/HHx6apPEzqpv3cXmqOMYXNkPJvTlCKzgkVCRId391QyNfWYulMaMfHvf2YJ09o= sidebar_class_name: "post api-method" info_path: reference/api/agenta-api custom_edit_url: null diff --git a/docs/docs/reference/api/resolve-tools.RequestSchema.json b/docs/docs/reference/api/resolve-tools.RequestSchema.json index 714b11b9359..ac8b90d8abf 100644 --- a/docs/docs/reference/api/resolve-tools.RequestSchema.json +++ b/docs/docs/reference/api/resolve-tools.RequestSchema.json @@ -1 +1 @@ -{"title":"Body","body":{"content":{"application/json":{"schema":{"properties":{"tools":{"items":{"anyOf":[{"properties":{"render":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Render"},"permission":{"anyOf":[{"type":"string","enum":["allow","ask","deny"]},{"type":"null"}],"title":"Permission"},"type":{"type":"string","const":"builtin","title":"Type","default":"builtin"},"name":{"type":"string","minLength":1,"title":"Name"}},"additionalProperties":false,"type":"object","required":["name"],"title":"BuiltinToolConfig","description":"Legacy entry, accepted so revisions written before the rework still parse.\n\nBuilt-in tools are always active and are no longer configured here; the resolver drops\nevery entry with a warning. Keep this arm until the dual-read window closes."},{"properties":{"render":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Render"},"permission":{"anyOf":[{"type":"string","enum":["allow","ask","deny"]},{"type":"null"}],"title":"Permission"},"type":{"type":"string","const":"gateway","title":"Type","default":"gateway"},"provider":{"type":"string","minLength":1,"title":"Provider","default":"composio"},"integration":{"type":"string","minLength":1,"title":"Integration"},"action":{"type":"string","minLength":1,"title":"Action"},"connection":{"type":"string","minLength":1,"title":"Connection"},"name":{"anyOf":[{"type":"string","minLength":1},{"type":"null"}],"title":"Name"}},"additionalProperties":false,"type":"object","required":["integration","action","connection"],"title":"GatewayToolConfig"}]},"type":"array","title":"Tools"}},"type":"object","title":"ToolResolveRequest"}}},"required":true}} \ No newline at end of file +{"title":"Body","body":{"content":{"application/json":{"schema":{"properties":{"tools":{"items":{"anyOf":[{"properties":{"render":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Render"},"permission":{"anyOf":[{"type":"string","enum":["allow","ask","deny"]},{"type":"null"}],"title":"Permission"},"type":{"type":"string","const":"builtin","title":"Type","default":"builtin"},"name":{"type":"string","minLength":1,"title":"Name"}},"additionalProperties":false,"type":"object","required":["name"],"title":"BuiltinToolConfig","description":"Legacy entry, accepted so revisions written before the rework still parse.\n\nBuilt-in tools are always active and are no longer configured here; the resolver drops\nevery entry with a warning. Keep this arm until the dual-read window closes."},{"properties":{"render":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Render"},"permission":{"anyOf":[{"type":"string","enum":["allow","ask","deny"]},{"type":"null"}],"title":"Permission"},"type":{"type":"string","const":"gateway","title":"Type","default":"gateway"},"provider":{"type":"string","minLength":1,"title":"Provider","default":"composio"},"integration":{"type":"string","minLength":1,"title":"Integration"},"action":{"type":"string","minLength":1,"title":"Action"},"connection":{"type":"string","minLength":1,"title":"Connection"},"name":{"anyOf":[{"type":"string","minLength":1},{"type":"null"}],"title":"Name"}},"additionalProperties":false,"type":"object","required":["integration","action","connection"],"title":"GatewayToolConfig"},{"properties":{"type":{"type":"string","const":"gateway_connection","title":"Type","default":"gateway_connection"},"connection":{"properties":{"provider":{"type":"string","const":"composio","title":"Provider","default":"composio"},"integration":{"type":"string","minLength":1,"title":"Integration"},"slug":{"type":"string","minLength":1,"title":"Slug"}},"additionalProperties":false,"type":"object","required":["integration","slug"],"title":"GatewayConnectionRef","description":"The shared project connection a gateway entry points at.\n\nA resource reference, never a credential: the project owns the connection and several\nagents can reuse it, each with its own policy."},"policy":{"properties":{"permissions":{"properties":{"default":{"type":"string","enum":["inherit","allow","ask","deny"],"title":"Default"},"tools":{"additionalProperties":{"type":"string","enum":["inherit","allow","ask","deny"]},"propertyNames":{"minLength":1},"type":"object","title":"Tools"}},"additionalProperties":false,"type":"object","required":["default"],"title":"GatewayPermissions","description":"What the agent may do through one connection, per tool key."}},"additionalProperties":false,"type":"object","required":["permissions"],"title":"GatewayConnectionPolicy","description":"The ``policy`` node of the saved entry. It mirrors the saved nesting and holds one\nfield on purpose, so a later policy of a different kind has a place to go."}},"additionalProperties":false,"type":"object","required":["connection","policy"],"title":"GatewayConnectionToolConfig","description":"One whole integration, with a policy the SDK compiles into per-tool decisions.\n\nReplaces the one-entry-per-tool :class:`GatewayToolConfig`, which stays readable while\nsaved revisions migrate. The entry carries no credentials, provider account IDs, tool\nschemas, or read-only hints: those are resolved data, not authored configuration.\n\nIt does not extend :class:`ToolConfigBase`. That base carries a per-tool ``render`` and\n``permission``, and an entry that covers a whole integration has no single tool to apply\neither to. Every permission here lives in ``policy``, so a top-level one is refused\ninstead of accepted and then ignored, which would let an author believe a `deny` applies\nwhen nothing reads it. The deleted legacy permission spellings are refused here for the\nsame reason, rather than dropped in silence as they are on the other arms."}]},"type":"array","title":"Tools"}},"type":"object","title":"ToolResolveRequest"}}},"required":true}} \ No newline at end of file diff --git a/docs/docs/reference/api/resolve-tools.StatusCodes.json b/docs/docs/reference/api/resolve-tools.StatusCodes.json index 26923b8b9e4..58d1e2ccea7 100644 --- a/docs/docs/reference/api/resolve-tools.StatusCodes.json +++ b/docs/docs/reference/api/resolve-tools.StatusCodes.json @@ -1 +1 @@ -{"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"properties":{"count":{"type":"integer","title":"Count","default":0},"builtins":{"items":{"type":"string"},"type":"array","title":"Builtins"},"custom":{"items":{"properties":{"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"input_schema":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Input Schema"},"call_ref":{"type":"string","title":"Call Ref"},"read_only":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Read Only"}},"type":"object","required":["name","call_ref"],"title":"ResolvedTool","description":"A runnable reference resolved into a model-ready tool spec.\n\n``call_ref`` is the ``tools.{provider}.{integration}.{action}.{connection}`` slug\nthe execution bridge sends back to ``POST /tools/call``."},"type":"array","title":"Custom"}},"type":"object","title":"ToolResolveResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"properties":{"detail":{"items":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"}}}}}} \ No newline at end of file +{"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"properties":{"count":{"type":"integer","title":"Count","default":0},"builtins":{"items":{"type":"string"},"type":"array","title":"Builtins"},"custom":{"items":{"properties":{"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"input_schema":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Input Schema"},"call_ref":{"type":"string","title":"Call Ref"},"read_only":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Read Only"}},"type":"object","required":["name","call_ref"],"title":"ResolvedTool","description":"A runnable reference resolved into a model-ready tool spec.\n\n``call_ref`` is the ``tools.{provider}.{integration}.{action}.{connection}`` slug\nthe execution bridge sends back to ``POST /tools/call``."},"type":"array","title":"Custom"},"gateway_connections":{"items":{"properties":{"provider":{"type":"string","title":"Provider"},"integration":{"type":"string","title":"Integration"},"connection":{"type":"string","title":"Connection"},"toolkit_version":{"type":"string","title":"Toolkit Version"},"tools":{"items":{"properties":{"key":{"type":"string","title":"Key"},"read_only":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Read Only"}},"type":"object","required":["key"],"title":"ResolvedGatewayTool","description":"One catalog tool as the SDK permission compiler reads it (contracts section 2)."},"type":"array","title":"Tools"}},"type":"object","required":["provider","integration","connection","toolkit_version"],"title":"ResolvedGatewayConnection","description":"The catalog slice for one validated connection entry (contracts section 3).\n\nThe whole integration is returned in one round trip, so the SDK compiles its\nper-tool policy without asking for each tool separately."},"type":"array","title":"Gateway Connections"}},"type":"object","title":"ToolResolveResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"properties":{"detail":{"items":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"}}}}}} \ No newline at end of file diff --git a/docs/docs/reference/api/resolve-tools.api.mdx b/docs/docs/reference/api/resolve-tools.api.mdx index 280e47703bd..1ffc3749e0e 100644 --- a/docs/docs/reference/api/resolve-tools.api.mdx +++ b/docs/docs/reference/api/resolve-tools.api.mdx @@ -5,7 +5,7 @@ description: "Resolve an agent's tool references into model-ready specs." sidebar_label: "Resolve Tools" hide_title: true hide_table_of_contents: true -api: eJztWMFy2zYQ/RUMLm1naNrJ9KRe6jiZxpO00ThuL7bGXIErEjEIsABoWdVwph/RL+yXdBYgRUqyFDftrfVBlsjdxWL3vd0F1txD4fjkhl8boxyfJTxHJ6ysvTSaT/gVOqMekIFmUKD2XznmjVHM4gItaoGOSe0Nq0yO6sQi5CvmahQuvdW3+hdQMgePjl2YqjZOGiaM1ijIumNNzRbWaM9A5wy1laJExxBEySCI0OuK+RJvtQAPyhQJc4YBs43WUhfRJ/Y1pkXKpvIbVqB3LMtE47ypwpayjC1L45BlGT6iaDxm2a22piGv5iDumS+taYqSZdn0w8drdkr7c6cClMoy9ufvf7DamgeZo2X3uHLMeVgxh/YB7YmTOaY84aZGC+TwZc4n3MaY3QVDPOEWf23Q+VcmX/HJmgujPWpPX6GulRRB8/STo4CvuRMlVkDfakt2vURHv6K1yZpLj1X4Anr1YcEnN7uSFnWOdlsC8lzSMqCmI1lvG0y4X9XIJ9zMP6HwvE3W/RPdKMXbWcK99AoDGoLlNuE12ko6J6PPwzqdpvNW6oInHHVTEbxAKbPkCQd3zwljesVnx1aaDvbb3sN968Jo5/mEzxupvNR80L8mSVpoAY0ai7QJ11A9aa2S+j3qwpd88mKw9BNJt21yIIQLUG4vhjHn0mJOew/rjfb2KrpC8LwweiELvsu691iAWDHU3q4SBkJg7TEn6Ft8kC6QZ2ml96jZHBfGIpGEWVwae8+cl0qxGqzDwMKw3onUgbmOgUUGagkrF1gWyJ2Hp9owZXSBlli6kEVjMWclWvyuMx9wbVluTe1uNT6g7ZxkS+lLBmwJlniZsneINfOlpOUq1mgvVbCRNxDLBFtKnZslE8o4dGmA3X8dxgV4XMLqGIx7EfK9K0vPh/K01xhbFF1lJpNSeyxiJXu+1cuREtFE/D31c9FrDq3h+doXg86I2YcTObZyLHH/nPXjWG6isrXJ0XI/xLSOSkI72wCGg7XbqAitoG33HRhLdK37KnYf3pL84CBxJjxwtdEuMu7l2Rn9265FHxsh0LlFo9hVJxy38SVNTJgmKnWOhyAFQA4JJYkRQM/apC/e2+1vO7WHo/WqVyaIhcFgy8y2g4d6wzYudur1YcAdg9jrkYnAvbrxd0Pg/u2id0n22cdon0IBSt1ZXBzb7QUoyvqCB6RAfme0Wj2537kxCkF/puxCzj6Qhaegu9cxRy5uGQmozgnhe33zPAyGMFc4DKh928rjoApbo2qYZWleDY0yy/oVs4xJFxpWloWuma77ctum6xG123Qdqd2m64HabZYxp5riVpOJOHfSODu3Mi+QOdR5P3yaJ+fO9AieLyKGn03/jrNtKADfvny5T/FuTCcP31hr7JfzO0cPUh3hlzLiwAx7kDh9jThWEd8bsWlAlSuOYfpHdA4KPNaPe9EQDBZ6cE/QID5mVKCSfxyZ2cvGBcXy0X8W9RSb6H4nN4L9kKKYocOheB1TcAweb6+vp3sGIz4q9KWhQ0xtHKnUQK2Sd+DsqMQTHg9ALmSusYpEoJYhbfFn6X3tJqen2KRCmSZPw1ENUpBRcEY2RGOlXwUj59PLd7h6ixAmmpvZWCAUrYifbbFNzKGW75CiEOs3P298aaz8rW+/kmBeRi3aJuH4ajiWvXmEqlY4OmbdjObPnTlxMwT2pW9z+OgWH0H4uTaGyW+Y6obJbGcwG4Daz1pbo+Rmhhqe7jg2C2hemDGYz0N62Pn0cr+qjl9RYQDhh17ZvebJTuKHfNP8XIWywD1C9f3mDaGYUBSXOUtfpGdhsDXOV6BHS/S3ENfdiXqn+26K1f/XFc+6ruhAR0XptFYQT8Uhe+uO8Dd8uLyIlJ8lvKSSMLnh6/UcHP5sVdvS418btMThWcIfwErqvoHASU84ItM9rvpKqP1Jd6x5ANVE9u50GKJO1DgPZ9+jsrNR1aKQ8ITPu6sWSjNdyABRjT4nPNzWhHyGMyY9W3MFumioKUx4tEl/fwFTWadX +api: eJztWc1yG7kRfpUuXLKuGlFeJyfmEll2ZVXexCpZ2RxElaY10+RgBQKzAIY0w2JVHiJPmCdJdWOGMxR/rNhbe0l8cFEk0Gj0z9dfN9Yq4iyo8Z26dc4EdZ+pkkLhdR21s2qsbig4syBACzgjG38XIDpnwNOUPNmCAmgbHcxdSebME5YrCDUVYTSxE/sTGl1ipACXbl67oB0UzloqWHqApoapdzYC2hLIel1UFICwqABlCf88h1jRxBYY0bhZBsEBgm+s1XaWdILvaDQbwbV+BTOKAfK8aEJ0c7lSnsOycoEgz+kzFU2kPJ9Y7xrW6hGLJ4iVd82sgjy//vjpFs75fuG8QGPyHP79z39B7d1Cl+ThiVYBQsQVBPIL8mdBlzRSmXI1eWSFr0o1Vj7Z7EEEqUx5+qWhEN+6cqXGa1U4G8lG/oh1bXQhO89/DmzwtQpFRXPkT7VnuVFT4L+StPFa6Uhz+YB29XGqxnfPV3qyJfndFViWmo9Bcz1YG31DmYqrmtRYucefqYhqk627b2xjjNrcZyrqaEiiQSRvMlWTn+sQdNK5P6fdGaLXdqYyRbaZc3ihMW6pMoXhSXGM2ZW6P3XSdS9/02m4L71wNkQ1Vo+NNlFb1e+/5ZV80BQbM1yyyZTF+UFpc21/JDuLlRp/30v6K6/ebLIjJpyiCXs2TD7Xnkq+u5w3uNvbpAqH56WzUz1Tz7PuR5phsQKy0a8ywKKgOlLJoe9poYMkz9LrGMnCI02dJ04S8LR0/glC1MZAjT6QZKGcd6atZG4A9ARolrgKkmWS3KV8ax0YZ2fkOUunetZ4KqEiT39sxUtceyi9q8PE0oJ8qyQsdawAYYme83IEH4hqiJXm4+bQ2KiNyCgbTDABS21Lt4TCuEBhJGH3vx7GM4y0xNWpMO6WsO4tLL08lK+7HUOJRYvMLFLbSLOEZC+XejXYxGlS/HfbL4puZ18aXr77st8zyOzjjhxKOeW4b8/6oS23Vtm55OC4Pye3DiBhPx9eGD0PgxO+HEjD1Xsu2D3/VLx1OmyD6TcOumCa2cs3f+LVv6Jz5fR9b/axeUPTPYy/rQhChQyxtXd8xoAbAULroBZfa6dtDIBREP1CsLjxBfVELAMBZEAoPJVko0YzFsztxLulDfLF8BxbQuB9aCZW2FSAAi14agKBjlmiYwLvOgaWAbUzuliNBIPk44FY2eJe2P9xGwbHcVbbirxmqx9C3N7S71pRDK4dPzrs1K89KwEti1kxJoikXRDZC5RtzolG3xRnnan2g+t6YOHnofX3CqM4OtHjOa6gdFue6+wwAjKoySdO/0Ts1G9Rd+j2U/lwncLmUErkeYqpPAfrSgI3lZsEXFCZcmEEVxHm2nvnw+A3SyFKS2BLqJwpA190YqeaTAnOQt342gVqGwiDkXwbyXwGQqmnkkkRnjSLwAAItcGCIDqYuW80zQ4qt2lzykQn2OFHS9zTGIIBDGUdBWvvxIb59O4DMNJq07VpNfkz8XVJReKRgic3JBdN5nSWzsTQZ9vV48JgCON8r0zlGSwrXVTSFQVgYoePhtXThiY2eabnrHPN2tII2NMJ2Ar0XlNg7tnjVsj6pguLwjU2wtW7kEmcTmxqkUIGzsuRZ86aFVSMkIx43O0xnW35agklRszAugjYxMox5HYEV2wnNriKUDpRJAJ9jmTL7bX7+77FQDmrjxEeMdBWfexNm+eJueY5x+LE5nmfFnmeJbZt2+tHllS4BXmWsedVCUPrIGg7M5TSNDrgvnE1saRjJck7gvfCxfuDhLaD0Qvx/CCr2viPrj4ztCAjcKDZddMmUDmx2obI/JyToms8WOVYkQU9s2y+zutL15gSDEWZD4ht4ZGMJm4qIGcEzUVZTWFilyzBulhxmrLfAuiYYqEkQ3yQSY3P4B6hJmO0nYXWpaJlut3U+TQbCDjnnzBwGnhMRqnQSqNSU8kWCNpwlQSUIF+JNGdTwMsG9HNuQ+57QEfvd8n4Fs9PIX47MblJTb/a8PoeCLhVkS9C7WxI5eTN69epLA6T/FNTFBTCtDFw0y5OPOtrZgeSQYMaKBEmlKzn0bxiQNFeb7KuZ96dOuyW0ePWetttZlop85gdMbsKHmvJd+n4MyA8zvNPMft3AxHCPusmPvSG+7V7zSuWD5+SfDYFGvPgaXrqtpdo2OtTJZGC5QPj28H7PjpnCO0Xul0s4SNLOBS6e4OKgYo7QhKWcoTvFaQLmccJ8G/paI++UnhwZ0IoOMZjQkHePO9OzHOGoig0QPjcaN3Vgc1oPcDFzWidOqrNaN1X1k2eA3PxiWURadzHGPLodTkjCGTLbubnDo77Rifi+TLF8CY70DqFE6F9qm3a65G+3A4daYBO986Hu2W++ZOOD1x8vrDxNi2Fn9qlQ7595N5PtDol8QOtfuvwZo0OhPSA1BykWu3kOQVtKh/CrAZFqiVZflvV4DtGao9FDBDaVuvNq1Phdby47BDsvpnebUF3m/5njj1+6cvhtn0y3l09GF2kestsYdFO9cthI5nozIFr//6VJDmL26c3Qjxi420q0izdu4bZhte1UJV9HhvDxG7JVst2mfu6JgKGJyYXrKn0rQloqEamnWZ1ygGtReBykNUvrvVtgd5Itf/Dmzf79bx9CuFLv+fW5euLeUkRtTmRecYVR94JjlbJjhCcoj8/umILN/NwcNzSLf0LhYAzOjXz7JaKMUDGU101luXD8ikAFz8PxOx545Jt+Tl+MYHYNkn9dt0gN3oXJQ8dN8W75IJT4fHD7e31nsAUH3OKleOHotoF3lIjTxJUW4nausljJXlkCuK5xhtegrUWt6U/qxjrMD4/p2ZUGNeUI+n3cYQ6LbxnGUXjdVyJkIvrqw+0+oFQytHd/XCBMJQUP7vLtjbHWjNod1NWdSF8X/+jgyDNYV6lXXxNjuOb/unr/Wec14YGT1l3gxn/s1n8dhTTFYLtA097+CCEXyqjn673JXk4sNwpvH2gdvPsnWHnttb23+4rtnvu7lx2b9D6coXSrHNAwAdDuN2xWz9v7WdcXdnmULyXjJu6YcJdSAjBxfXVPs0b/sTghUXsyXv7s8qeBWcfkzxzmwt0qUg4/9P2F77EloSo16PvR6/TvUKcox0c0b1G37Yvq8/agS2g/v/Z+kXP1m2AMnCe1wbT66h4b92C0p3qH7ETLN1nqmLYGt+p9ZpHIH/zZrPhr39pyDPO3GdqgV5zOyAgk3WgwAkvrDChtY1n7avEAk2TEOZZFeQsSjsuZBRxcu39AFnZJCpTj+2TO7uZH+aR4YD/Hyt5td9yd/lurQzaWcOFa6ySTP73H4ZAqs4= sidebar_class_name: "post api-method" info_path: reference/api/agenta-api custom_edit_url: null diff --git a/docs/docs/reference/openapi.json b/docs/docs/reference/openapi.json index bcb904a42c0..1912c514c52 100644 --- a/docs/docs/reference/openapi.json +++ b/docs/docs/reference/openapi.json @@ -1 +1 @@ -{"openapi":"3.1.0","info":{"title":"Agenta API","description":"Agenta API","contact":{"name":"Agenta","url":"https://agenta.ai/","email":"team@agenta.ai"},"version":"0.1.0"},"paths":{"/access/plans":{"get":{"tags":["Access"],"summary":"Fetch Plans","description":"Return the effective plan catalog: slug -> entitlement controls.\n\nThe shape mirrors what `AGENTA_ACCESS_PLANS` accepts, but fully parsed\nand validated. The frontend reads `flags`, `counters`, `gauges`, and\n`throttles` from here rather than slug-matching against constants.","operationId":"fetch_access_plans","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":{"additionalProperties":true,"type":"object"},"type":"object","title":"Response Fetch Access Plans"}}}}}}},"/billing/stripe/events/":{"post":{"tags":["Billing"],"summary":"Handle Events","operationId":"handle_events","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/billing/stripe/portals/":{"post":{"tags":["Billing"],"summary":"Create Portal User Route","operationId":"create_portal","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/billing/stripe/checkouts/":{"post":{"tags":["Billing"],"summary":"Create Checkout User Route","operationId":"create_checkout","parameters":[{"name":"plan","in":"query","required":true,"schema":{"type":"string","title":"Plan"}},{"name":"success_url","in":"query","required":true,"schema":{"type":"string","title":"Success Url"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/billing/plans":{"get":{"tags":["Billing"],"summary":"Fetch Plan User Route","operationId":"fetch_plans","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/billing/plans/switch":{"post":{"tags":["Billing"],"summary":"Switch Plans User Route","operationId":"switch_plans","parameters":[{"name":"plan","in":"query","required":true,"schema":{"type":"string","title":"Plan"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/billing/subscription":{"get":{"tags":["Billing"],"summary":"Fetch Subscription User Route","operationId":"fetch_subscription","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/billing/subscription/cancel":{"post":{"tags":["Billing"],"summary":"Cancel Subscription User Route","operationId":"cancel_plan","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/billing/usage":{"get":{"tags":["Billing"],"summary":"Fetch Usage User Route","operationId":"fetch_usage","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/billing/catalog":{"get":{"tags":["Billing"],"summary":"Fetch Catalog","description":"Return the effective billing catalog with pricing merged in.\n\nEach entry carries `title`, `description`, `plan`, `type`, `features`,\nand (when configured) a `price` block sourced from the matching\n`AGENTA_BILLING_PRICING` entry. Pre-joining avoids a client-side\ncatalog × pricing merge by slug.","operationId":"fetch_billing_catalog","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Response Fetch Billing Catalog"}}}}}}},"/billing/pricing":{"get":{"tags":["Billing"],"summary":"Fetch Pricing","description":"Return the effective pricing map: plan slug -> normalized pricing.\n\nIncludes backend-resolved free/trial fallback markers so clients do\nnot need to duplicate billing default rules.","operationId":"fetch_billing_pricing","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":{"additionalProperties":true,"type":"object"},"type":"object","title":"Response Fetch Billing Pricing"}}}}}}},"/events/query":{"post":{"tags":["Events"],"summary":"Query Events","operationId":"query_events_rpc","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventsQueryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations/domains/":{"get":{"tags":["Organizations"],"summary":"List Domains","description":"List all domains for the organization.","operationId":"list_organization_domains","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/OrganizationDomainResponse"},"type":"array","title":"Response List Organization Domains"}}}}}},"post":{"tags":["Organizations"],"summary":"Create Domain","description":"Create a new domain for verification.\n\nThis endpoint initiates the domain verification process by:\n1. Creating a domain record\n2. Generating a unique verification token\n3. Returning DNS configuration instructions\n\nThe user must add a DNS TXT record to verify ownership.","operationId":"create_organization_domain","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationDomainCreate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationDomainResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations/domains/verify":{"post":{"tags":["Organizations"],"summary":"Verify Domain","description":"Verify domain ownership via DNS TXT record.\n\nThis endpoint checks for the presence of the verification TXT record\nand marks the domain as verified if found.","operationId":"verify_organization_domain","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationDomainVerify"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationDomainResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations/domains/{domain_id}/refresh":{"post":{"tags":["Organizations"],"summary":"Refresh Domain Token","description":"Refresh the verification token for an unverified domain.\n\nGenerates a new token and resets the 48-hour expiry window.\nThis is useful when the original token has expired.","operationId":"refresh_organization_domain_token","parameters":[{"name":"domain_id","in":"path","required":true,"schema":{"type":"string","title":"Domain Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationDomainResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations/domains/{domain_id}/reset":{"post":{"tags":["Organizations"],"summary":"Reset Domain","description":"Reset a verified domain to unverified state for re-verification.\n\nGenerates a new token and marks the domain as unverified.\nThis allows re-verification of already verified domains.","operationId":"reset_organization_domain","parameters":[{"name":"domain_id","in":"path","required":true,"schema":{"type":"string","title":"Domain Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationDomainResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations/domains/{domain_id}":{"delete":{"tags":["Organizations"],"summary":"Delete Domain","description":"Delete a domain.","operationId":"delete_organization_domain","parameters":[{"name":"domain_id","in":"path","required":true,"schema":{"type":"string","title":"Domain Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations/providers/":{"get":{"tags":["Organizations"],"summary":"List Providers","description":"List all SSO providers for the organization.","operationId":"list_organization_providers","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/OrganizationProviderResponse"},"type":"array","title":"Response List Organization Providers"}}}}}},"post":{"tags":["Organizations"],"summary":"Create Provider","description":"Create a new SSO provider configuration.\n\nSupported provider types:\n- oidc: OpenID Connect\n- saml: SAML 2.0 (coming soon)","operationId":"create_organization_provider","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationProviderCreate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationProviderResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations/providers/{provider_id}":{"patch":{"tags":["Organizations"],"summary":"Update Provider","description":"Update an SSO provider configuration.","operationId":"update_organization_provider","parameters":[{"name":"provider_id","in":"path","required":true,"schema":{"type":"string","title":"Provider Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationProviderUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationProviderResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Organizations"],"summary":"Delete Provider","description":"Delete an SSO provider configuration.","operationId":"delete_organization_provider","parameters":[{"name":"provider_id","in":"path","required":true,"schema":{"type":"string","title":"Provider Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations/providers/{provider_id}/test":{"post":{"tags":["Organizations"],"summary":"Test Provider","description":"Test SSO provider connection.\n\nThis endpoint tests the OIDC provider configuration by fetching the\ndiscovery document and validating required endpoints exist.\nIf successful, marks the provider as valid (is_valid=true).\nIf failed, marks as invalid and deactivates (is_valid=false, is_active=false).","operationId":"test_organization_provider","parameters":[{"name":"provider_id","in":"path","required":true,"schema":{"type":"string","title":"Provider Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationProviderResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/secrets/":{"get":{"tags":["Secrets"],"summary":"List Secrets","operationId":"list_secrets","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/PublicSecretResponseDTO"},"type":"array","title":"Response List Secrets"}}}}}},"post":{"tags":["Secrets"],"summary":"Create Secret","operationId":"create_secret","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSecretDTO"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicSecretResponseDTO"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/secrets/{secret_id_or_slug}":{"get":{"tags":["Secrets"],"summary":"Read Secret","operationId":"read_secret","parameters":[{"name":"secret_id_or_slug","in":"path","required":true,"schema":{"type":"string","title":"Secret Id Or Slug"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicSecretResponseDTO"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/secrets/{secret_id}":{"put":{"tags":["Secrets"],"summary":"Update Secret","operationId":"update_secret","parameters":[{"name":"secret_id","in":"path","required":true,"schema":{"type":"string","title":"Secret Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSecretDTO"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicSecretResponseDTO"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Secrets"],"summary":"Delete Secret","operationId":"delete_secret","parameters":[{"name":"secret_id","in":"path","required":true,"schema":{"type":"string","title":"Secret Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/providers/probe":{"post":{"tags":["Secrets"],"summary":"Probe Provider","operationId":"probe_provider","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProbeProviderRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProbeProviderResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/webhooks/subscriptions/":{"post":{"tags":["Webhooks"],"summary":"Create Subscription","operationId":"create_webhook_subscription","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookSubscriptionCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookSubscriptionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/webhooks/subscriptions/test":{"post":{"tags":["Webhooks"],"summary":"Test Subscription","operationId":"test_webhook_subscription","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookSubscriptionTestRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDeliveryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/webhooks/subscriptions/{subscription_id}":{"get":{"tags":["Webhooks"],"summary":"Fetch Subscription","operationId":"fetch_webhook_subscription","parameters":[{"name":"subscription_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Subscription Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookSubscriptionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Webhooks"],"summary":"Edit Subscription","operationId":"edit_webhook_subscription","parameters":[{"name":"subscription_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Subscription Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookSubscriptionEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookSubscriptionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Webhooks"],"summary":"Delete Subscription","operationId":"delete_webhook_subscription","parameters":[{"name":"subscription_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Subscription Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/webhooks/subscriptions/query":{"post":{"tags":["Webhooks"],"summary":"Query Subscriptions","operationId":"query_webhook_subscriptions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookSubscriptionQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookSubscriptionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/webhooks/subscriptions/{subscription_id}/start":{"post":{"tags":["Webhooks"],"summary":"Start Subscription","operationId":"start_webhook_subscription","parameters":[{"name":"subscription_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Subscription Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookSubscriptionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/webhooks/subscriptions/{subscription_id}/stop":{"post":{"tags":["Webhooks"],"summary":"Stop Subscription","operationId":"stop_webhook_subscription","parameters":[{"name":"subscription_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Subscription Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookSubscriptionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/webhooks/deliveries":{"post":{"tags":["Webhooks"],"summary":"Create Delivery","operationId":"create_webhook_delivery","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDeliveryCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDeliveryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/webhooks/deliveries/{delivery_id}":{"get":{"tags":["Webhooks"],"summary":"Fetch Delivery","operationId":"fetch_webhook_delivery","parameters":[{"name":"delivery_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Delivery Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDeliveryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/webhooks/deliveries/query":{"post":{"tags":["Webhooks"],"summary":"Query Deliveries","operationId":"query_webhook_deliveries","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDeliveryQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDeliveriesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/otlp/v1/traces":{"get":{"tags":["OpenTelemetry"],"summary":"Status check for OTLP","description":"Return the OTLP endpoint liveness status.\n\nLightweight readiness probe. Returns `{\"status\": \"ready\"}` when\nthe router is mounted. Intended for health checks from OTel\ncollectors before they start exporting traces.","operationId":"otlp_status","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CollectStatusResponse"}}}}}},"post":{"tags":["OpenTelemetry"],"summary":"Ingest traces via OTLP","description":"Ingest traces via the OTLP/HTTP protobuf protocol.\n\nThis endpoint accepts a serialized\n`ExportTraceServiceRequest` protobuf. Point any OTLP/HTTP\ncollector or SDK at `POST /otlp/v1/traces` and spans will flow\ninto the same ingest stream as the Agenta-native endpoints.\n\nUse this when you already have OTel instrumentation emitting\nOTLP. For new integrations that don't need raw OTLP, prefer\n`POST /tracing/spans/ingest` — it takes JSON, accepts Agenta's\nnested shape directly, and surfaces parse failures immediately.\n\n## Content-Type and size limit\n\nBinary protobuf only (`Content-Type: application/x-protobuf`).\nJSON OTLP is not accepted. Requests larger than the configured\nbatch limit (default 10 MB, see `AGENTA_OTLP_MAX_BATCH_BYTES`) return\n`413 Request Entity Too Large`.\n\n## Response\n\nSuccessful ingest returns `200 OK` with a serialized\n`ExportTraceServiceResponse` protobuf. Parse failures on the\nrequest body return `400`; malformed spans return `500`; quota\nexhaustion returns `403`. Like the native ingest paths, spans\nare queued on a Redis stream and persisted asynchronously — see\n[Tracing — Async write\ncontract](/reference/api-guide/tracing#async-write-contract-202).","operationId":"otlp_ingest","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CollectStatusResponse"}}}}}}},"/auth/discover":{"post":{"tags":["Access"],"summary":"Discover","description":"Discover authentication methods available for a given email.\n\nThis endpoint does NOT reveal:\n- Organization names\n- User existence (optionally - currently does for UX)\n- Detailed policy information\n\nReturns minimal information needed for authentication flow.","operationId":"discover_access","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DiscoverRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DiscoverResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/auth/access":{"get":{"tags":["Access"],"summary":"Check Organization Access","description":"Check if the current session satisfies the organization's auth policy.\n\nReturns 200 when access is allowed, 403 with AUTH_UPGRADE_REQUIRED when not.","operationId":"check_organization_access","parameters":[{"name":"organization_id","in":"query","required":true,"schema":{"type":"string","title":"Organization Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/auth/session/identities":{"patch":{"tags":["Access"],"summary":"Update Session Identities","operationId":"update_session_identities","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionIdentitiesUpdate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/auth/sso/callback/{organization_slug}/{provider_slug}":{"get":{"tags":["Access"],"summary":"Sso Callback Redirect","description":"Custom SSO callback endpoint that redirects to SuperTokens.\n\nThis endpoint:\n1. Accepts clean URL path: /auth/sso/callback/{organization_slug}/{provider_slug}\n2. Validates the organization and provider exist\n3. Builds SuperTokens thirdPartyId: sso:{organization_slug}:{provider_slug}\n4. Redirects to SuperTokens callback: /auth/callback/{thirdPartyId}\n\nSuperTokens then handles:\n1. Exchange code for tokens (using our dynamic provider config)\n2. Get user info\n3. Call our sign_in_up override (creates user_identity, adds user_identities to session)\n4. Redirect to frontend with session cookie","operationId":"sso_callback_redirect","parameters":[{"name":"organization_slug","in":"path","required":true,"schema":{"type":"string","title":"Organization Slug"}},{"name":"provider_slug","in":"path","required":true,"schema":{"type":"string","title":"Provider Slug"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tracing/spans/ingest":{"post":{"tags":["Deprecated"],"summary":"Ingest Spans","description":"Ingest spans into the tracing backend.\n\nUse this endpoint to write full OpenTelemetry-style spans — including\nmulti-span hierarchies (parent → child → grandchild), attributes,\nreferences, events and links. For simple single-span annotations or\nevaluator outputs, prefer `POST /preview/tracing/traces/`\n(`create_simple_trace`) — it's a higher-level helper on top of this\nendpoint.\n\n## Request body\n\nProvide exactly one of:\n\n- `spans`: a flat list of spans. Parent/child relationships are\n expressed via `parent_id` on each span.\n- `traces`: a nested tree keyed by `trace_id` then by span name,\n where each node may contain a `spans` dict of its children. The\n query endpoint (`POST /tracing/spans/query`) returns this shape.\n\nEach span requires `trace_id`, `span_id`, `start_time`, `end_time`.\n`trace_id` must be a 32-char hex UUID, `span_id` a 16-char hex.\nAttributes follow the Agenta convention under the `ag` namespace\n(`ag.type`, `ag.data`, `ag.metrics`, `ag.references`) and may be\nsubmitted either as a flat dotted map (OTel wire format) or as a\nnested object — both are accepted.\n\n## Response\n\nReturns `202 Accepted` with the links (`trace_id` + `span_id`) for\nthe spans that were parsed into the ingest stream. See\n[Tracing — Async write\ncontract](/reference/api-guide/tracing#async-write-contract-202)\nfor what `count < N submitted` means.\n\n## Example\n\n```json\n{\n \"spans\": [\n {\n \"trace_id\": \"f5a2efb40895881e938e2ebc070beca8\",\n \"span_id\": \"15f3df0731995245\",\n \"span_name\": \"completion_v0\",\n \"span_type\": \"workflow\",\n \"span_kind\": \"SPAN_KIND_SERVER\",\n \"start_time\": \"2026-04-16T18:18:18.491929Z\",\n \"end_time\": \"2026-04-16T18:18:20.415372Z\",\n \"attributes\": {\n \"ag.type.trace\": \"invocation\",\n \"ag.type.span\": \"workflow\",\n \"ag.data.inputs.country\": \"France\",\n \"ag.data.outputs\": \"Paris\"\n }\n }\n ]\n}\n```","operationId":"ingest_spans","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OTelTracingRequest"}}},"required":true},"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OTelLinksResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"deprecated":true}},"/tracing/spans/query":{"post":{"tags":["Deprecated"],"summary":"Query Spans","description":"Query spans and traces in the tracing backend.\n\nUse `focus` in the request body to control the response shape:\n\n- `\"trace\"` (default): returns a nested `traces` tree keyed by\n `trace_id` then by span name. Children hang off their parent's\n `spans` field. Best for rendering a trace waterfall.\n- `\"span\"`: returns a flat `spans` list. Best for paginating or\n filtering across all spans regardless of hierarchy.\n\nUse `oldest` / `newest` (unix seconds) to window the query and\n`limit` to cap the number of traces/spans returned.\n\nThe response preserves the Agenta `ag.*` attribute namespace and\nincludes computed metrics (`ag.metrics.duration`, `ag.metrics.tokens`,\n`ag.metrics.costs`) on each span. The `traces` tree returned here is\nthe same shape that `POST /tracing/spans/ingest` accepts as its\n`traces` field.","operationId":"query_spans_rpc","deprecated":true,"parameters":[{"name":"focus","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/Focus"},{"type":"null"}],"title":"Focus"}},{"name":"format","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/Format"},{"type":"null"}],"title":"Format"}},{"name":"oldest","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"null"}],"title":"Oldest"}},{"name":"newest","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"null"}],"title":"Newest"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}},{"name":"interval","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"}},{"name":"rate","in":"query","required":false,"schema":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Rate"}},{"name":"filter","in":"query","required":false,"schema":{"title":"Filter"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OTelTracingResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tracing/analytics/query":{"post":{"tags":["Deprecated"],"summary":"Fetch Analytics","description":"Aggregate span metrics into time buckets.\n\nRuns filtering and windowing identical to `POST /tracing/spans/query`,\nthen bucketizes the matched spans by time and computes one or more\nmetric summaries per bucket. Use this to build charts of latency,\ncost, token usage, or custom numeric and categorical attributes.\n\n## Request body\n\n- `filtering` — same shape as the query endpoint, scoped to the spans\n that contribute to the analytics.\n- `windowing` — `oldest`/`newest` for the time range and `interval`\n for bucket width (in seconds).\n- `specs` — a list of `MetricSpec` entries describing which\n attributes to summarize and how. Each spec declares a `type`\n (`numeric/continuous`, `numeric/discrete`, `binary`,\n `categorical/single`, `categorical/multiple`, `string`, `json`,\n or `*` for auto) and a dotted `path` into the span (for example\n `attributes.ag.metrics.costs.cumulative.total`).\n\n## Response\n\nBuckets are returned in chronological order. Each bucket carries a\n`metrics` dict keyed by spec path. See [Tracing — the ag.*\nnamespace](/reference/api-guide/tracing#the-ag-attribute-namespace)\nfor the cumulative/incremental metric layout on each span.","operationId":"query_analytics","deprecated":true,"parameters":[{"name":"focus","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/Focus"},{"type":"null"}],"title":"Focus"}},{"name":"format","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/Format"},{"type":"null"}],"title":"Format"}},{"name":"oldest","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"null"}],"title":"Oldest"}},{"name":"newest","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"null"}],"title":"Newest"}},{"name":"interval","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"}},{"name":"rate","in":"query","required":false,"schema":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Rate"}},{"name":"filter","in":"query","required":false,"schema":{"title":"Filter"}},{"name":"specs","in":"query","required":false,"schema":{"title":"Specs"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyticsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tracing/traces/":{"post":{"tags":["Deprecated"],"summary":"Create Trace","description":"Create a trace from one or more spans.\n\nThis is the single-trace counterpart to `POST /tracing/spans/ingest`.\nAccepts the same `OTelTracingRequest` body (either `spans` flat list\nor `traces` nested tree) but requires all spans to share a single\n`trace_id`.\n\nReturns `202 Accepted` with the links for the spans that entered\nthe ingest stream. See [Tracing — Async write\ncontract](/reference/api-guide/tracing#async-write-contract-202).\n\nMost callers should prefer `POST /tracing/spans/ingest` (no\nsingle-trace restriction) or `POST /simple/traces/` (helper for a\none-span payload).","operationId":"create_trace_tracing","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OTelTracingRequest"}}},"required":true},"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OTelLinksResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"deprecated":true}},"/tracing/traces/{trace_id}":{"get":{"tags":["Deprecated"],"summary":"Fetch Trace","description":"Fetch a single trace by `trace_id`.\n\nReturns the trace as a `traces` map keyed by `trace_id` → span\nname. The response is empty when the trace is not in the current\nproject. `trace_id` must be a 32-char hex UUID; any other format\nreturns `400`.\n\nFor flat-list retrieval across many traces, use\n`POST /tracing/spans/query` with `focus=\"span\"`.","operationId":"fetch_trace_tracing","deprecated":true,"parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OTelTracingResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Deprecated"],"summary":"Edit Trace","description":"Replace the spans of an existing trace.\n\nThe path `trace_id` must match the `trace_id` in the payload.\nMismatches return `400`. The payload must contain exactly one\ntrace; submitting spans from more than one trace returns `400`.\n\nEdit is implemented as a re-ingest: the new spans are written\nthrough the same stream as `POST /tracing/spans/ingest`, and the\n`202 Accepted` response reports how many spans entered the stream.\nThe worker reconciles the trace asynchronously.","operationId":"edit_trace_tracing","deprecated":true,"parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OTelTracingRequest"}}}},"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OTelLinksResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Deprecated"],"summary":"Delete Trace","description":"Delete a trace and all its spans.\n\nRemoves every span that shares this `trace_id` within the project.\nReturns `202 Accepted` with the links for the spans that were\nmarked for deletion. `trace_id` must be a 32-char hex UUID.\n\nDeletion is not reversible. For soft-removal semantics on a\nsingle-trace simple annotation, prefer\n`DELETE /simple/traces/{trace_id}`.","operationId":"delete_trace_tracing","deprecated":true,"parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}}],"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OTelLinksResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tracing/sessions/query":{"post":{"tags":["Deprecated"],"summary":"List Sessions","description":"List distinct session IDs from span attributes.\n\nReturns the distinct values of `ag.session.id` across spans in the\ncurrent project, in a windowed, cursor-paginated form. Use this to\ndrive a session-picker UI before drilling into the spans of each\nsession.\n\nThe `realtime` flag controls the cursor field:\n\n- `false` or unset — paginate by a stable `first_active` cursor\n (safe to iterate under heavy write load).\n- `true` — paginate by `last_active`, reflecting ongoing activity\n but less stable between pages.\n\nThe response includes a `windowing` cursor; pass it as `windowing.next`\non the next call to continue.","operationId":"query_sessions_tracing","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionsQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionIdsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"deprecated":true}},"/tracing/users/query":{"post":{"tags":["Deprecated"],"summary":"List Users","description":"List distinct user IDs from span attributes.\n\nReturns the distinct values of `ag.user.id` across spans in the\ncurrent project. Same pagination and `realtime` semantics as\n`POST /tracing/sessions/query`; pass the returned `windowing.next`\ncursor on subsequent calls.","operationId":"query_users","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UsersQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserIdsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"deprecated":true}},"/tracing/spans/analytics":{"post":{"tags":["Legacy"],"summary":"Fetch Legacy Analytics","description":"Aggregate span metrics using the fixed legacy schema.\n\nReturns time-bucketed aggregates with a fixed set of fields\n(`count`, `duration`, `costs`, `tokens`) split into `total` and\n`errors`. The shape predates `specs`-driven analytics and is kept\nfor the existing observability dashboards that consume it.\n\nNew integrations should prefer `POST /tracing/analytics/query`,\nwhich accepts `specs` and can summarize arbitrary span attributes,\nnot just the four fixed metrics.","operationId":"fetch_legacy_analytics","parameters":[{"name":"focus","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/Focus"},{"type":"null"}],"title":"Focus"}},{"name":"format","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/Format"},{"type":"null"}],"title":"Format"}},{"name":"oldest","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"null"}],"title":"Oldest"}},{"name":"newest","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"null"}],"title":"Newest"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}},{"name":"interval","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"}},{"name":"rate","in":"query","required":false,"schema":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Rate"}},{"name":"filter","in":"query","required":false,"schema":{"title":"Filter"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OldAnalyticsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/traces/":{"get":{"tags":["Traces"],"summary":"Fetch Traces","description":"Fetch multiple traces by known IDs.\n\nPoint lookup endpoint. Accepts either repeated query params\n(`?trace_id=a&trace_id=b`) or a comma-separated single param\n(`?trace_ids=a,b`). Results are deduplicated. Returns `400` when\nno IDs are supplied. Use `POST /traces/query` for filter-based\nretrieval.","operationId":"fetch_traces","parameters":[{"name":"trace_id","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Trace Id"}},{"name":"trace_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Trace Ids"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TracesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["Traces"],"summary":"Create Trace","description":"Create a single trace from the canonical `Trace` shape.\n\nAccepts one trace (`trace_id` plus a nested `spans` tree) and\nreturns the resulting `trace_id`. The payload is internally\nnormalized into the same ingest pipeline as\n`POST /tracing/spans/ingest`.\n\nReturns `202 Accepted`. The async write contract applies — see\n[Tracing — Async write\ncontract](/reference/api-guide/tracing#async-write-contract-202).\n\nUse this when you want to operate on whole traces in the\nlist-shaped `Trace` payload. For flat-list ingestion or multiple\ntraces in one call, use `POST /traces/ingest` (plural).","operationId":"create_trace","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TraceRequest"}}}},"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TraceIdResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/traces/query":{"post":{"tags":["Traces"],"summary":"Query Traces","description":"Query traces as a list of canonical `Trace` records.\n\nThin wrapper over the shared span-query backend that forces\n`focus = \"trace\"` and returns the list-shaped `Traces` payload\n(one entry per trace, each with its nested `spans` tree). Use this\nto build a table of runs, where each row is a trace.\n\n## Request body\n\n- `filtering` — span-level conditions, same dialect as\n `POST /spans/query`. A trace matches when any of its spans\n matches.\n- `windowing` — cursor pagination and time range.\n- `query_ref`, `query_variant_ref`, `query_revision_ref` — resolve\n filters and windowing from a saved query revision. If the\n revision's stored `formatting.focus` is `span`, this endpoint\n returns `409` — call `POST /spans/query` instead.\n\n## Response\n\nReturns `{count, traces: [...]}`. For the per-trace map shape\nkeyed by `trace_id`, call `POST /tracing/spans/query` with\n`focus=\"trace\"`.","operationId":"query_traces","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TracesQueryRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TracesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/traces/{trace_id}":{"get":{"tags":["Traces"],"summary":"Fetch Trace","description":"Fetch a single trace by `trace_id` in the canonical `Trace` shape.\n\nReturns `{count: 1, trace}` when found and `{count: 0}` otherwise.\n`trace_id` must be a 32-char hex UUID; any other format returns\n`400`. The reserved path segments `query` and `ingest` return\n`405` to disambiguate from the sibling query/ingest endpoints.","operationId":"fetch_trace","parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TraceResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Traces"],"summary":"Edit Trace","description":"Replace a trace's spans using the canonical `Trace` shape.\n\nPath `trace_id` must match the `trace_id` inside the payload's\n`trace.trace_id`. Mismatches return `400`. The payload must\ndescribe exactly one trace.\n\nEdit re-ingests the spans through the same stream as\n`POST /tracing/spans/ingest`. Returns `202 Accepted` once the\nspans are queued. The worker reconciles the trace asynchronously.","operationId":"edit_trace","parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TraceRequest"}}}},"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TraceIdResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Traces"],"summary":"Delete Trace","operationId":"delete_trace","parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}}],"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TraceIdResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/traces/ingest":{"post":{"tags":["Deprecated"],"summary":"Ingest Traces","description":"Ingest a batch of traces in the canonical `Traces` list shape.\n\nAccepts a list of trace records (each `trace_id` plus nested\n`spans`). Internally normalized into the same pipeline as\n`POST /tracing/spans/ingest`. Use this when you already hold\ndata in the `Traces` list shape — for example, replaying traces\nfrom another environment.\n\nReturns `202 Accepted` with the list of accepted `trace_ids`. See\n[Tracing — Async write\ncontract](/reference/api-guide/tracing#async-write-contract-202)\nfor what `count` means here.","operationId":"ingest_traces","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TracesRequest"}}},"required":true},"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TraceIdsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"deprecated":true}},"/spans/":{"get":{"tags":["Traces"],"summary":"Fetch Spans","description":"Fetch spans by known IDs.\n\nPoint lookup endpoint. At least one of `trace_id` or `span_id`\nmust be present. Both accept either repeated query params\n(`?trace_id=a&trace_id=b`) or a comma-separated single param\n(`?trace_ids=a,b`); results are deduplicated.\n\nReturns `400` when neither IDs nor trace IDs are supplied.\nFor filter-based retrieval, use `POST /spans/query`.","operationId":"fetch_spans","parameters":[{"name":"trace_id","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Trace Id"}},{"name":"trace_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Trace Ids"}},{"name":"span_id","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Span Id"}},{"name":"span_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Ids"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SpansResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/spans/query":{"post":{"tags":["Traces"],"summary":"Query Spans","description":"Query spans as a flat list.\n\nThin wrapper over the shared span-query backend that forces\n`focus = \"span\"`. Use this when you want a paged list of spans\nregardless of trace hierarchy — for example, to surface all LLM\ncalls across traces or to stream spans into an external system.\n\n## Request body\n\n- `filtering` — span-level conditions (fields on `Span` and\n `attributes` paths).\n- `windowing` — cursor pagination and time range (see\n [Query Pattern](/reference/api-guide/query-pattern#windowing)).\n- `query_ref`, `query_variant_ref`, `query_revision_ref` — resolve\n filtering and windowing from a saved query revision. If the\n revision's stored `formatting.focus` is `trace`, this endpoint\n returns `409` — call `POST /traces/query` for that revision.\n\n## Response\n\nReturns `{count, spans}`. For the nested per-trace shape, call\n`POST /traces/query` or `POST /tracing/spans/query` with\n`focus=\"trace\"` instead.","operationId":"query_spans","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SpansQueryRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SpansResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/spans/analytics/query":{"post":{"tags":["Traces"],"summary":"Query Analytics","operationId":"query_spans_analytics","parameters":[{"name":"focus","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/Focus"},{"type":"null"}],"title":"Focus"}},{"name":"format","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/Format"},{"type":"null"}],"title":"Format"}},{"name":"oldest","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"null"}],"title":"Oldest"}},{"name":"newest","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"null"}],"title":"Newest"}},{"name":"interval","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"}},{"name":"rate","in":"query","required":false,"schema":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Rate"}},{"name":"filter","in":"query","required":false,"schema":{"title":"Filter"}},{"name":"specs","in":"query","required":false,"schema":{"title":"Specs"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyticsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/spans/sessions/query":{"post":{"tags":["Traces"],"summary":"Query Sessions","operationId":"query_spans_sessions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionsQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionIdsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/spans/users/query":{"post":{"tags":["Traces"],"summary":"Query Users","operationId":"query_spans_users","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UsersQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserIdsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/spans/{trace_id}/{span_id}":{"get":{"tags":["Traces"],"summary":"Fetch Span","description":"Fetch a single span by `trace_id` + `span_id`.\n\nReturns `{count: 1, span}` when found and `{count: 0}` otherwise.\nBoth IDs are required path parameters. Use this to drill in on one\nspan from a trace waterfall without pulling the full tree.","operationId":"fetch_span","parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}},{"name":"span_id","in":"path","required":true,"schema":{"type":"string","title":"Span Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SpanResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/traces/":{"post":{"tags":["Traces"],"summary":"Create Trace","description":"Create a single-span \"simple\" trace.\n\nThis endpoint is a higher-level helper for the common case of\nrecording one self-contained event — an evaluator output, a human\nannotation, a feedback entry, a manually-logged inference. It\ncreates one span under a fresh `trace_id` and returns the resulting\nhandle.\n\n## When to use this vs. `/tracing/spans/ingest`\n\n- **Use this endpoint** when you have a single payload to record\n with no internal hierarchy: evaluation results, human feedback,\n manual annotations, or a standalone completion. It takes care of\n `trace_id`/`span_id` generation, attribute namespacing, and link\n wiring for you.\n- **Use `POST /tracing/spans/ingest`** when you need multi-span\n traces (e.g. an agent run with nested tool calls and LLM spans),\n precise control over IDs, timings, or parent/child relationships,\n or when forwarding traces from another OTel-compatible source.\n\n## Request body\n\nSend a `trace` object with:\n\n- `origin` — who produced the trace (`human`, `auto`, `custom`).\n- `kind` — intent (`adhoc`, `eval`, `play`).\n- `channel` — transport that produced it (`sdk`, `api`, `web`, `otlp`).\n- `data` — required dict carrying the actual payload (inputs,\n outputs, or evaluator results).\n- `tags`, `meta` — optional free-form dicts for filtering and\n metadata.\n- `references` — optional links to Agenta entities (application,\n variant, revision, evaluator, testset, etc.).\n- `links` — optional OTel-style links to other traces/spans.\n\nUse `PATCH /preview/tracing/traces/{trace_id}` to update fields\nlater, `GET` to fetch, and `DELETE` to remove. See\n[Tracing — References and links](/reference/api-guide/tracing#references-and-entity-linking)\nfor when to use `references` vs. `links`.","operationId":"create_simple_trace","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTraceCreateRequest"}}},"required":true},"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTraceResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/traces/{trace_id}":{"get":{"tags":["Traces"],"summary":"Fetch Trace","description":"Fetch a single \"simple\" trace by `trace_id`.\n\nReturns the high-level `SimpleTrace` view (origin, kind, channel,\ndata, references, links) rather than the raw OTel span shape. Use\nthis for evaluation results, feedback entries, and annotations\ncreated via `POST /simple/traces/`. For the span-level view of the\nsame trace, call `GET /tracing/traces/{trace_id}`.","operationId":"fetch_simple_trace","parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTraceResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Traces"],"summary":"Edit Trace","description":"Update an existing \"simple\" trace.\n\nSupplied fields overwrite the existing trace. Fields not present\nin the request body are left unchanged. `data` is required (the\npayload being recorded); `tags`, `meta`, `references`, and\n`links` are optional.\n\nThis endpoint is intended for annotations and feedback entries,\nwhere the `data.outputs` is the part that typically gets revised.\nFor span-level edits, use `PUT /tracing/traces/{trace_id}`.","operationId":"edit_simple_trace","parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTraceEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTraceResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Traces"],"summary":"Delete Trace","description":"Delete a \"simple\" trace.\n\nRemoves the single-span trace created via\n`POST /simple/traces/`. Returns the `(trace_id, span_id)` pair\nthat was removed, for logging or downstream cleanup. Use\n`DELETE /tracing/traces/{trace_id}` when operating on a\nmulti-span trace.","operationId":"delete_simple_trace","parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTraceLinkResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/traces/query":{"post":{"tags":["Traces"],"summary":"Query Traces","description":"Query \"simple\" traces.\n\nFilter annotations and feedback by `origin`, `kind`, `channel`,\n`tags`, `meta`, `references`, and `links`. The shape of the\nrequest body is described in the\n[Simple Endpoints](/reference/api-guide/simple-endpoints#query-traces)\nguide, including the distinction between filtering via\n`trace.links` (inbound links on the trace) and the top-level\n`links` (batch GET by the trace's own IDs).\n\nUse this endpoint when building feedback or annotation UIs.\nFor span-level queries across all trace types, use\n`POST /tracing/spans/query`.","operationId":"query_simple_traces","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTraceQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTracesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/invocations/":{"post":{"tags":["Invocations"],"summary":"Create Invocation","operationId":"create_invocation","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvocationCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvocationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/invocations/{trace_id}":{"get":{"tags":["Invocations"],"summary":"Fetch Invocation","operationId":"fetch_invocation","parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}},{"name":"span_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvocationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Invocations"],"summary":"Edit Invocation","operationId":"edit_invocation","parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}},{"name":"span_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvocationEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvocationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Invocations"],"summary":"Delete Invocation","operationId":"delete_invocation","parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}},{"name":"span_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvocationLinkResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/invocations/query":{"post":{"tags":["Invocations"],"summary":"Query Invocations","operationId":"query_invocations","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvocationQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvocationsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/annotations/":{"post":{"tags":["Annotations"],"summary":"Create Annotation","operationId":"create_annotation","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnnotationCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnnotationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/annotations/{trace_id}":{"get":{"tags":["Annotations"],"summary":"Fetch Annotation","operationId":"fetch_annotation","parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}},{"name":"span_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnnotationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Annotations"],"summary":"Edit Annotation","operationId":"edit_annotation","parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}},{"name":"span_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnnotationEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnnotationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Annotations"],"summary":"Delete Annotation","operationId":"delete_annotation","parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}},{"name":"span_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnnotationLinkResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/annotations/query":{"post":{"tags":["Annotations"],"summary":"Query Annotations","operationId":"query_annotations","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnnotationQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnnotationsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testcases/":{"get":{"tags":["Testcases"],"summary":"Fetch Testcases","operationId":"fetch_testcases","parameters":[{"name":"testcase_id","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Testcase Id"}},{"name":"testcase_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Testcase Ids"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestcasesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testcases/{testcase_id}":{"get":{"tags":["Testcases"],"summary":"Fetch Testcase","operationId":"fetch_testcase","parameters":[{"name":"testcase_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testcase Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestcaseResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testcases/query":{"post":{"tags":["Testcases"],"summary":"Query Testcases","operationId":"query_testcases","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestcasesQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestcasesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/":{"post":{"tags":["Testsets"],"summary":"Create Testset","description":"Create an empty testset artifact.\n\nOnly creates the artifact row (name, slug, metadata). No variant or\nrevision is created; add testcases by committing a revision with\n`/testsets/revisions/commit`, or use `/simple/testsets/` to create\na testset with seed rows in a single call.","operationId":"create_testset","parameters":[{"name":"testset_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/{testset_id}":{"get":{"tags":["Testsets"],"summary":"Fetch Testset","description":"Fetch a testset artifact by ID.\n\nReturns the artifact row only; testcases are stored on revisions and\nmust be fetched via `/testsets/revisions/retrieve` or\n`/testcases/query`.","operationId":"fetch_testset","parameters":[{"name":"testset_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Testsets"],"summary":"Edit Testset","description":"Update metadata on a testset artifact.\n\nOnly artifact-level fields (name, description, slug, flags, tags,\nmeta, folder) are editable here. Testcase changes are committed as\nnew revisions via `/testsets/revisions/commit`.","operationId":"edit_testset","parameters":[{"name":"testset_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/{testset_id}/archive":{"post":{"tags":["Testsets"],"summary":"Archive Testset","description":"Soft-delete a testset artifact.\n\nSets `deleted_at` on the testset. Archived testsets are excluded\nfrom `/testsets/query` unless `include_archived` is true. Use\n`/testsets/{testset_id}/unarchive` to restore.","operationId":"archive_testset","parameters":[{"name":"testset_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/{testset_id}/unarchive":{"post":{"tags":["Testsets"],"summary":"Unarchive Testset","description":"Restore a previously archived testset artifact.\n\nClears `deleted_at` on the testset so it shows up in queries again.","operationId":"unarchive_testset","parameters":[{"name":"testset_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/query":{"post":{"tags":["Testsets"],"summary":"Query Testsets","description":"List and filter testset artifacts.\n\nFollows the shared query pattern: attribute filters on the testset\nbody, optional `testset_refs` to restrict by id/slug, cursor-based\npagination via `windowing`. Only artifact rows are returned — no\ntestcases. See the Query Pattern guide for the full body shape.","operationId":"query_testsets","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/variants/":{"post":{"tags":["Testsets"],"summary":"Create Testset Variant","description":"Create a variant (history branch) on a testset.\n\nMost testsets only need one variant. Create additional variants to\nmaintain parallel revision histories (for example, a staging branch\nseparate from the main one).","operationId":"create_testset_variant","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetVariantCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/variants/{testset_variant_id}":{"get":{"tags":["Testsets"],"summary":"Fetch Testset Variant","description":"Fetch a variant by ID.\n\nReturns the variant row (branch metadata). Use\n`/testsets/revisions/retrieve` to get the latest revision on this\nvariant.","operationId":"fetch_testset_variant","parameters":[{"name":"testset_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Testsets"],"summary":"Edit Testset Variant","description":"Update metadata on a testset variant.\n\nVariants hold only branch-level metadata (name, description, slug,\nflags, tags, meta). Testcase content belongs to revisions.","operationId":"edit_testset_variant","parameters":[{"name":"testset_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Variant Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetVariantEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/variants/{testset_variant_id}/archive":{"post":{"tags":["Testsets"],"summary":"Archive Testset Variant","description":"Soft-delete a testset variant.\n\nArchiving a variant excludes it from `/testsets/variants/query`\nunless `include_archived` is true. Its revisions stay in place and\ncan still be retrieved by ID.","operationId":"archive_testset_variant","parameters":[{"name":"testset_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/variants/{testset_variant_id}/unarchive":{"post":{"tags":["Testsets"],"summary":"Unarchive Testset Variant","description":"Restore a previously archived testset variant.","operationId":"unarchive_testset_variant","parameters":[{"name":"testset_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/variants/query":{"post":{"tags":["Testsets"],"summary":"Query Testset Variants","description":"List and filter testset variants.\n\nUse `testset_refs` to scope to one or more parent testsets. Use\n`testset_variant_refs` to restrict by specific variant id/slug.","operationId":"query_testset_variants","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetVariantQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetVariantsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/variants/fork":{"post":{"tags":["Testsets"],"summary":"Fork Testset Variant","description":"Fork an existing testset variant into a new variant.\n\nThe new variant starts from the source variant's head revision (or a\npinned revision if `testset_revision_ref` is provided). Provide `slug`\nand `name` in the fork body to identify the new variant.","operationId":"fork_testset_variant","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetVariantForkRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/revisions/":{"post":{"tags":["Testsets"],"summary":"Create Testset Revision","description":"Create and commit the initial revision for a testset variant.\n\nMost callers instead use `/testsets/revisions/commit`, which writes\nthe testcases and the revision together. This endpoint commits an\ninitial revision with the `initial` guard, preventing duplicate\ninitial revisions for the same variant.","operationId":"create_testset_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/revisions/{testset_revision_id}":{"get":{"tags":["Testsets"],"summary":"Fetch Testset Revision","operationId":"fetch_testset_revision","parameters":[{"name":"testset_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Revision Id"}},{"name":"include_testcases","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"Include full testcase objects. Default (null/true): include testcases. False: return only testcase IDs.","title":"Include Testcases"},"description":"Include full testcase objects. Default (null/true): include testcases. False: return only testcase IDs."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Testsets"],"summary":"Edit Testset Revision","operationId":"edit_testset_revision","parameters":[{"name":"testset_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Revision Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/revisions/{testset_revision_id}/archive":{"post":{"tags":["Testsets"],"summary":"Archive Testset Revision","operationId":"archive_testset_revision","parameters":[{"name":"testset_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/revisions/{testset_revision_id}/unarchive":{"post":{"tags":["Testsets"],"summary":"Unarchive Testset Revision","operationId":"unarchive_testset_revision","parameters":[{"name":"testset_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/revisions/{testset_revision_id}/download":{"post":{"tags":["Testsets"],"summary":"Fetch Testset Revision To File","operationId":"fetch_testset_revision_to_file","parameters":[{"name":"testset_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Revision Id"}},{"name":"file_type","in":"query","required":false,"schema":{"anyOf":[{"enum":["csv","json"],"type":"string"},{"type":"null"}],"description":"File type to download. Supported: 'csv' or 'json'. Default: 'csv'.","default":"csv","title":"File Type"},"description":"File type to download. Supported: 'csv' or 'json'. Default: 'csv'."},{"name":"file_name","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Optional custom filename for the download.","title":"File Name"},"description":"Optional custom filename for the download."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/revisions/{testset_revision_id}/upload":{"post":{"tags":["Testsets"],"summary":"Create Testset Revision From File","operationId":"create_testset_revision_from_file","parameters":[{"name":"testset_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Revision Id"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_create_testset_revision_from_file"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/revisions/query":{"post":{"tags":["Testsets"],"summary":"Query Testset Revisions","operationId":"query_testset_revisions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/revisions/commit":{"post":{"tags":["Testsets"],"summary":"Commit Testset Revision","operationId":"commit_testset_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionCommitRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/revisions/retrieve":{"post":{"tags":["Testsets"],"summary":"Retrieve Testset Revision","operationId":"retrieve_testset_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionRetrieveRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/revisions/log":{"post":{"tags":["Testsets"],"summary":"Log Testset Revisions","operationId":"log_testset_revisions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionsLogRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/testsets/":{"post":{"tags":["Testsets"],"summary":"Create Simple Testset","operationId":"create_simple_testset","parameters":[{"name":"testset_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTestsetCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTestsetResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/testsets/{testset_id}":{"get":{"tags":["Testsets"],"summary":"Fetch Simple Testset","operationId":"fetch_simple_testset","parameters":[{"name":"testset_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTestsetResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Testsets"],"summary":"Edit Simple Testset","operationId":"edit_simple_testset","parameters":[{"name":"testset_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTestsetEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTestsetResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/testsets/{testset_id}/archive":{"post":{"tags":["Testsets"],"summary":"Archive Simple Testset","operationId":"archive_simple_testset","parameters":[{"name":"testset_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTestsetResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/testsets/{testset_id}/unarchive":{"post":{"tags":["Testsets"],"summary":"Unarchive Simple Testset","operationId":"unarchive_simple_testset","parameters":[{"name":"testset_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTestsetResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/testsets/{testset_id}/upload":{"post":{"tags":["Testsets"],"summary":"Edit Simple Testset From File","operationId":"edit_simple_testset_from_file","parameters":[{"name":"testset_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Id"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_edit_simple_testset_from_file"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTestsetResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/testsets/{testset_id}/download":{"post":{"tags":["Testsets"],"summary":"Fetch Simple Testset To File","operationId":"fetch_simple_testset_to_file","parameters":[{"name":"testset_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Id"}},{"name":"file_type","in":"query","required":false,"schema":{"anyOf":[{"enum":["csv","json"],"type":"string"},{"type":"null"}],"title":"File Type"}},{"name":"file_name","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"File Name"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/testsets/query":{"post":{"tags":["Testsets"],"summary":"Query Simple Testsets","operationId":"query_simple_testsets","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTestsetQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTestsetsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/testsets/upload":{"post":{"tags":["Testsets"],"summary":"Create Simple Testset From File","operationId":"create_simple_testset_from_file","requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_create_simple_testset_from_file"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTestsetResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/":{"post":{"tags":["Queries"],"summary":"Create Query","operationId":"create_query","parameters":[{"name":"query_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Query Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/{query_id}":{"get":{"tags":["Queries"],"summary":"Fetch Query","operationId":"fetch_query","parameters":[{"name":"query_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Queries"],"summary":"Edit Query","operationId":"edit_query","parameters":[{"name":"query_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/{query_id}/archive":{"post":{"tags":["Queries"],"summary":"Archive Query","operationId":"archive_query","parameters":[{"name":"query_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/{query_id}/unarchive":{"post":{"tags":["Queries"],"summary":"Unarchive Query","operationId":"unarchive_query","parameters":[{"name":"query_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/query":{"post":{"tags":["Queries"],"summary":"Query Queries","operationId":"query_queries","parameters":[{"name":"query_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Query Id"}},{"name":"query_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Query Ids"}},{"name":"query_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Query Slug"}},{"name":"query_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Query Slugs"}},{"name":"name","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"}},{"name":"description","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},{"name":"flags","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Flags"}},{"name":"tags","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tags"}},{"name":"meta","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Meta"}},{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"}},{"name":"next","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Next"}},{"name":"newest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Newest"}},{"name":"oldest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Oldest"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"enum":["ascending","descending"],"type":"string"},{"type":"null"}],"title":"Order"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueriesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/variants/":{"post":{"tags":["Queries"],"summary":"Create Query Variant","operationId":"create_query_variant","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryVariantCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/variants/{query_variant_id}":{"get":{"tags":["Queries"],"summary":"Fetch Query Variant","operationId":"fetch_query_variant","parameters":[{"name":"query_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Queries"],"summary":"Edit Query Variant","operationId":"edit_query_variant","parameters":[{"name":"query_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Variant Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryVariantEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/variants/{query_variant_id}/archive":{"post":{"tags":["Queries"],"summary":"Archive Query Variant","operationId":"archive_query_variant","parameters":[{"name":"query_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/variants/{query_variant_id}/unarchive":{"post":{"tags":["Queries"],"summary":"Unarchive Query Variant","operationId":"unarchive_query_variant","parameters":[{"name":"query_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/variants/query":{"post":{"tags":["Queries"],"summary":"Query Query Variants","operationId":"query_query_variants","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryVariantQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryVariantsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/variants/fork":{"post":{"tags":["Queries"],"summary":"Fork Query Variant","description":"Fork an existing query variant into a new variant.\n\nThe new variant starts from the source variant's head revision (or a\npinned revision if `query_revision_ref` is provided). Provide `slug`\nand `name` in the fork body to identify the new variant.","operationId":"fork_query_variant","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryVariantForkRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/revisions/retrieve":{"post":{"tags":["Queries"],"summary":"Retrieve Query Revision","operationId":"retrieve_query_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRevisionRetrieveRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/revisions/":{"post":{"tags":["Queries"],"summary":"Create Query Revision","operationId":"create_query_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRevisionCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/revisions/{query_revision_id}":{"get":{"tags":["Queries"],"summary":"Fetch Query Revision","operationId":"fetch_query_revision","parameters":[{"name":"query_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Queries"],"summary":"Edit Query Revision","operationId":"edit_query_revision","parameters":[{"name":"query_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Revision Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRevisionEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/revisions/{query_revision_id}/archive":{"post":{"tags":["Queries"],"summary":"Archive Query Revision","operationId":"archive_query_revision","parameters":[{"name":"query_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/revisions/{query_revision_id}/unarchive":{"post":{"tags":["Queries"],"summary":"Unarchive Query Revision","operationId":"unarchive_query_revision","parameters":[{"name":"query_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/revisions/query":{"post":{"tags":["Queries"],"summary":"Query Query Revisions","operationId":"query_query_revisions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRevisionQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRevisionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/revisions/commit":{"post":{"tags":["Queries"],"summary":"Commit Query Revision","operationId":"commit_query_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRevisionCommitRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/revisions/log":{"post":{"tags":["Queries"],"summary":"Log Query Revisions","operationId":"log_query_revisions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRevisionsLogRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRevisionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/queries/":{"post":{"tags":["Queries"],"summary":"Create Simple Query","operationId":"create_simple_query","parameters":[{"name":"query_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Query Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueryCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/queries/{query_id}":{"get":{"tags":["Queries"],"summary":"Fetch Simple Query","operationId":"fetch_simple_query","parameters":[{"name":"query_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Queries"],"summary":"Edit Simple Query","operationId":"edit_simple_query","parameters":[{"name":"query_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueryEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/queries/{query_id}/archive":{"post":{"tags":["Queries"],"summary":"Archive Simple Query","operationId":"archive_simple_query","parameters":[{"name":"query_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/queries/{query_id}/unarchive":{"post":{"tags":["Queries"],"summary":"Unarchive Simple Query","operationId":"unarchive_simple_query","parameters":[{"name":"query_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/queries/query":{"post":{"tags":["Queries"],"summary":"Query Simple Queries","operationId":"query_simple_queries","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueryQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueriesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/folders/":{"post":{"tags":["Folders"],"summary":"Create Folder","description":"Create a folder.\n\nThe folder name must match `[\\w -]+` (letters, digits, underscore,\nspace, hyphen); other characters return `400`. The resulting path\n(the slug joined to the parent's path with a dot) must be unique\nwithin the project, otherwise the call returns `409`. Passing a\n`parent_id` that does not exist returns `404`. Paths are capped at\n10 levels of nesting and slugs at 64 characters.","operationId":"create_folder","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FolderCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FolderResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/folders/{folder_id}":{"get":{"tags":["Folders"],"summary":"Fetch Folder","description":"Fetch one folder by id.\n\nReturns a single `folder` envelope. If the folder does not exist in\nthe caller's project, `count` is `0` and `folder` is omitted.","operationId":"fetch_folder","parameters":[{"name":"folder_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Folder Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FolderResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Folders"],"summary":"Edit Folder","description":"Rename or move a folder.\n\nUse this endpoint to change a folder's `slug`, `name`, or\n`parent_id`. The `id` in the request body must match the path\nparameter or the call returns `400`. Name and path-uniqueness rules\nfrom create apply: invalid names return `400`, a path collision\nreturns `409`, and a missing `parent_id` returns `404`.","operationId":"edit_folder","parameters":[{"name":"folder_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Folder Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FolderEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FolderResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Folders"],"summary":"Delete Folder","description":"Delete a folder and every descendant.\n\nRemoves the folder identified by `folder_id` together with every\nfolder beneath it, in a single transaction. Deletion is\nunconditional; there is no archive or unarchive step. Resources\nthat were assigned to any of the removed folders continue to\nexist and are no longer reachable through the deleted folder.","operationId":"delete_folder","parameters":[{"name":"folder_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Folder Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FolderIdResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/folders/query":{"post":{"tags":["Folders"],"summary":"Query Folders","description":"Filter folders inside the caller's project.\n\nFollows the general response envelope described in the\n[Query Pattern](/reference/api-guide/query-pattern) guide, but\ndoes not accept `windowing` or `include_archived` — folders are\nhard-deleted and the response always returns the full filtered\nset. Filters include `id`/`ids`, `slug`/`slugs`, `kind`/`kinds`,\n`parent_id`/`parent_ids` (use `parent_id: null` for root folders),\n`path`/`paths`, and `prefix`/`prefixes` for subtree lookup.","operationId":"query_folders","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FolderQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FoldersResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/streams/":{"get":{"tags":["Sessions","Sessions"],"summary":"Fetch Session Stream","operationId":"fetch_session_stream","parameters":[{"name":"session_id","in":"query","required":true,"schema":{"type":"string","title":"Session Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionStreamResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["Sessions","Sessions"],"summary":"Set Session Stream","operationId":"set_session_stream","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionStreamCommandRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionStreamCommandResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Sessions","Sessions"],"summary":"Delete Session Stream","operationId":"delete_session_stream","parameters":[{"name":"session_id","in":"query","required":true,"schema":{"type":"string","title":"Session Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Delete Session Stream"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/streams/query":{"post":{"tags":["Sessions","Sessions"],"summary":"Query Session Streams","operationId":"query_session_streams","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionStreamQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionStreamsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/streams/detach":{"post":{"tags":["Sessions","Sessions"],"summary":"Detach Session Stream","operationId":"detach_session_stream","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionDetachRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Detach Session Stream"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/streams/heartbeat":{"post":{"tags":["Sessions","Sessions"],"summary":"Heartbeat Session Stream","operationId":"heartbeat_session_stream","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionHeartbeatRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionHeartbeatResult"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/streams/header":{"put":{"tags":["Sessions","Sessions"],"summary":"Set Session Stream Header","operationId":"set_session_stream_header","parameters":[{"name":"session_id","in":"query","required":true,"schema":{"type":"string","title":"Session Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionStreamHeaderEdit"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionStreamResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["Sessions","Sessions"],"summary":"Set Session Stream Header","operationId":"set_session_stream_header","parameters":[{"name":"session_id","in":"query","required":true,"schema":{"type":"string","title":"Session Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionStreamHeaderEdit"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionStreamResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/streams/watch":{"get":{"tags":["Sessions","Sessions"],"summary":"Watch Session Stream","description":"Server-sent events relay for one session (M3 live relay).\n\nEmits change notifications only — never record payloads; clients\nrevalidate through the regular query endpoints on each event:\n\n- ``event: records-changed`` — ``{\"session_id\"}``; new/updated rows\n landed in the record log (published post-DB-commit).\n- ``event: lifecycle`` — ``{\"session_id\", \"state\": \"running\"|\"ended\"}``.\n- ``event: interaction`` — ``{\"session_id\", \"status\": \"pending\"|\"resolved\"}``.\n- ``: heartbeat`` comment frames while idle (keep-alive).\n\nAuth is the standard middleware (cookie ``sAccessToken``, ApiKey, or\nBearer) evaluated once at connect; scope is the credential's project.\nBrowsers authenticate by cookie — ``EventSource`` cannot set headers —\nso a connect landing on an expired access token 401s like any other\nrequest. There is no interceptor to refresh-and-retry a stream, so the\nclient must refresh the session itself and reopen (see the web hooks).\n\nThe stream has no replay/cursor semantics — ``EventSource`` reconnects\nand clients revalidate once on every ``open``, which covers any missed\nnotifications.\n\nNOTE (spec surface): this route appears in OpenAPI for documentation,\nbut Fern does not model SSE — consume it with a native ``EventSource``\n(same-origin ``/api`` + cookie auth needs no custom headers), not the\ngenerated client.","operationId":"watch_session_stream","parameters":[{"name":"session_id","in":"query","required":true,"schema":{"type":"string","title":"Session Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/watch":{"get":{"tags":["Sessions","Sessions"],"summary":"Watch Project","description":"Relay low-frequency entity changes for the authorized project.\n\nA caller with only one required view permission cannot open this stream and falls back to\nthe lists' polling behavior.","operationId":"watch_project","parameters":[{"name":"project_id","in":"query","required":true,"schema":{"type":"string","format":"uuid","title":"Project Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/catalog/types/":{"get":{"tags":["Applications"],"summary":"List Application Catalog Types","description":"List shared catalog types.\n\nCatalog types are reusable JSON-Schema building blocks referenced from\ntemplate schemas (for example `message`, `prompt-template`). Types are\nread-only and version with the product.\nSee the [Applications guide](/reference/api-guide/applications#catalog).","operationId":"list_application_catalog_types","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationCatalogTypesResponse"}}}}}}},"/applications/catalog/templates/":{"get":{"tags":["Applications"],"summary":"List Application Catalog Templates","description":"List application templates available in the catalog.\n\nTemplates describe the handler (`uri`) and JSON schemas used to create\na new application. Pass `include_archived=true` to include retired\ntemplates (useful when editing applications created from an old\ntemplate). Templates are global and read-only.","operationId":"list_application_catalog_templates","parameters":[{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationCatalogTemplatesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/catalog/templates/{template_key}":{"get":{"tags":["Applications"],"summary":"Fetch Application Catalog Template","description":"Fetch one application template by key.\n\nUse this to inspect the exact `uri`, `data`, and JSON Schemas for a\ntemplate before creating an application from it. `template_key` comes\nfrom the `key` field of a template returned by the list endpoint\n(for example `completion`, `chat`, `hook`).","operationId":"fetch_application_catalog_template","parameters":[{"name":"template_key","in":"path","required":true,"schema":{"type":"string","title":"Template Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationCatalogTemplateResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/catalog/templates/{template_key}/presets/":{"get":{"tags":["Applications"],"summary":"List Application Catalog Presets","description":"List presets scoped to a template.\n\nPresets are named parameter sets (for example a curated prompt +\nmodel combination) that scaffold the first revision when creating an\napplication from a template. Pass `include_archived=true` to include\nretired presets.","operationId":"list_application_catalog_presets","parameters":[{"name":"template_key","in":"path","required":true,"schema":{"type":"string","title":"Template Key"}},{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationCatalogPresetsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/catalog/templates/{template_key}/presets/{preset_key}":{"get":{"tags":["Applications"],"summary":"Fetch Application Catalog Preset","description":"Fetch one preset by key within a template.\n\nReturns the preset's `data` so clients can use it as the payload for a\nfirst revision when creating an application from a template.","operationId":"fetch_application_catalog_preset","parameters":[{"name":"template_key","in":"path","required":true,"schema":{"type":"string","title":"Template Key"}},{"name":"preset_key","in":"path","required":true,"schema":{"type":"string","title":"Preset Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationCatalogPresetResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/":{"post":{"tags":["Applications"],"summary":"Create Application","description":"Create an application artifact only.\n\nReturns an empty application without any variants or revisions.\nMost callers should use `POST /simple/applications/` instead — it\ncreates the artifact, a default variant, and a first committed\nrevision in one request.\nSee the [Applications guide](/reference/api-guide/applications).","operationId":"create_application","parameters":[{"name":"application_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/{application_id}":{"get":{"tags":["Applications"],"summary":"Fetch Application","description":"Fetch one application artifact by ID.\n\nReturns artifact-level fields only. To get the current variant,\nrevision, and `data` in a single call, use\n`GET /simple/applications/{application_id}`.","operationId":"fetch_application","parameters":[{"name":"application_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Applications"],"summary":"Edit Application","description":"Edit artifact-level fields on an application.\n\nEditable fields: `description`, `flags`, `tags`, `meta`. Editing `name`\nis currently disabled and returns `400`. Prompt or model-parameter\nchanges go through `POST /applications/revisions/commit`, not this\nendpoint.","operationId":"edit_application","parameters":[{"name":"application_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/{application_id}/archive":{"post":{"tags":["Applications"],"summary":"Archive Application","description":"Soft-delete an application.\n\nArchiving sets `deleted_at` on the application and hides it from\nqueries that don't set `include_archived: true`. Its variants and\nrevisions become unreachable from listing but their IDs remain\nresolvable so historical traces stay intact.\nSee [Versioning](/reference/api-guide/versioning#archive-and-unarchive).","operationId":"archive_application","parameters":[{"name":"application_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/{application_id}/unarchive":{"post":{"tags":["Applications"],"summary":"Unarchive Application","description":"Restore a previously archived application.\n\nClears `deleted_at` and makes the application visible to standard\nqueries again. Safe to call on an already-active application; it is a\nno-op in that case.","operationId":"unarchive_application","parameters":[{"name":"application_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/query":{"post":{"tags":["Applications"],"summary":"Query Applications","description":"Query application artifacts.\n\nReturns only artifact-level fields; the variant, revision, and `data`\npayload are not included. For one row per application with those\nmerged in, use `POST /simple/applications/query`.\nSee [Query Pattern](/reference/api-guide/query-pattern).","operationId":"query_applications","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/variants/":{"post":{"tags":["Applications"],"summary":"Create Application Variant","description":"Create a new variant on an existing application.\n\nA variant is an independent branch of the application's history. The\nnew variant starts empty — call `POST /applications/revisions/commit`\nto add its first revision. Use `POST /applications/variants/fork` when\nyou want the new variant to inherit an existing revision history.","operationId":"create_application_variant","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationVariantCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/variants/{application_variant_id}":{"get":{"tags":["Applications"],"summary":"Fetch Application Variant","description":"Fetch one variant by ID.\n\nReturns variant-level fields. To get the variant's tip revision and\nits `data`, call `POST /applications/revisions/retrieve` with\n`application_variant_ref`.","operationId":"fetch_application_variant","parameters":[{"name":"application_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Applications"],"summary":"Edit Application Variant","description":"Edit a variant's header fields (`name`, `description`, `tags`, `meta`).\n\nConfiguration changes go through a new commit via\n`POST /applications/revisions/commit`. This endpoint only touches\nvariant-level metadata.","operationId":"edit_application_variant","parameters":[{"name":"application_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Variant Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationVariantEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/variants/{application_variant_id}/archive":{"post":{"tags":["Applications"],"summary":"Archive Application Variant","description":"Soft-delete a variant.\n\nThe variant and its revisions are hidden from queries unless\n`include_archived: true` is sent. Revision IDs remain resolvable so\nhistorical traces are preserved.","operationId":"archive_application_variant","parameters":[{"name":"application_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/variants/{application_variant_id}/unarchive":{"post":{"tags":["Applications"],"summary":"Unarchive Application Variant","description":"Restore a previously archived variant.","operationId":"unarchive_application_variant","parameters":[{"name":"application_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/variants/query":{"post":{"tags":["Applications"],"summary":"Query Application Variants","description":"Query variants across one or more applications.\n\nFilters are parsed from both query-string parameters and the request\nbody; body values take precedence. Use `application_refs` to scope to\nspecific applications, `application_variant_refs` to narrow to\nspecific variants.\nSee [Query Pattern](/reference/api-guide/query-pattern).","operationId":"query_application_variants","parameters":[{"name":"application_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Id"}},{"name":"application_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Application Ids"}},{"name":"application_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Slug"}},{"name":"application_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Application Slugs"}},{"name":"application_variant_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Variant Id"}},{"name":"application_variant_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Application Variant Ids"}},{"name":"application_variant_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Variant Slug"}},{"name":"application_variant_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Application Variant Slugs"}},{"name":"name","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"}},{"name":"description","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},{"name":"flags","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Flags"}},{"name":"tags","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tags"}},{"name":"meta","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Meta"}},{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"}},{"name":"next","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Next"}},{"name":"newest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Newest"}},{"name":"oldest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Oldest"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"enum":["ascending","descending"],"type":"string"},{"type":"null"}],"title":"Order"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationVariantsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/variants/fork":{"post":{"tags":["Applications"],"summary":"Fork Application Variant","description":"Fork an existing variant into a new variant on the same application.\n\nUse this to experiment without touching the source variant's history.\nThe fork copies the source variant's revisions up to the specified\nrevision (or tip) into the new variant, then commits the supplied\n`revision` object on top. Both `variant` and `revision` sub-objects\nin the request must be present; the server returns `count: 0` when\neither is missing. Returns `400 Bad Request` if the fork target is\ninvalid (for example, the source variant or revision cannot be\nlocated in this application's lineage).","operationId":"fork_application_variant","parameters":[{"name":"application_variant_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Variant Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationVariantForkRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/revisions/retrieve":{"post":{"tags":["Applications"],"summary":"Retrieve Application Revision","description":"Retrieve one application revision by reference.\n\nAccepts application / variant / revision references for direct lookup,\nor an environment reference (with optional `key`) to resolve the\ncurrently-deployed revision in that environment. Returns the revision\nincluding its `data` payload (URL, parameters, schemas), which clients\nuse to invoke the application.\nSet `resolve: true` to inline embedded references inside `data`.","operationId":"retrieve_application_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionRetrieveRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/revisions/deploy":{"post":{"tags":["Applications"],"summary":"Deploy Application Revision","description":"Deploy an application revision to an environment.\n\nWrites a reference from the environment revision to the application\nrevision under `key` (default: `{application_slug}.revision`). Clients\nthat subsequently call `/applications/revisions/retrieve` with the\nsame `environment_ref` and `key` resolve to this revision.\nSee the [Applications guide](/reference/api-guide/applications#deployment).","operationId":"deploy_application_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionDeployRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/revisions/":{"post":{"tags":["Applications"],"summary":"Create Application Revision","description":"Create and commit the initial revision for an application variant.\n\nAdvanced use only. For normal development loops prefer\n`POST /applications/revisions/commit`, which commits the new revision\nas the variant's tip and assigns a version number.","operationId":"create_application_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/revisions/{application_revision_id}":{"get":{"tags":["Applications"],"summary":"Fetch Application Revision","description":"Fetch one revision by its ID.\n\nReturns the revision including its `data` payload. For lookup by\nvariant slug or environment, use `POST /applications/revisions/retrieve`.","operationId":"fetch_application_revision","parameters":[{"name":"application_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Applications"],"summary":"Edit Application Revision","description":"Edit a revision's header fields only.\n\nRevisions are immutable snapshots; `data`, `author`, `date`, and\n`message` cannot be changed. This endpoint updates header fields such\nas `description` and `tags`. To change configuration, commit a new\nrevision with `POST /applications/revisions/commit`.","operationId":"edit_application_revision","parameters":[{"name":"application_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Revision Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/revisions/{application_revision_id}/archive":{"post":{"tags":["Applications"],"summary":"Archive Application Revision","description":"Soft-delete a revision.\n\nArchived revisions are hidden from `/query` and `/log` responses\nunless `include_archived: true` is set. The ID remains resolvable for\ntraces and deployed environment references.","operationId":"archive_application_revision","parameters":[{"name":"application_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/revisions/{application_revision_id}/unarchive":{"post":{"tags":["Applications"],"summary":"Unarchive Application Revision","description":"Restore a previously archived revision.","operationId":"unarchive_application_revision","parameters":[{"name":"application_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/revisions/query":{"post":{"tags":["Applications"],"summary":"Query Application Revisions","description":"Query revisions across one or more applications or variants.\n\nUse `application_refs` / `application_variant_refs` to scope the\nquery, or filter on commit metadata (`author`, `date`, `message`) via\nthe `application_revision` object. For the ordered history of a\nsingle variant, `POST /applications/revisions/log` is more direct.","operationId":"query_application_revisions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/revisions/commit":{"post":{"tags":["Applications"],"summary":"Commit Application Revision","description":"Commit a new revision on a variant.\n\nThe new revision becomes the variant's tip and is assigned the next\n`version` number. Revisions are immutable once committed; to change\nconfiguration, commit a new revision.\nSee [Versioning](/reference/api-guide/versioning#committing-a-revision).","operationId":"commit_application_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionCommitRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/revisions/log":{"post":{"tags":["Applications"],"summary":"Log Application Revisions","description":"Return the ordered revision log for a variant.\n\nPass `application_variant_id` to list the full history of that\nvariant; optionally pass `application_revision_id` + `depth` to walk\nback a bounded number of commits from a specific revision. Entries\nare returned newest-first and include the full revision record.","operationId":"log_application_revisions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionsLogRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/revisions/resolve":{"post":{"tags":["Applications"],"summary":"Resolve Application Revision","description":"Fetch a revision with embedded references inlined.\n\nWhen a revision's `data` carries references to other entities\n(snippets, linked revisions), this endpoint resolves them in place and\nreturns the fully-inlined configuration along with `resolution_info`\ndescribing what was substituted. Use it when clients need a\nself-contained configuration for invocation or export.","operationId":"resolve_application_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionResolveRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionResolveResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/applications/":{"post":{"tags":["Applications"],"summary":"Create Simple Application","description":"Create an application end-to-end.\n\nCreates the application artifact, a default variant, and a first\ncommitted revision whose `data` comes from the request. This is the\nrecommended entry point for \"spin up a new application from a\ntemplate\". For more control over variant and revision creation, use\nthe structured endpoints under `/applications/`.\nSee [Simple Endpoints](/reference/api-guide/simple-endpoints).","operationId":"create_simple_application","parameters":[{"name":"application_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleApplicationCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleApplicationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/applications/query":{"post":{"tags":["Applications"],"summary":"Query Simple Applications","description":"Query applications with variant, revision, and `data` merged per row.\n\nThis is the shape most clients want for dashboards or invocation\npickers: each row carries `variant_id`, `revision_id`, and `data`\n(URL, parameters, schemas) alongside the artifact fields. For the\nstructured query that returns artifacts only, use\n`POST /applications/query`.","operationId":"query_simple_applications","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleApplicationQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleApplicationsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/applications/{application_id}":{"get":{"tags":["Applications"],"summary":"Fetch Simple Application","description":"Fetch one application with its current variant, revision, and `data` merged.\n\nThe returned `data` includes the invocation `url`, the `parameters`\nthe revision was committed with, and the JSON `schemas` for inputs,\noutputs, and parameters.","operationId":"fetch_simple_application","parameters":[{"name":"application_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleApplicationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Applications"],"summary":"Edit Simple Application","description":"Edit an application and commit a new revision if configuration changed.\n\nFields other than `id` in the request body are treated as changes and\nproduce a new committed revision. Supplying `data` changes the\nconfiguration; supplying only header fields (`flags`, `tags`, `meta`)\nstill produces a new revision with the updated header but the\nexisting `data`. Editing the application `name` is currently\ndisabled and returns `400`.","operationId":"edit_simple_application","parameters":[{"name":"application_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleApplicationEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleApplicationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/applications/{application_id}/archive":{"post":{"tags":["Applications"],"summary":"Archive Simple Application","description":"Archive an application through the simple endpoint layer.\n\nEquivalent to `POST /applications/{application_id}/archive`; returns\nthe archived application in the simple shape (with its last known\nvariant, revision, and `data`).","operationId":"archive_simple_application","parameters":[{"name":"application_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleApplicationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/applications/{application_id}/unarchive":{"post":{"tags":["Applications"],"summary":"Unarchive Simple Application","description":"Unarchive an application through the simple endpoint layer.\n\nEquivalent to `POST /applications/{application_id}/unarchive`, with\nthe response shape of `/simple/applications/`.","operationId":"unarchive_simple_application","parameters":[{"name":"application_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleApplicationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/catalog/types/":{"get":{"tags":["Workflows"],"summary":"List Workflow Catalog Types","description":"List the shared JSON Schema fragments available to workflow schemas.\n\nWorkflow input/output schemas reference these via `x-ag-type-ref` (for\nexample, `message` or `prompt`). Use this endpoint to discover what\ntype keys exist before building a schema.\n\nSee: [Workflows](/reference/api-guide/workflows).","operationId":"list_workflow_catalog_types","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowCatalogTypesResponse"}}}}}}},"/workflows/catalog/types/{ag_type}":{"get":{"tags":["Workflows"],"summary":"Fetch Workflow Catalog Type","description":"Return the JSON Schema for a single shared type key.\n\nReturns 404 when the `ag_type` is not part of the shipped catalog.\n\nSee: [Workflows](/reference/api-guide/workflows).","operationId":"fetch_workflow_catalog_type","parameters":[{"name":"ag_type","in":"path","required":true,"schema":{"type":"string","title":"Ag Type"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowCatalogTypeResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/catalog/harnesses/":{"get":{"tags":["Workflows"],"summary":"List Workflow Catalog Harnesses","description":"List the agent harness records shipped with the product.\n\nEach record carries the harness `capabilities` (providers, deployments, connection\nmodes, model selection, models). A workflow's harness field references one via\n`x-ag-harness-ref`, resolved against `/catalog/harnesses/{ag_harness}`.\n\nSee: [Workflows](/reference/api-guide/workflows).","operationId":"list_workflow_catalog_harnesses","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowCatalogHarnessesResponse"}}}}}}},"/workflows/catalog/harnesses/{ag_harness}":{"get":{"tags":["Workflows"],"summary":"Fetch Workflow Catalog Harness","description":"Return a single harness record (with its `capabilities`).\n\nReturns 404 when the `ag_harness` is not part of the shipped catalog.\n\nSee: [Workflows](/reference/api-guide/workflows).","operationId":"fetch_workflow_catalog_harness","parameters":[{"name":"ag_harness","in":"path","required":true,"schema":{"type":"string","title":"Ag Harness"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowCatalogHarnessResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/catalog/templates/":{"get":{"tags":["Workflows"],"summary":"List Workflow Catalog Templates","description":"List workflow blueprints shipped with the product.\n\nFilter by domain with `is_application`, `is_evaluator`, or\n`is_snippet`. Archived templates are hidden unless `include_archived`\nis true. Templates are global and read-only.\n\nSee: [Workflows](/reference/api-guide/workflows).","operationId":"list_workflow_catalog_templates","parameters":[{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"}},{"name":"is_application","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Application"}},{"name":"is_evaluator","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Evaluator"}},{"name":"is_snippet","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Snippet"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowCatalogTemplatesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/catalog/templates/{template_key}":{"get":{"tags":["Workflows"],"summary":"Fetch Workflow Catalog Template","description":"Return a single workflow template by its key.\n\nReturns `count=0` when the template is not found. Templates are global\nmetadata and are not scoped to a project.\n\nSee: [Workflows](/reference/api-guide/workflows).","operationId":"fetch_workflow_catalog_template","parameters":[{"name":"template_key","in":"path","required":true,"schema":{"type":"string","title":"Template Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowCatalogTemplateResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/catalog/templates/{template_key}/presets/":{"get":{"tags":["Workflows"],"summary":"List Workflow Catalog Presets","description":"List presets defined against a template.\n\nPresets are named parameter sets that can be committed as the first\nrevision of a new variant. Returns an empty list when a template has\nno canned presets.\n\nSee: [Workflows](/reference/api-guide/workflows).","operationId":"list_workflow_catalog_presets","parameters":[{"name":"template_key","in":"path","required":true,"schema":{"type":"string","title":"Template Key"}},{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowCatalogPresetsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/catalog/templates/{template_key}/presets/{preset_key}":{"get":{"tags":["Workflows"],"summary":"Fetch Workflow Catalog Preset","description":"Return a single preset for a template by key.\n\nReturns `count=0` when the preset is not defined.\n\nSee: [Workflows](/reference/api-guide/workflows).","operationId":"fetch_workflow_catalog_preset","parameters":[{"name":"template_key","in":"path","required":true,"schema":{"type":"string","title":"Template Key"}},{"name":"preset_key","in":"path","required":true,"schema":{"type":"string","title":"Preset Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowCatalogPresetResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/":{"post":{"tags":["Workflows"],"summary":"Create Workflow","description":"Create a workflow artifact.\n\nCreates the top-level container only; commit a revision on a variant\nbefore the workflow can be retrieved or invoked. Use when you need the\nlower-level primitive — pick `/applications/` for serving logic or\n`/evaluators/` for scoring logic.\n\nSee: [Workflows](/reference/api-guide/workflows),\n[Versioning](/reference/api-guide/versioning).","operationId":"create_workflow","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/{workflow_id}":{"get":{"tags":["Workflows"],"summary":"Fetch Workflow","description":"Fetch a workflow artifact by ID.\n\nReturns the artifact only — variants and revisions are not included.\nUse `/workflows/variants/query` and `/workflows/revisions/query` for\nthe child entities.\n\nSee: [Workflows](/reference/api-guide/workflows).","operationId":"fetch_workflow","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Workflows"],"summary":"Edit Workflow","description":"Update artifact-level fields on a workflow.\n\nThe `id` in the body must match the path parameter. Only supplied\nfields are modified. Configuration (parameters, URL, schemas) lives on\nrevisions — commit a new revision to change those.\n\nSee: [Workflows](/reference/api-guide/workflows),\n[Versioning](/reference/api-guide/versioning).","operationId":"edit_workflow","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/{workflow_id}/archive":{"post":{"tags":["Workflows"],"summary":"Archive Workflow","description":"Archive a workflow artifact (soft delete).\n\nSets `deleted_at` on the workflow and its variants. Archived\nworkflows are hidden from queries unless `include_archived=true`.\nRevision IDs remain resolvable so historical traces stay intact.\n\nSee: [Versioning](/reference/api-guide/versioning).","operationId":"archive_workflow","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/{workflow_id}/unarchive":{"post":{"tags":["Workflows"],"summary":"Unarchive Workflow","description":"Restore a previously archived workflow.\n\nClears `deleted_at` on the workflow. Archived variants and revisions\nare restored with the workflow.\n\nSee: [Versioning](/reference/api-guide/versioning).","operationId":"unarchive_workflow","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/query":{"post":{"tags":["Workflows"],"summary":"Query Workflows","description":"Query workflow artifacts with filters and pagination.\n\nAccepts the same filters as query parameters or in the request body;\nbody fields win when both are supplied. Results are ordered by\ncreation time; pass `windowing.next` back for the following page.\n\nSee: [Query Pattern](/reference/api-guide/query-pattern).","operationId":"query_workflows","parameters":[{"name":"workflow_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"}},{"name":"workflow_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Workflow Ids"}},{"name":"workflow_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Slug"}},{"name":"workflow_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Workflow Slugs"}},{"name":"name","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"}},{"name":"description","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},{"name":"flags","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Flags"}},{"name":"tags","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tags"}},{"name":"meta","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Meta"}},{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"}},{"name":"next","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Next"}},{"name":"newest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Newest"}},{"name":"oldest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Oldest"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"enum":["ascending","descending"],"type":"string"},{"type":"null"}],"title":"Order"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/variants/":{"post":{"tags":["Workflows"],"summary":"Create Workflow Variant","description":"Create a new variant under an existing workflow.\n\nVariants are branches of an artifact's history; each maintains its own\nrevision log. Variant slugs are unique within the project — reuse of a\nslug already in use returns a 409 conflict.\n\nSee: [Versioning](/reference/api-guide/versioning).","operationId":"create_workflow_variant","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowVariantCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/variants/{workflow_variant_id}":{"get":{"tags":["Workflows"],"summary":"Fetch Workflow Variant","description":"Fetch a workflow variant by ID.\n\nReturns the variant metadata only — use\n`/workflows/revisions/retrieve` or `/workflows/revisions/log` for the\nvariant's revisions.\n\nSee: [Workflows](/reference/api-guide/workflows).","operationId":"fetch_workflow_variant","parameters":[{"name":"workflow_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Workflows"],"summary":"Edit Workflow Variant","description":"Update metadata on a workflow variant.\n\nThe `id` in the body must match the path parameter. Revisions on the\nvariant are not affected — commit a new revision to change data.\n\nSee: [Versioning](/reference/api-guide/versioning).","operationId":"edit_workflow_variant","parameters":[{"name":"workflow_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Variant Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowVariantEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/variants/{workflow_variant_id}/archive":{"post":{"tags":["Workflows"],"summary":"Archive Workflow Variant","description":"Archive a workflow variant.\n\nSoft-deletes the variant and its revisions. Archived variants are\nhidden from queries unless `include_archived=true`. See\n[Versioning](/reference/api-guide/versioning).","operationId":"archive_workflow_variant","parameters":[{"name":"workflow_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/variants/{workflow_variant_id}/unarchive":{"post":{"tags":["Workflows"],"summary":"Unarchive Workflow Variant","description":"Restore a previously archived workflow variant.\n\nClears `deleted_at` on the variant and its revisions. See\n[Versioning](/reference/api-guide/versioning).","operationId":"unarchive_workflow_variant","parameters":[{"name":"workflow_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/variants/query":{"post":{"tags":["Workflows"],"summary":"Query Workflow Variants","description":"Query workflow variants with filters and pagination.\n\nScope the query by `workflow_refs` (parent artifact) or\n`workflow_variant_refs` (specific variants). Accepts the same fields\nas query parameters or in the request body.\n\nSee: [Query Pattern](/reference/api-guide/query-pattern).","operationId":"query_workflow_variants","parameters":[{"name":"workflow_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"}},{"name":"workflow_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Workflow Ids"}},{"name":"workflow_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Slug"}},{"name":"workflow_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Workflow Slugs"}},{"name":"workflow_variant_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"}},{"name":"workflow_variant_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Workflow Variant Ids"}},{"name":"workflow_variant_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Variant Slug"}},{"name":"workflow_variant_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Workflow Variant Slugs"}},{"name":"name","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"}},{"name":"description","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},{"name":"flags","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Flags"}},{"name":"tags","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tags"}},{"name":"meta","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Meta"}},{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"}},{"name":"next","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Next"}},{"name":"newest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Newest"}},{"name":"oldest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Oldest"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"enum":["ascending","descending"],"type":"string"},{"type":"null"}],"title":"Order"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowVariantsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/variants/fork":{"post":{"tags":["Workflows"],"summary":"Fork Workflow Variant","operationId":"fork_workflow_variant","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowVariantForkRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/revisions/retrieve":{"post":{"tags":["Workflows"],"summary":"Retrieve Workflow Revision","operationId":"retrieve_workflow_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionRetrieveRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/revisions/deploy":{"post":{"tags":["Workflows"],"summary":"Deploy Workflow Revision","operationId":"deploy_workflow_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionDeployRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/revisions/":{"post":{"tags":["Workflows"],"summary":"Create Workflow Revision","operationId":"create_workflow_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/revisions/{workflow_revision_id}":{"get":{"tags":["Workflows"],"summary":"Fetch Workflow Revision","operationId":"fetch_workflow_revision","parameters":[{"name":"workflow_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Workflows"],"summary":"Edit Workflow Revision","operationId":"edit_workflow_revision","parameters":[{"name":"workflow_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Revision Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/revisions/{workflow_revision_id}/archive":{"post":{"tags":["Workflows"],"summary":"Archive Workflow Revision","operationId":"archive_workflow_revision","parameters":[{"name":"workflow_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/revisions/{workflow_revision_id}/unarchive":{"post":{"tags":["Workflows"],"summary":"Unarchive Workflow Revision","operationId":"unarchive_workflow_revision","parameters":[{"name":"workflow_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/revisions/query":{"post":{"tags":["Workflows"],"summary":"Query Workflow Revisions","operationId":"query_workflow_revisions","parameters":[{"name":"workflow_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"}},{"name":"workflow_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Workflow Ids"}},{"name":"workflow_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Slug"}},{"name":"workflow_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Workflow Slugs"}},{"name":"workflow_variant_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"}},{"name":"workflow_variant_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Workflow Variant Ids"}},{"name":"workflow_variant_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Variant Slug"}},{"name":"workflow_variant_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Workflow Variant Slugs"}},{"name":"workflow_revision_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Revision Id"}},{"name":"workflow_revision_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Workflow Revision Ids"}},{"name":"workflow_revision_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Revision Slug"}},{"name":"workflow_revision_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Workflow Revision Slugs"}},{"name":"workflow_revision_version","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Revision Version"}},{"name":"workflow_revision_versions","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Workflow Revision Versions"}},{"name":"name","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"}},{"name":"description","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},{"name":"flags","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Flags"}},{"name":"tags","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tags"}},{"name":"meta","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Meta"}},{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"}},{"name":"next","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Next"}},{"name":"newest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Newest"}},{"name":"oldest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Oldest"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"enum":["ascending","descending"],"type":"string"},{"type":"null"}],"title":"Order"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/revisions/commit":{"post":{"tags":["Workflows"],"summary":"Commit Workflow Revision","description":"The human and SDK route: no write scope, the caller owns the whole revision.","operationId":"commit_workflow_revision","parameters":[{"name":"workflow_variant_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionCommitRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/revisions/log":{"post":{"tags":["Workflows"],"summary":"Log Workflow Revisions","operationId":"log_workflow_revisions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionsLogRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/revisions/resolve":{"post":{"tags":["Workflows"],"summary":"Resolve Workflow Revision Endpoint","description":"Resolve embedded references in a workflow revision configuration.\n\nThis endpoint:\n1. Fetches the workflow revision\n2. Resolves all @ag.references tokens in the configuration\n3. Returns the revision with resolved configuration + metadata","operationId":"resolve_workflow_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionResolveRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionResolveResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/workflows/":{"post":{"tags":["Workflows"],"summary":"Create Simple Workflow","operationId":"create_simple_workflow","parameters":[{"name":"workflow_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleWorkflowCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleWorkflowResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/workflows/{workflow_id}":{"get":{"tags":["Workflows"],"summary":"Fetch Simple Workflow","operationId":"fetch_simple_workflow","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleWorkflowResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Workflows"],"summary":"Edit Simple Workflow","operationId":"edit_simple_workflow","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleWorkflowEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleWorkflowResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/workflows/{workflow_id}/archive":{"post":{"tags":["Workflows"],"summary":"Archive Simple Workflow","operationId":"archive_simple_workflow","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleWorkflowResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/workflows/{workflow_id}/unarchive":{"post":{"tags":["Workflows"],"summary":"Unarchive Simple Workflow","operationId":"unarchive_simple_workflow","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleWorkflowResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/workflows/query":{"post":{"tags":["Workflows"],"summary":"Query Simple Workflows","operationId":"query_simple_workflows","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleWorkflowQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleWorkflowsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/catalog/types/":{"get":{"tags":["Evaluators"],"summary":"List Evaluator Catalog Types","description":"List the JSON schema types the evaluator catalog understands.\n\nTypes are static metadata shipped with the product. Use this when\nrendering a catalog UI or validating that a template's schema is\nsupported. See the Evaluators guide for how the catalog relates\nto user-owned evaluator artifacts.","operationId":"list_evaluator_catalog_types","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorCatalogTypesResponse"}}}}}}},"/evaluators/catalog/templates/":{"get":{"tags":["Evaluators"],"summary":"List Evaluator Catalog Templates","description":"List evaluator templates from the catalog.\n\nTemplates are blueprints that describe an evaluator's handler\nURI, JSON schemas, and default configuration. Pass\n`include_archived=true` to include deprecated templates. Use the\nreturned `key` with `/catalog/templates/{template_key}/presets/`\nto list its presets.","operationId":"list_evaluator_catalog_templates","parameters":[{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorCatalogTemplatesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/catalog/templates/{template_key}":{"get":{"tags":["Evaluators"],"summary":"Fetch Evaluator Catalog Template","description":"Fetch one evaluator template by key.\n\nReturns an empty envelope (`count: 0`) when no template matches\nthe key. Template keys come from\n`GET /catalog/templates/`.","operationId":"fetch_evaluator_catalog_template","parameters":[{"name":"template_key","in":"path","required":true,"schema":{"type":"string","title":"Template Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorCatalogTemplateResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/catalog/templates/{template_key}/presets/":{"get":{"tags":["Evaluators"],"summary":"List Evaluator Catalog Presets","description":"List presets defined against one evaluator template.\n\nA preset is a named set of parameter values pre-filled against\nthe template. Use the returned `key` to fetch a specific preset\nvia `GET /catalog/templates/{template_key}/presets/{preset_key}`.","operationId":"list_evaluator_catalog_presets","parameters":[{"name":"template_key","in":"path","required":true,"schema":{"type":"string","title":"Template Key"}},{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorCatalogPresetsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/catalog/templates/{template_key}/presets/{preset_key}":{"get":{"tags":["Evaluators"],"summary":"Fetch Evaluator Catalog Preset","description":"Fetch one evaluator preset by template and preset key.\n\nPresets are not separate entities; they are metadata. Use the\nreturned preset payload as the starting point when creating a\nnew evaluator from a template. See the Evaluators guide.","operationId":"fetch_evaluator_catalog_preset","parameters":[{"name":"template_key","in":"path","required":true,"schema":{"type":"string","title":"Template Key"}},{"name":"preset_key","in":"path","required":true,"schema":{"type":"string","title":"Preset Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorCatalogPresetResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/":{"post":{"tags":["Evaluators"],"summary":"Create Evaluator","description":"Create an evaluator artifact, its first variant, and its initial revision.\n\nUse this endpoint when you already know you want to manage the\nartifact / variant / revision layers independently. For a\none-shot \"create and forget\" call that returns a flat record,\nsee `POST /simple/evaluators/`. See the Versioning guide for\ncommit semantics.","operationId":"create_evaluator","parameters":[{"name":"evaluator_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/{evaluator_id}":{"get":{"tags":["Evaluators"],"summary":"Fetch Evaluator","description":"Fetch an evaluator artifact by id.\n\nReturns the artifact-level record (slug, name, flags, lifecycle)\nwithout variant or revision data. Use the variant and revision\nendpoints to retrieve those layers.","operationId":"fetch_evaluator","parameters":[{"name":"evaluator_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Evaluators"],"summary":"Edit Evaluator","description":"Edit an evaluator artifact's metadata.\n\nEdits are limited to metadata fields (description, tags, meta).\nRenaming is temporarily disabled and returns 400. To change\nevaluator behavior, commit a new revision on the variant — see\n`/evaluators/revisions/commit`.","operationId":"edit_evaluator","parameters":[{"name":"evaluator_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/{evaluator_id}/archive":{"post":{"tags":["Evaluators"],"summary":"Archive Evaluator","description":"Soft-delete an evaluator artifact.\n\nSets `deleted_at` on the evaluator and hides it from subsequent\n`/query` responses unless `include_archived=true`. Revision IDs\nremain resolvable so historical traces stay intact.","operationId":"archive_evaluator","parameters":[{"name":"evaluator_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/{evaluator_id}/unarchive":{"post":{"tags":["Evaluators"],"summary":"Unarchive Evaluator","description":"Restore a soft-deleted evaluator artifact.\n\nClears `deleted_at` on the evaluator so it re-appears in `/query`\nresponses.","operationId":"unarchive_evaluator","parameters":[{"name":"evaluator_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/query":{"post":{"tags":["Evaluators"],"summary":"Query Evaluators","description":"Query evaluator artifacts with filters and pagination.\n\nReturns artifact-level records only. The request body follows\nthe shared query pattern (filter + refs + windowing). Send `{}`\nto list all evaluators in the project. See the Query Pattern\nguide.","operationId":"query_evaluators","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/variants/":{"post":{"tags":["Evaluators"],"summary":"Create Evaluator Variant","description":"Create a new variant on an existing evaluator.\n\nA variant is a named branch of the evaluator's history. New\nrevisions committed to this variant do not touch other variants.\nSee the Versioning guide.","operationId":"create_evaluator_variant","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorVariantCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/variants/{evaluator_variant_id}":{"get":{"tags":["Evaluators"],"summary":"Fetch Evaluator Variant","description":"Fetch an evaluator variant by id.\n\nReturns the variant record (slug, flags, lifecycle) without the\ncommitted revisions. Use `/evaluators/revisions/retrieve` to\nread the variant's current revision payload.","operationId":"fetch_evaluator_variant","parameters":[{"name":"evaluator_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Evaluators"],"summary":"Edit Evaluator Variant","description":"Edit a variant's metadata.\n\nEdits only touch variant-level metadata. To change evaluator\nbehavior commit a new revision via\n`/evaluators/revisions/commit`.","operationId":"edit_evaluator_variant","parameters":[{"name":"evaluator_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Variant Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorVariantEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/variants/{evaluator_variant_id}/archive":{"post":{"tags":["Evaluators"],"summary":"Archive Evaluator Variant","description":"Soft-delete an evaluator variant.\n\nSets `deleted_at` on the variant. Its revisions stay resolvable\nby id; they are hidden from `/query` unless the caller sets\n`include_archived=true`.","operationId":"archive_evaluator_variant","parameters":[{"name":"evaluator_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/variants/{evaluator_variant_id}/unarchive":{"post":{"tags":["Evaluators"],"summary":"Unarchive Evaluator Variant","description":"Restore a soft-deleted evaluator variant.","operationId":"unarchive_evaluator_variant","parameters":[{"name":"evaluator_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/variants/query":{"post":{"tags":["Evaluators"],"summary":"Query Evaluator Variants","description":"Query evaluator variants with filters, reference scoping, and pagination.\n\nAccepts parameters from both the query string and a JSON body;\nthe two are merged. Use `evaluator_refs` to scope to one or more\nevaluators, or `evaluator_variant_refs` for specific variants.","operationId":"query_evaluator_variants","parameters":[{"name":"evaluator_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Id"}},{"name":"evaluator_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Evaluator Ids"}},{"name":"evaluator_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Evaluator Slug"}},{"name":"evaluator_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Evaluator Slugs"}},{"name":"evaluator_variant_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Variant Id"}},{"name":"evaluator_variant_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Evaluator Variant Ids"}},{"name":"evaluator_variant_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Evaluator Variant Slug"}},{"name":"evaluator_variant_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Evaluator Variant Slugs"}},{"name":"name","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"}},{"name":"description","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},{"name":"flags","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Flags"}},{"name":"tags","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tags"}},{"name":"meta","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Meta"}},{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"}},{"name":"next","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Next"}},{"name":"newest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Newest"}},{"name":"oldest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Oldest"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"enum":["ascending","descending"],"type":"string"},{"type":"null"}],"title":"Order"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorVariantsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/variants/fork":{"post":{"tags":["Evaluators"],"summary":"Fork Evaluator Variant","description":"Fork an evaluator variant into a new variant.\n\nCreates a new branch whose initial revision is copied from the\nsource. Use this to experiment without touching the original.\nThe returned variant has a fresh id and slug but inherits\nlineage metadata from its source.","operationId":"fork_evaluator_variant","parameters":[{"name":"evaluator_variant_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Variant Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorVariantForkRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/revisions/retrieve":{"post":{"tags":["Evaluators"],"summary":"Retrieve Evaluator Revision","description":"Retrieve one evaluator revision, either directly or via an environment key.\n\nProvide one of:\nan evaluator / variant / revision reference (returns that\nrevision, or the latest revision on the variant or evaluator),\nor an environment reference plus `key` (returns the revision\ncurrently pinned to that key). Supplying both forms returns 400.\nPass `resolve=true` to expand embedded references on the\nreturned payload.","operationId":"retrieve_evaluator_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionRetrieveRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/revisions/deploy":{"post":{"tags":["Evaluators"],"summary":"Deploy Evaluator Revision","description":"Pin an evaluator revision into an environment revision under a key.\n\nRequires an evaluator ref (`evaluator_ref`,\n`evaluator_variant_ref`, or `evaluator_revision_ref`) and an\nenvironment ref. When `key` is omitted it defaults to\n`.revision`. The deployment is recorded as a\nnew commit on the environment revision. See the Evaluators\nguide for the deployment model.","operationId":"deploy_evaluator_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionDeployRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/revisions/":{"post":{"tags":["Evaluators"],"summary":"Create Evaluator Revision","description":"Create and commit the initial revision for an evaluator variant.\n\nPrefer `/evaluators/revisions/commit` for the standard commit\nflow. This endpoint commits an initial revision with the `initial`\nguard, preventing duplicate initial revisions for the same variant.","operationId":"create_evaluator_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/revisions/{evaluator_revision_id}":{"get":{"tags":["Evaluators"],"summary":"Fetch Evaluator Revision","description":"Fetch a specific evaluator revision by id.\n\nReturns the full revision including `data` (handler uri,\nschemas, and parameters). To pick the latest revision on a\nvariant without knowing its id, use\n`/evaluators/revisions/retrieve`.","operationId":"fetch_evaluator_revision","parameters":[{"name":"evaluator_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Evaluators"],"summary":"Edit Evaluator Revision","description":"Edit a revision's metadata.\n\nRevision `data` is immutable once committed. This endpoint is\nfor metadata fields only (description, tags, meta). To change\nevaluator behavior, commit a new revision instead.","operationId":"edit_evaluator_revision","parameters":[{"name":"evaluator_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Revision Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/revisions/{evaluator_revision_id}/archive":{"post":{"tags":["Evaluators"],"summary":"Archive Evaluator Revision","description":"Soft-delete an evaluator revision.\n\nArchived revisions remain resolvable by id but are excluded from\nrevision logs and queries unless `include_archived=true`.","operationId":"archive_evaluator_revision","parameters":[{"name":"evaluator_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/revisions/{evaluator_revision_id}/unarchive":{"post":{"tags":["Evaluators"],"summary":"Unarchive Evaluator Revision","description":"Restore a soft-deleted evaluator revision.","operationId":"unarchive_evaluator_revision","parameters":[{"name":"evaluator_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/revisions/query":{"post":{"tags":["Evaluators"],"summary":"Query Evaluator Revisions","description":"Query evaluator revisions with filters, reference scoping, and pagination.\n\nReturns revision payloads. Use `evaluator_refs`,\n`evaluator_variant_refs`, or `evaluator_revision_refs` to scope\nthe query.","operationId":"query_evaluator_revisions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/revisions/commit":{"post":{"tags":["Evaluators"],"summary":"Commit Evaluator Revision","description":"Commit a new revision on an evaluator variant.\n\nThe commit body carries the target `evaluator_variant_id`, an\noptional `message`, and the revision `data` (handler uri,\nschemas, parameters). A committed revision is immutable.","operationId":"commit_evaluator_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionCommitRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/revisions/log":{"post":{"tags":["Evaluators"],"summary":"Log Evaluator Revisions","description":"List the revision log of an evaluator variant.\n\nReturns revisions in commit order. Scope the log by supplying\nan evaluator, variant, or revision reference. Use the retrieve\nendpoint to fetch a specific revision's full payload.","operationId":"log_evaluator_revisions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionsLogRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/revisions/resolve":{"post":{"tags":["Evaluators"],"summary":"Resolve Evaluator Revision","description":"Resolve embedded references on an evaluator revision's `data`.\n\nWalks embedded references (for example, references to other\nrevisions or to secrets) up to `max_depth` and `max_embeds`.\nThe response includes a `resolution_info` block with counts,\ndepth reached, and errors according to `error_policy`.","operationId":"resolve_evaluator_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionResolveRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionResolveResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluators/":{"post":{"tags":["Evaluators"],"summary":"Create Simple Evaluator","description":"Create an evaluator via the simple surface.\n\nCreates the artifact, its first variant, and its initial\nrevision in one call. Returns the flat evaluator record\n(latest revision merged into `data`). Use this when you do not\nneed to manage variants or revisions directly.","operationId":"create_simple_evaluator","parameters":[{"name":"evaluator_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluatorCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluatorResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluators/templates":{"get":{"tags":["Evaluators"],"summary":"List Evaluator Templates","description":"List the legacy built-in evaluator templates.\n\nReturns static evaluator-type definitions shipped with the\nproduct. Prefer the `/evaluators/catalog/*` endpoints for new\nintegrations; this endpoint is kept for older clients. Pass\n`include_archived=true` to include deprecated templates.","operationId":"list_evaluator_templates","parameters":[{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"default":false,"title":"Include Archived"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorTemplatesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluators/{evaluator_id}":{"get":{"tags":["Evaluators"],"summary":"Fetch Simple Evaluator","description":"Fetch one evaluator via the simple surface.\n\nReturns the flat evaluator record including its current variant\nand revision ids and the merged `data` payload.","operationId":"fetch_simple_evaluator","parameters":[{"name":"evaluator_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluatorResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Evaluators"],"summary":"Edit Simple Evaluator","description":"Edit an evaluator via the simple surface.\n\nTouches metadata and (when `data` is supplied) commits a new\nrevision on the evaluator's variant. Renaming is temporarily\ndisabled and returns 400.","operationId":"edit_simple_evaluator","parameters":[{"name":"evaluator_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluatorEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluatorResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluators/{evaluator_id}/archive":{"post":{"tags":["Evaluators"],"summary":"Archive Simple Evaluator","description":"Soft-delete an evaluator via the simple surface.\n\nArchives the underlying artifact. Historical traces that\nreference specific revision ids remain resolvable.","operationId":"archive_simple_evaluator","parameters":[{"name":"evaluator_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluatorResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluators/{evaluator_id}/unarchive":{"post":{"tags":["Evaluators"],"summary":"Unarchive Simple Evaluator","description":"Restore a soft-deleted evaluator via the simple surface.","operationId":"unarchive_simple_evaluator","parameters":[{"name":"evaluator_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluatorResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluators/query":{"post":{"tags":["Evaluators"],"summary":"Query Simple Evaluators","description":"Query evaluators via the simple surface with filters and pagination.\n\nReturns flat evaluator records (one per artifact with its\nlatest variant and revision merged into `data`). Send `{}` to\nlist all evaluators in the project. See the Query Pattern\nguide.","operationId":"query_simple_evaluators","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluatorQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluatorsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/":{"post":{"tags":["Environments"],"summary":"Create Environment","operationId":"create_environment","parameters":[{"name":"environment_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/{environment_id}":{"get":{"tags":["Environments"],"summary":"Fetch Environment","operationId":"fetch_environment","parameters":[{"name":"environment_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Environments"],"summary":"Edit Environment","operationId":"edit_environment","parameters":[{"name":"environment_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/{environment_id}/archive":{"post":{"tags":["Environments"],"summary":"Archive Environment","operationId":"archive_environment","parameters":[{"name":"environment_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/{environment_id}/unarchive":{"post":{"tags":["Environments"],"summary":"Unarchive Environment","operationId":"unarchive_environment","parameters":[{"name":"environment_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/query":{"post":{"tags":["Environments"],"summary":"Query Environments","operationId":"query_environments","parameters":[{"name":"environment_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Id"}},{"name":"environment_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Environment Ids"}},{"name":"environment_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Environment Slug"}},{"name":"environment_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Environment Slugs"}},{"name":"name","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"}},{"name":"description","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},{"name":"tags","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tags"}},{"name":"meta","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Meta"}},{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"}},{"name":"next","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Next"}},{"name":"newest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Newest"}},{"name":"oldest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Oldest"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"enum":["ascending","descending"],"type":"string"},{"type":"null"}],"title":"Order"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/variants/":{"post":{"tags":["Environments"],"summary":"Create Environment Variant","operationId":"create_environment_variant","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentVariantCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/variants/{environment_variant_id}":{"get":{"tags":["Environments"],"summary":"Fetch Environment Variant","operationId":"fetch_environment_variant","parameters":[{"name":"environment_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Environments"],"summary":"Edit Environment Variant","operationId":"edit_environment_variant","parameters":[{"name":"environment_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Variant Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentVariantEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/variants/{environment_variant_id}/archive":{"post":{"tags":["Environments"],"summary":"Archive Environment Variant","operationId":"archive_environment_variant","parameters":[{"name":"environment_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/variants/{environment_variant_id}/unarchive":{"post":{"tags":["Environments"],"summary":"Unarchive Environment Variant","operationId":"unarchive_environment_variant","parameters":[{"name":"environment_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/variants/query":{"post":{"tags":["Environments"],"summary":"Query Environment Variants","operationId":"query_environment_variants","parameters":[{"name":"environment_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Id"}},{"name":"environment_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Environment Ids"}},{"name":"environment_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Environment Slug"}},{"name":"environment_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Environment Slugs"}},{"name":"environment_variant_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Variant Id"}},{"name":"environment_variant_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Environment Variant Ids"}},{"name":"environment_variant_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Environment Variant Slug"}},{"name":"environment_variant_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Environment Variant Slugs"}},{"name":"name","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"}},{"name":"description","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},{"name":"tags","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tags"}},{"name":"meta","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Meta"}},{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"}},{"name":"next","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Next"}},{"name":"newest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Newest"}},{"name":"oldest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Oldest"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"enum":["ascending","descending"],"type":"string"},{"type":"null"}],"title":"Order"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentVariantsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/variants/fork":{"post":{"tags":["Environments"],"summary":"Fork Environment Variant","description":"Fork an existing environment variant into a new variant.\n\nThe new variant starts from the source variant's head revision (or a\npinned revision if `environment_revision_ref` is provided). Provide\n`slug` and `name` in the fork body to identify the new variant.","operationId":"fork_environment_variant","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentVariantForkRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/revisions/retrieve":{"post":{"tags":["Environments"],"summary":"Retrieve Environment Revision","operationId":"retrieve_environment_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionRetrieveRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/revisions/":{"post":{"tags":["Environments"],"summary":"Create Environment Revision","operationId":"create_environment_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/revisions/{environment_revision_id}":{"get":{"tags":["Environments"],"summary":"Fetch Environment Revision","operationId":"fetch_environment_revision","parameters":[{"name":"environment_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Environments"],"summary":"Edit Environment Revision","operationId":"edit_environment_revision","parameters":[{"name":"environment_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Revision Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/revisions/{environment_revision_id}/archive":{"post":{"tags":["Environments"],"summary":"Archive Environment Revision","operationId":"archive_environment_revision","parameters":[{"name":"environment_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/revisions/{environment_revision_id}/unarchive":{"post":{"tags":["Environments"],"summary":"Unarchive Environment Revision","operationId":"unarchive_environment_revision","parameters":[{"name":"environment_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/revisions/query":{"post":{"tags":["Environments"],"summary":"Query Environment Revisions","operationId":"query_environment_revisions","parameters":[{"name":"environment_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Id"}},{"name":"environment_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Environment Ids"}},{"name":"environment_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Environment Slug"}},{"name":"environment_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Environment Slugs"}},{"name":"environment_variant_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Variant Id"}},{"name":"environment_variant_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Environment Variant Ids"}},{"name":"environment_variant_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Environment Variant Slug"}},{"name":"environment_variant_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Environment Variant Slugs"}},{"name":"environment_revision_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Revision Id"}},{"name":"environment_revision_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Environment Revision Ids"}},{"name":"environment_revision_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Environment Revision Slug"}},{"name":"environment_revision_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Environment Revision Slugs"}},{"name":"environment_revision_version","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Environment Revision Version"}},{"name":"environment_revision_versions","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Environment Revision Versions"}},{"name":"name","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"}},{"name":"description","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},{"name":"tags","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tags"}},{"name":"meta","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Meta"}},{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"}},{"name":"next","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Next"}},{"name":"newest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Newest"}},{"name":"oldest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Oldest"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"enum":["ascending","descending"],"type":"string"},{"type":"null"}],"title":"Order"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/revisions/commit":{"post":{"tags":["Environments"],"summary":"Commit Environment Revision","operationId":"commit_environment_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionCommitRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/revisions/log":{"post":{"tags":["Environments"],"summary":"Log Environment Revisions","operationId":"log_environment_revisions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionsLogRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/revisions/resolve":{"post":{"tags":["Environments"],"summary":"Resolve Environment Revision Endpoint","description":"Resolve embedded references in an environment revision configuration.\n\nThis endpoint:\n1. Fetches the environment revision\n2. Resolves all @ag.references tokens in the configuration\n3. Returns the revision with resolved configuration + metadata","operationId":"resolve_environment_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionResolveRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionResolveResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/environments/":{"post":{"tags":["Environments"],"summary":"Create Simple Environment","operationId":"create_simple_environment","parameters":[{"name":"environment_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEnvironmentCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEnvironmentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/environments/{environment_id}":{"get":{"tags":["Environments"],"summary":"Fetch Simple Environment","operationId":"fetch_simple_environment","parameters":[{"name":"environment_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEnvironmentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Environments"],"summary":"Edit Simple Environment","operationId":"edit_simple_environment","parameters":[{"name":"environment_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEnvironmentEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEnvironmentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/environments/{environment_id}/archive":{"post":{"tags":["Environments"],"summary":"Archive Simple Environment","operationId":"archive_simple_environment","parameters":[{"name":"environment_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEnvironmentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/environments/{environment_id}/unarchive":{"post":{"tags":["Environments"],"summary":"Unarchive Simple Environment","operationId":"unarchive_simple_environment","parameters":[{"name":"environment_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEnvironmentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/environments/query":{"post":{"tags":["Environments"],"summary":"Query Simple Environments","operationId":"query_simple_environments","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEnvironmentQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEnvironmentsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/environments/{environment_id}/guard":{"post":{"tags":["Environments"],"summary":"Guard Simple Environment","operationId":"guard_simple_environment","parameters":[{"name":"environment_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEnvironmentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/environments/{environment_id}/unguard":{"post":{"tags":["Environments"],"summary":"Unguard Simple Environment","operationId":"unguard_simple_environment","parameters":[{"name":"environment_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEnvironmentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/variants/configs/fetch":{"post":{"tags":["Deprecated"],"summary":"Configs Fetch","operationId":"configs_fetch_variants_configs_fetch_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Body_configs_fetch_variants_configs_fetch_post"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConfigResponseModel"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"deprecated":true}},"/tools/catalog/providers/":{"get":{"tags":["Tools"],"summary":"List Providers","operationId":"list_tool_providers","parameters":[{"name":"full_details","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Full Details"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolCatalogProvidersResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tools/catalog/providers/{provider_key}":{"get":{"tags":["Tools"],"summary":"Get Provider","operationId":"fetch_tool_provider","parameters":[{"name":"provider_key","in":"path","required":true,"schema":{"type":"string","title":"Provider Key"}},{"name":"full_details","in":"query","required":false,"schema":{"type":"boolean","default":true,"title":"Full Details"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolCatalogProviderResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tools/catalog/providers/{provider_key}/integrations/":{"get":{"tags":["Tools"],"summary":"List Integrations","operationId":"list_tool_integrations","parameters":[{"name":"provider_key","in":"path","required":true,"schema":{"type":"string","title":"Provider Key"}},{"name":"search","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Search"}},{"name":"sort_by","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sort By"}},{"name":"category","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Category"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"name":"full_details","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Full Details"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolCatalogIntegrationsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tools/catalog/providers/{provider_key}/categories/":{"get":{"tags":["Tools"],"summary":"List Categories","operationId":"list_tool_categories","parameters":[{"name":"provider_key","in":"path","required":true,"schema":{"type":"string","title":"Provider Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolCatalogCategoriesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tools/catalog/providers/{provider_key}/integrations/{integration_key}":{"get":{"tags":["Tools"],"summary":"Get Integration","operationId":"fetch_tool_integration","parameters":[{"name":"provider_key","in":"path","required":true,"schema":{"type":"string","title":"Provider Key"}},{"name":"integration_key","in":"path","required":true,"schema":{"type":"string","title":"Integration Key"}},{"name":"full_details","in":"query","required":false,"schema":{"type":"boolean","default":true,"title":"Full Details"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolCatalogIntegrationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tools/catalog/providers/{provider_key}/integrations/{integration_key}/actions/":{"get":{"tags":["Tools"],"summary":"List Actions","operationId":"list_tool_actions","parameters":[{"name":"provider_key","in":"path","required":true,"schema":{"type":"string","title":"Provider Key"}},{"name":"integration_key","in":"path","required":true,"schema":{"type":"string","title":"Integration Key"}},{"name":"query","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Query"}},{"name":"categories","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Categories"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"name":"full_details","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Full Details"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolCatalogActionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tools/catalog/providers/{provider_key}/integrations/{integration_key}/actions/{action_key}":{"get":{"tags":["Tools"],"summary":"Get Action","operationId":"fetch_tool_action","parameters":[{"name":"provider_key","in":"path","required":true,"schema":{"type":"string","title":"Provider Key"}},{"name":"integration_key","in":"path","required":true,"schema":{"type":"string","title":"Integration Key"}},{"name":"action_key","in":"path","required":true,"schema":{"type":"string","title":"Action Key"}},{"name":"full_details","in":"query","required":false,"schema":{"type":"boolean","default":true,"title":"Full Details"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolCatalogActionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tools/connections/query":{"post":{"tags":["Tools"],"summary":"Query Connections","description":"Query connections with optional filtering.","operationId":"query_tool_connections","parameters":[{"name":"provider_key","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider Key"}},{"name":"integration_key","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Integration Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolConnectionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tools/connections/":{"post":{"tags":["Tools"],"summary":"Create Connection","description":"Create a new tool connection.","operationId":"create_tool_connection","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolConnectionCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolConnectionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tools/connections/callback":{"get":{"tags":["Tools"],"summary":"Callback Connection","description":"Handle OAuth callback from Composio.","operationId":"callback_tool_connection","parameters":[{"name":"connected_account_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Connected Account Id"}},{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status"}},{"name":"error_message","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error Message"}},{"name":"state","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"State"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tools/connections/{connection_id}":{"get":{"tags":["Tools"],"summary":"Get Connection","description":"Get a connection by ID.","operationId":"fetch_tool_connection","parameters":[{"name":"connection_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Connection Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolConnectionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Tools"],"summary":"Delete Connection","description":"Delete a connection by ID.","operationId":"delete_tool_connection","parameters":[{"name":"connection_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Connection Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tools/connections/{connection_id}/refresh":{"post":{"tags":["Tools"],"summary":"Refresh Connection","description":"Refresh a connection's credentials.","operationId":"refresh_tool_connection","parameters":[{"name":"connection_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Connection Id"}},{"name":"force","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Force"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolConnectionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tools/connections/{connection_id}/revoke":{"post":{"tags":["Tools"],"summary":"Revoke Connection","description":"Mark a connection invalid locally (does not revoke at the provider).","operationId":"revoke_tool_connection","parameters":[{"name":"connection_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Connection Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolConnectionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tools/resolve":{"post":{"tags":["Tools"],"summary":"Resolve Tools","description":"Resolve an agent's tool references into model-ready specs.\n\nValidates Composio connections up front and enriches each action from the\ncatalog, so a running agent (e.g. Pi) gets ``customTools`` whose ``execute``\nroutes back through ``POST /tools/call`` — provider keys stay server-side.","operationId":"resolve_tools","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolResolveRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolResolveResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tools/discover":{"post":{"tags":["Tools"],"summary":"Discover Capabilities","description":"Discover the tools that fit a set of use cases, translated to Agenta terms.\n\nWraps the provider's semantic search and reports each integration's connection\nstate for the calling project. Read-only; project scope comes from caller auth.\nSee ``docs/design/agent-workflows/projects/tool-discovery/design.md``.","operationId":"discover_tool_capabilities","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CapabilitiesQuery"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CapabilitiesResult"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tools/call":{"post":{"tags":["Tools"],"summary":"Call Tool","description":"Call a tool action with a connection.","operationId":"call_tool","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolCall"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolCallResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/composio/events/":{"post":{"tags":["Triggers"],"summary":"Ingest Composio Event","description":"Receive a Composio provider event; verify, demux, ack-fast, enqueue.\n\nPublic (no Agenta auth) — mirrors the Stripe events receiver. Scope and\nattribution are recovered downstream from the resolved subscription row.","operationId":"ingest_composio_event","responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerEventAck"}}}}}}},"/triggers/catalog/providers/":{"get":{"tags":["Triggers"],"summary":"List Providers","operationId":"list_trigger_providers","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCatalogProvidersResponse"}}}}}}},"/triggers/catalog/providers/{provider_key}":{"get":{"tags":["Triggers"],"summary":"Get Provider","operationId":"fetch_trigger_provider","parameters":[{"name":"provider_key","in":"path","required":true,"schema":{"type":"string","title":"Provider Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCatalogProviderResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/catalog/providers/{provider_key}/integrations/":{"get":{"tags":["Triggers"],"summary":"List Integrations","operationId":"list_trigger_integrations","parameters":[{"name":"provider_key","in":"path","required":true,"schema":{"type":"string","title":"Provider Key"}},{"name":"search","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Search"}},{"name":"sort_by","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sort By"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCatalogIntegrationsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/catalog/providers/{provider_key}/integrations/{integration_key}":{"get":{"tags":["Triggers"],"summary":"Get Integration","operationId":"fetch_trigger_integration","parameters":[{"name":"provider_key","in":"path","required":true,"schema":{"type":"string","title":"Provider Key"}},{"name":"integration_key","in":"path","required":true,"schema":{"type":"string","title":"Integration Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCatalogIntegrationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/catalog/providers/{provider_key}/integrations/{integration_key}/events/":{"get":{"tags":["Triggers"],"summary":"List Events","operationId":"list_trigger_events","parameters":[{"name":"provider_key","in":"path","required":true,"schema":{"type":"string","title":"Provider Key"}},{"name":"integration_key","in":"path","required":true,"schema":{"type":"string","title":"Integration Key"}},{"name":"query","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Query"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCatalogEventsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/catalog/providers/{provider_key}/integrations/{integration_key}/events/{event_key}":{"get":{"tags":["Triggers"],"summary":"Get Event","operationId":"fetch_trigger_event","parameters":[{"name":"provider_key","in":"path","required":true,"schema":{"type":"string","title":"Provider Key"}},{"name":"integration_key","in":"path","required":true,"schema":{"type":"string","title":"Integration Key"}},{"name":"event_key","in":"path","required":true,"schema":{"type":"string","title":"Event Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCatalogEventResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/discover":{"post":{"tags":["Triggers"],"summary":"Discover Triggers","operationId":"discover_triggers","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerDiscoveryQuery"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCapabilitiesResult"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/connections/query":{"post":{"tags":["Triggers"],"summary":"Query Connections","operationId":"query_trigger_connections","parameters":[{"name":"provider_key","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider Key"}},{"name":"integration_key","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Integration Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerConnectionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/connections/":{"post":{"tags":["Triggers"],"summary":"Create Connection","operationId":"create_trigger_connection","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerConnectionCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerConnectionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/connections/{connection_id}":{"get":{"tags":["Triggers"],"summary":"Get Connection","operationId":"fetch_trigger_connection","parameters":[{"name":"connection_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Connection Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerConnectionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Triggers"],"summary":"Delete Connection","operationId":"delete_trigger_connection","parameters":[{"name":"connection_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Connection Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/connections/{connection_id}/refresh":{"post":{"tags":["Triggers"],"summary":"Refresh Connection","operationId":"refresh_trigger_connection","parameters":[{"name":"connection_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Connection Id"}},{"name":"force","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Force"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerConnectionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/connections/{connection_id}/revoke":{"post":{"tags":["Triggers"],"summary":"Revoke Connection","operationId":"revoke_trigger_connection","parameters":[{"name":"connection_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Connection Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerConnectionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/subscriptions/":{"get":{"tags":["Triggers"],"summary":"List Subscriptions","operationId":"list_trigger_subscriptions","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerSubscriptionsResponse"}}}}}},"post":{"tags":["Triggers"],"summary":"Create Subscription","operationId":"create_trigger_subscription","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerSubscriptionCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerSubscriptionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/subscriptions/query":{"post":{"tags":["Triggers"],"summary":"Query Subscriptions","operationId":"query_trigger_subscriptions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerSubscriptionQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerSubscriptionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/subscriptions/test":{"post":{"tags":["Triggers"],"summary":"Test Subscription","operationId":"test_trigger_subscription","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerSubscriptionCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerDeliveryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/subscriptions/{subscription_id}/refresh":{"post":{"tags":["Triggers"],"summary":"Refresh Subscription","operationId":"refresh_trigger_subscription","parameters":[{"name":"subscription_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Subscription Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerSubscriptionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/subscriptions/{subscription_id}/revoke":{"post":{"tags":["Triggers"],"summary":"Revoke Subscription","operationId":"revoke_trigger_subscription","parameters":[{"name":"subscription_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Subscription Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerSubscriptionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/subscriptions/{subscription_id}/start":{"post":{"tags":["Triggers"],"summary":"Start Subscription","operationId":"start_trigger_subscription","parameters":[{"name":"subscription_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Subscription Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerSubscriptionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/subscriptions/{subscription_id}/stop":{"post":{"tags":["Triggers"],"summary":"Stop Subscription","operationId":"stop_trigger_subscription","parameters":[{"name":"subscription_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Subscription Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerSubscriptionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/subscriptions/{subscription_id}":{"get":{"tags":["Triggers"],"summary":"Fetch Subscription","operationId":"fetch_trigger_subscription","parameters":[{"name":"subscription_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Subscription Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerSubscriptionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Triggers"],"summary":"Edit Subscription","operationId":"edit_trigger_subscription","parameters":[{"name":"subscription_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Subscription Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerSubscriptionEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerSubscriptionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Triggers"],"summary":"Delete Subscription","operationId":"delete_trigger_subscription","parameters":[{"name":"subscription_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Subscription Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/schedules/":{"get":{"tags":["Triggers"],"summary":"List Schedules","operationId":"list_trigger_schedules","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerSchedulesResponse"}}}}}},"post":{"tags":["Triggers"],"summary":"Create Schedule","operationId":"create_trigger_schedule","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerScheduleCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerScheduleResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/schedules/query":{"post":{"tags":["Triggers"],"summary":"Query Schedules","operationId":"query_trigger_schedules","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerScheduleQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerSchedulesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/schedules/{schedule_id}":{"get":{"tags":["Triggers"],"summary":"Fetch Schedule","operationId":"fetch_trigger_schedule","parameters":[{"name":"schedule_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Schedule Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerScheduleResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Triggers"],"summary":"Edit Schedule","operationId":"edit_trigger_schedule","parameters":[{"name":"schedule_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Schedule Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerScheduleEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerScheduleResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Triggers"],"summary":"Delete Schedule","operationId":"delete_trigger_schedule","parameters":[{"name":"schedule_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Schedule Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/schedules/{schedule_id}/start":{"post":{"tags":["Triggers"],"summary":"Start Schedule","operationId":"start_trigger_schedule","parameters":[{"name":"schedule_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Schedule Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerScheduleResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/schedules/{schedule_id}/stop":{"post":{"tags":["Triggers"],"summary":"Stop Schedule","operationId":"stop_trigger_schedule","parameters":[{"name":"schedule_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Schedule Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerScheduleResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/deliveries":{"get":{"tags":["Triggers"],"summary":"List Deliveries","operationId":"list_trigger_deliveries","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerDeliveriesResponse"}}}}}}},"/triggers/deliveries/query":{"post":{"tags":["Triggers"],"summary":"Query Deliveries","operationId":"query_trigger_deliveries","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerDeliveryQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerDeliveriesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/deliveries/{delivery_id}":{"get":{"tags":["Triggers"],"summary":"Fetch Delivery","operationId":"fetch_trigger_delivery","parameters":[{"name":"delivery_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Delivery Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerDeliveryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/interactions/":{"post":{"tags":["Sessions"],"summary":"Create Interaction","operationId":"create_interaction","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionInteractionCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionInteractionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/interactions/query":{"post":{"tags":["Sessions"],"summary":"Query Interactions","operationId":"query_interactions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionInteractionQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionInteractionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/interactions/transition":{"post":{"tags":["Sessions"],"summary":"Transition Interaction","operationId":"transition_interaction","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionInteractionTransitionRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionInteractionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/interactions/cancel-stale":{"post":{"tags":["Sessions"],"summary":"Cancel Stale Interactions","operationId":"cancel_stale_interactions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionInteractionCancelStaleRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Cancel Stale Interactions"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/interactions/{interaction_id}":{"get":{"tags":["Sessions"],"summary":"Fetch Interaction","operationId":"fetch_interaction","parameters":[{"name":"interaction_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Interaction Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionInteractionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/interactions/{interaction_id}/respond":{"post":{"tags":["Sessions"],"summary":"Respond Interaction","operationId":"respond_interaction","parameters":[{"name":"interaction_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Interaction Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionInteractionRespondRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionInteractionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/runs/":{"post":{"tags":["Evaluations"],"summary":"Create Runs","operationId":"create_runs","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunsCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Evaluations"],"summary":"Delete Runs","operationId":"delete_runs","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunIdsRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunIdsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Evaluations"],"summary":"Edit Runs","operationId":"edit_runs","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunsEditRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/runs/query":{"post":{"tags":["Evaluations"],"summary":"Query Runs","operationId":"query_runs","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/runs/close":{"post":{"tags":["Evaluations"],"summary":"Close Runs","operationId":"close_runs","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunIdsRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/runs/open":{"post":{"tags":["Evaluations"],"summary":"Open Runs","operationId":"open_runs","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunIdsRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/runs/{run_id}":{"get":{"tags":["Evaluations"],"summary":"Fetch Run","operationId":"fetch_run","parameters":[{"name":"run_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Evaluations"],"summary":"Edit Run","operationId":"edit_run","parameters":[{"name":"run_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Run Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Evaluations"],"summary":"Delete Run","operationId":"delete_run","parameters":[{"name":"run_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunIdResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/runs/{run_id}/close":{"post":{"tags":["Evaluations"],"summary":"Close Run","operationId":"close_run","parameters":[{"name":"run_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Run Id"}},{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}],"title":"Status"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/runs/{run_id}/open":{"post":{"tags":["Evaluations"],"summary":"Open Run","operationId":"open_run","parameters":[{"name":"run_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/runs/{run_id}/queues/default":{"get":{"tags":["Evaluations"],"summary":"Fetch Default Queue","operationId":"fetch_default_queue","parameters":[{"name":"run_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationQueueResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/scenarios/":{"post":{"tags":["Evaluations"],"summary":"Create Scenarios","operationId":"create_scenarios","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationScenariosCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationScenariosResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Evaluations"],"summary":"Delete Scenarios","operationId":"delete_scenarios","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationScenarioIdsRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationScenarioIdsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Evaluations"],"summary":"Edit Scenarios","operationId":"edit_scenarios","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationScenariosEditRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationScenariosResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/scenarios/query":{"post":{"tags":["Evaluations"],"summary":"Query Scenarios","operationId":"query_scenarios","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationScenarioQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationScenariosResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/scenarios/{scenario_id}":{"get":{"tags":["Evaluations"],"summary":"Fetch Scenario","operationId":"fetch_scenario","parameters":[{"name":"scenario_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Scenario Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationScenarioResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Evaluations"],"summary":"Edit Scenario","operationId":"edit_scenario","parameters":[{"name":"scenario_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Scenario Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationScenarioEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationScenarioResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Evaluations"],"summary":"Delete Scenario","operationId":"delete_scenario","parameters":[{"name":"scenario_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Scenario Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationScenarioIdResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/results/":{"put":{"tags":["Evaluations"],"summary":"Set Results","operationId":"set_results","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationResultsSetRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationResultsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["Evaluations"],"summary":"Set Results","operationId":"set_results","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationResultsSetRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationResultsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Evaluations"],"summary":"Delete Results","operationId":"delete_results","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationResultIdsRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationResultIdsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Evaluations"],"summary":"Set Results","operationId":"set_results","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationResultsSetRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationResultsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/results/query":{"post":{"tags":["Evaluations"],"summary":"Query Results","operationId":"query_results","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationResultQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationResultsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/results/{result_id}":{"get":{"tags":["Evaluations"],"summary":"Fetch Result","operationId":"fetch_result","parameters":[{"name":"result_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Result Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationResultResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Evaluations"],"summary":"Delete Result","operationId":"delete_result","parameters":[{"name":"result_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Result Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationResultIdResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/metrics/refresh":{"post":{"tags":["Evaluations"],"summary":"Refresh Metrics","operationId":"refresh_metrics","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationMetricsRefreshRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationMetricsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/metrics/":{"put":{"tags":["Evaluations"],"summary":"Set Metrics","operationId":"set_metrics","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationMetricsSetRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationMetricsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["Evaluations"],"summary":"Set Metrics","operationId":"set_metrics","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationMetricsSetRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationMetricsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Evaluations"],"summary":"Delete Metrics","operationId":"delete_metrics","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationMetricsIdsRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationMetricsIdsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Evaluations"],"summary":"Set Metrics","operationId":"set_metrics","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationMetricsSetRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationMetricsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/metrics/query":{"post":{"tags":["Evaluations"],"summary":"Query Metrics","operationId":"query_metrics","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationMetricsQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationMetricsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/metrics/{metrics_id}":{"get":{"tags":["Evaluations"],"summary":"Fetch Metric","operationId":"fetch_metric","parameters":[{"name":"metrics_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Metrics Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationMetricsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Evaluations"],"summary":"Delete Metric","operationId":"delete_metric","parameters":[{"name":"metrics_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Metrics Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationMetricsIdsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/queues/":{"post":{"tags":["Evaluations"],"summary":"Create Queues","operationId":"create_queues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationQueuesCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationQueuesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Evaluations"],"summary":"Delete Queues","operationId":"delete_queues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationQueueIdsRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationQueueIdsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Evaluations"],"summary":"Edit Queues","operationId":"edit_queues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationQueuesEditRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationQueuesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/queues/query":{"post":{"tags":["Evaluations"],"summary":"Query Queues","operationId":"query_queues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationQueueQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationQueuesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/queues/{queue_id}":{"get":{"tags":["Evaluations"],"summary":"Fetch Queue","operationId":"fetch_queue","parameters":[{"name":"queue_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Queue Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationQueueResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Evaluations"],"summary":"Edit Queue","operationId":"edit_queue","parameters":[{"name":"queue_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Queue Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationQueueEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationQueueResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Evaluations"],"summary":"Delete Queue","operationId":"delete_queue","parameters":[{"name":"queue_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Queue Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationQueueIdResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/queues/{queue_id}/scenarios/query":{"post":{"tags":["Evaluations"],"summary":"Query Queue Scenarios","operationId":"query_evaluation_queue_scenarios","parameters":[{"name":"queue_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Queue Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationQueueScenariosQueryRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationScenariosResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/":{"post":{"tags":["Evaluations"],"summary":"Create Evaluation","operationId":"create_simple_evaluation","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluationCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/query":{"post":{"tags":["Evaluations"],"summary":"Query Evaluations","operationId":"query_simple_evaluations","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluationQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluationsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/{evaluation_id}":{"get":{"tags":["Evaluations"],"summary":"Fetch Evaluation","operationId":"fetch_simple_evaluation","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Evaluations"],"summary":"Edit Evaluation","operationId":"edit_simple_evaluation","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluationEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Evaluations"],"summary":"Delete Evaluation","operationId":"delete_simple_evaluation","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluationIdResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/{evaluation_id}/start":{"post":{"tags":["Evaluations"],"summary":"Start Evaluation","operationId":"start_simple_evaluation","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/{evaluation_id}/stop":{"post":{"tags":["Evaluations"],"summary":"Stop Evaluation","operationId":"stop_simple_evaluation","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/{evaluation_id}/close":{"post":{"tags":["Evaluations"],"summary":"Close Evaluation","operationId":"close_simple_evaluation","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/{evaluation_id}/open":{"post":{"tags":["Evaluations"],"summary":"Open Evaluation","operationId":"open_simple_evaluation","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/{evaluation_id}/populate":{"post":{"tags":["Evaluations"],"summary":"Populate Evaluation Slice","operationId":"populate_slice","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PopulateSliceRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationResultsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/{evaluation_id}/process":{"post":{"tags":["Evaluations"],"summary":"Process Evaluation Slice","operationId":"process_slice","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProcessSliceRequest"}}}},"responses":{"202":{"description":"Accepted.","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/{evaluation_id}/probe":{"post":{"tags":["Evaluations"],"summary":"Probe Evaluation Slice","operationId":"probe_slice","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProbeSliceRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationResultsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/{evaluation_id}/prune":{"post":{"tags":["Evaluations"],"summary":"Prune Evaluation Slice","operationId":"prune_slice","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PruneSliceRequest"}}}},"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/{evaluation_id}/refresh":{"post":{"tags":["Evaluations"],"summary":"Refresh Evaluation Slice","operationId":"refresh_slice","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshSliceRequest"}}}},"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/{evaluation_id}/scenarios/add":{"post":{"tags":["Evaluations"],"summary":"Add Evaluation Scenarios","operationId":"add_scenarios","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddScenariosRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationScenariosResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/{evaluation_id}/scenarios/remove":{"post":{"tags":["Evaluations"],"summary":"Remove Evaluation Scenarios","operationId":"remove_scenarios","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RemoveScenariosRequest"}}}},"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/{evaluation_id}/steps/add":{"post":{"tags":["Evaluations"],"summary":"Add Evaluation Steps","operationId":"add_steps","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddStepsRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/{evaluation_id}/steps/remove":{"post":{"tags":["Evaluations"],"summary":"Remove Evaluation Steps","operationId":"remove_steps","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RemoveStepsRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/{evaluation_id}/repeats/set":{"post":{"tags":["Evaluations"],"summary":"Set Evaluation Repeats","operationId":"set_repeats","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetRepeatsRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/queues/":{"post":{"tags":["Evaluations"],"summary":"Create Simple Queue","operationId":"create_simple_queue","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueueCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueueResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Evaluations"],"summary":"Delete Simple Queues","operationId":"delete_simple_queues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueueIdsRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueueIdsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/queues/query":{"post":{"tags":["Evaluations"],"summary":"Query Simple Queues","operationId":"query_simple_queues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueueQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueuesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/queues/{queue_id}":{"get":{"tags":["Evaluations"],"summary":"Fetch Simple Queue","operationId":"fetch_simple_queue","parameters":[{"name":"queue_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Queue Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueueResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Evaluations"],"summary":"Delete Simple Queue","operationId":"delete_simple_queue","parameters":[{"name":"queue_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Queue Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueueIdResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/queues/{queue_id}/scenarios/query":{"post":{"tags":["Evaluations"],"summary":"Query Simple Queue Scenarios","operationId":"query_simple_queue_scenarios","parameters":[{"name":"queue_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Queue Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueueScenariosQueryRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueueScenariosResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/queues/{queue_id}/traces/":{"post":{"tags":["Evaluations"],"summary":"Add Simple Queue Traces","operationId":"add_simple_queue_traces","parameters":[{"name":"queue_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Queue Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueueTracesCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueueIdResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/queues/{queue_id}/testcases/":{"post":{"tags":["Evaluations"],"summary":"Add Simple Queue Testcases","operationId":"add_simple_queue_testcases","parameters":[{"name":"queue_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Queue Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueueTestcasesCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueueIdResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/mounts/":{"post":{"tags":["Mounts"],"summary":"Create Mount","operationId":"create_mount","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/mounts/query":{"post":{"tags":["Mounts"],"summary":"Query Mounts","operationId":"query_mounts","parameters":[{"name":"session_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Session Id"}},{"name":"agent_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Id"}},{"name":"include_archived","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Include Archived"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountQueryRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/mounts/agents/sign":{"post":{"tags":["Mounts"],"summary":"Sign Agent Mount Credentials","operationId":"sign_agent_mount_credentials","parameters":[{"name":"artifact_id","in":"query","required":true,"schema":{"type":"string","title":"Artifact Id"}},{"name":"name","in":"query","required":false,"schema":{"type":"string","default":"default","title":"Name"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountCredentialsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/mounts/agents/query":{"post":{"tags":["Mounts"],"summary":"Query Agent Mount","operationId":"query_agent_mount","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentMountQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/mounts/{mount_id}":{"get":{"tags":["Mounts"],"summary":"Fetch Mount","operationId":"fetch_mount","parameters":[{"name":"mount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Mount Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Mounts"],"summary":"Edit Mount","operationId":"edit_mount","parameters":[{"name":"mount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Mount Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/mounts/{mount_id}/sign":{"post":{"tags":["Mounts"],"summary":"Sign Mount Credentials","operationId":"sign_mount_credentials","parameters":[{"name":"mount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Mount Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountCredentialsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/mounts/files/export":{"post":{"tags":["Mounts"],"summary":"Export Mount Files","operationId":"export_mount_files","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountArchiveRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/zip":{"schema":{"type":"string","format":"binary"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/mounts/{mount_id}/archive":{"post":{"tags":["Mounts"],"summary":"Archive Mount","operationId":"archive_mount","parameters":[{"name":"mount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Mount Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/mounts/{mount_id}/unarchive":{"post":{"tags":["Mounts"],"summary":"Unarchive Mount","operationId":"unarchive_mount","parameters":[{"name":"mount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Mount Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/mounts/{mount_id}/files/folder":{"post":{"tags":["Mounts"],"summary":"Create Folder","operationId":"create_mount_folder","parameters":[{"name":"mount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Mount Id"}},{"name":"path","in":"query","required":true,"schema":{"type":"string","title":"Path"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountFolderCreatedResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/mounts/{mount_id}/files/upload":{"post":{"tags":["Mounts"],"summary":"Upload Mount File","operationId":"upload_mount_file","parameters":[{"name":"mount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Mount Id"}},{"name":"path","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_upload_mount_file"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountFileWrittenResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/mounts/{mount_id}/files/download":{"get":{"tags":["Mounts"],"summary":"Download Mount File","operationId":"download_mount_file","parameters":[{"name":"mount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Mount Id"}},{"name":"path","in":"query","required":true,"schema":{"type":"string","title":"Path"}}],"responses":{"200":{"description":"Successful Response","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/mounts/{mount_id}/files":{"get":{"tags":["Mounts"],"summary":"Get Mount Files","operationId":"get_mount_files","parameters":[{"name":"mount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Mount Id"}},{"name":"path","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path"}},{"name":"read","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Read"}},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"enum":["recent","name","path"],"type":"string"},{"type":"null"}],"title":"Order"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","minimum":0},{"type":"null"}],"title":"Limit"}},{"name":"depth","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","maximum":1,"minimum":1},{"type":"null"}],"title":"Depth"}},{"name":"with_counts","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"With Counts"}},{"name":"git_aware","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Git Aware"}},{"name":"include_gitignored","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Include Gitignored"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Mounts"],"summary":"Write Mount File","operationId":"write_mount_file","parameters":[{"name":"mount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Mount Id"}},{"name":"path","in":"query","required":true,"schema":{"type":"string","title":"Path"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountFileWrittenResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Mounts"],"summary":"Delete Mount File","operationId":"delete_mount_file","parameters":[{"name":"mount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Mount Id"}},{"name":"path","in":"query","required":true,"schema":{"type":"string","title":"Path"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountFileDeletedResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/attachments":{"post":{"tags":["Sessions"],"summary":"Create Session Attachment","operationId":"create_session_attachment","parameters":[{"name":"session_id","in":"query","required":true,"schema":{"type":"string","title":"Session Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionAttachmentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/attachments/{attachment_id}/content":{"get":{"tags":["Sessions"],"summary":"Download Session Attachment Content","operationId":"download_session_attachment_content","parameters":[{"name":"attachment_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Attachment Id"}},{"name":"session_id","in":"query","required":true,"schema":{"type":"string","title":"Session Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/attachments/reference":{"post":{"tags":["Sessions"],"summary":"Reference Session Attachments","operationId":"reference_session_attachments","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionAttachmentReferenceRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionAttachmentsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/mounts/":{"get":{"tags":["Sessions"],"summary":"Fetch Session Mounts","operationId":"fetch_session_mounts","parameters":[{"name":"session_id","in":"query","required":true,"schema":{"type":"string","title":"Session Id"}},{"name":"include_archived","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Include Archived"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionMountsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/mounts/query":{"post":{"tags":["Sessions"],"summary":"Query Session Mounts","operationId":"query_session_mounts","parameters":[{"name":"session_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Session Id"}},{"name":"include_archived","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Include Archived"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionMountQueryRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionMountsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/mounts/sign":{"post":{"tags":["Sessions"],"summary":"Sign Session Mount Credentials","operationId":"sign_session_mount_credentials","parameters":[{"name":"session_id","in":"query","required":true,"schema":{"type":"string","title":"Session Id"}},{"name":"name","in":"query","required":false,"schema":{"type":"string","description":"Which session-scoped mount to sign, e.g. 'cwd' (default) or a per-harness transcript dir mount (e.g. 'claude-projects', 'pi-sessions'). Each name is its own mount row / durable prefix.","default":"cwd","title":"Name"},"description":"Which session-scoped mount to sign, e.g. 'cwd' (default) or a per-harness transcript dir mount (e.g. 'claude-projects', 'pi-sessions'). Each name is its own mount row / durable prefix."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountCredentialsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/mounts/{mount_id}/files/upload":{"post":{"tags":["Sessions"],"summary":"Upload Session Mount File","operationId":"upload_session_mount_file","parameters":[{"name":"mount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Mount Id"}},{"name":"path","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_upload_session_mount_file"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountFileWrittenResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/mounts/{mount_id}/files/download":{"get":{"tags":["Sessions"],"summary":"Download Session Mount File","operationId":"download_session_mount_file","parameters":[{"name":"mount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Mount Id"}},{"name":"path","in":"query","required":true,"schema":{"type":"string","title":"Path"}}],"responses":{"200":{"description":"Successful Response","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/records/query":{"post":{"tags":["Sessions"],"summary":"Query Records","operationId":"query_records","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionRecordQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionRecordsQueryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/records/{record_id}":{"get":{"tags":["Sessions"],"summary":"Get Record Event","operationId":"get_record_event","parameters":[{"name":"record_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Record Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionRecordResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/records/ingest":{"post":{"tags":["Sessions","Sessions"],"summary":"Ingest Record Event","operationId":"ingest_record","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionRecordIngestRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Ingest Record"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/turns/":{"post":{"tags":["Sessions"],"summary":"Append Turn","operationId":"append_turn","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionTurnAppendRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionTurnResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/turns/complete":{"post":{"tags":["Sessions"],"summary":"Complete Turn","operationId":"complete_turn","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionTurnCompleteRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionTurnResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/turns/query":{"post":{"tags":["Sessions"],"summary":"Query Turns","operationId":"query_turns","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionTurnQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionTurnsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/turns/{turn_id}":{"get":{"tags":["Sessions"],"summary":"Fetch Turn","operationId":"fetch_turn","parameters":[{"name":"turn_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Turn Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionTurnResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/accounts/":{"post":{"tags":["Admin"],"summary":"Create accounts","operationId":"create_accounts_admin_accounts__post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAccountsCreate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAccountsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Admin"],"summary":"Delete accounts","operationId":"delete_accounts_admin_accounts__delete","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAccountsDelete"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminDeleteResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/":{"post":{"tags":["Admin"],"summary":"Create simple accounts","operationId":"create_simple_accounts_admin_simple_accounts__post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSimpleAccountsCreate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSimpleAccountsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Admin"],"summary":"Delete simple accounts","operationId":"delete_simple_accounts_admin_simple_accounts__delete","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSimpleAccountsDelete"}}},"required":true},"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/users/":{"post":{"tags":["Admin"],"summary":"Create users","operationId":"create_user_admin_simple_accounts_users__post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSimpleAccountsUsersCreate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAccountsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/users/{user_id}":{"delete":{"tags":["Admin"],"summary":"Delete user","operationId":"delete_user_admin_simple_accounts_users__user_id__delete","parameters":[{"name":"user_id","in":"path","required":true,"schema":{"type":"string","title":"User Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminDeleteResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/users/identities/":{"post":{"tags":["Admin"],"summary":"Create user identities","operationId":"create_user_identity_admin_simple_accounts_users_identities__post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSimpleAccountsUsersIdentitiesCreate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAccountsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/users/{user_id}/identities/{identity_id}":{"delete":{"tags":["Admin"],"summary":"Delete user identity","operationId":"delete_user_identity_admin_simple_accounts_users__user_id__identities__identity_id__delete","parameters":[{"name":"user_id","in":"path","required":true,"schema":{"type":"string","title":"User Id"}},{"name":"identity_id","in":"path","required":true,"schema":{"type":"string","title":"Identity Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminDeleteResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/organizations/":{"post":{"tags":["Admin"],"summary":"Create organizations","operationId":"create_organization_admin_simple_accounts_organizations__post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSimpleAccountsOrganizationsCreate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAccountsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/organizations/{organization_id}":{"delete":{"tags":["Admin"],"summary":"Delete organization","operationId":"delete_organization_admin_simple_accounts_organizations__organization_id__delete","parameters":[{"name":"organization_id","in":"path","required":true,"schema":{"type":"string","title":"Organization Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminDeleteResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/organizations/memberships/":{"post":{"tags":["Admin"],"summary":"Create organization memberships","operationId":"create_organization_membership_admin_simple_accounts_organizations_memberships__post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSimpleAccountsOrganizationsMembershipsCreate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAccountsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/organizations/{organization_id}/memberships/{membership_id}":{"delete":{"tags":["Admin"],"summary":"Delete organization membership","operationId":"delete_organization_membership_admin_simple_accounts_organizations__organization_id__memberships__membership_id__delete","parameters":[{"name":"organization_id","in":"path","required":true,"schema":{"type":"string","title":"Organization Id"}},{"name":"membership_id","in":"path","required":true,"schema":{"type":"string","title":"Membership Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminDeleteResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/workspaces/":{"post":{"tags":["Admin"],"summary":"Create workspaces","operationId":"create_workspace_admin_simple_accounts_workspaces__post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSimpleAccountsWorkspacesCreate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAccountsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/workspaces/{workspace_id}":{"delete":{"tags":["Admin"],"summary":"Delete workspace","operationId":"delete_workspace_admin_simple_accounts_workspaces__workspace_id__delete","parameters":[{"name":"workspace_id","in":"path","required":true,"schema":{"type":"string","title":"Workspace Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminDeleteResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/workspaces/memberships/":{"post":{"tags":["Admin"],"summary":"Create workspace memberships","operationId":"create_workspace_membership_admin_simple_accounts_workspaces_memberships__post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSimpleAccountsWorkspacesMembershipsCreate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAccountsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/workspaces/{workspace_id}/memberships/{membership_id}":{"delete":{"tags":["Admin"],"summary":"Delete workspace membership","operationId":"delete_workspace_membership_admin_simple_accounts_workspaces__workspace_id__memberships__membership_id__delete","parameters":[{"name":"workspace_id","in":"path","required":true,"schema":{"type":"string","title":"Workspace Id"}},{"name":"membership_id","in":"path","required":true,"schema":{"type":"string","title":"Membership Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminDeleteResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/projects/":{"post":{"tags":["Admin"],"summary":"Create projects","operationId":"create_project_admin_simple_accounts_projects__post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSimpleAccountsProjectsCreate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAccountsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/projects/{project_id}":{"delete":{"tags":["Admin"],"summary":"Delete project","operationId":"delete_project_admin_simple_accounts_projects__project_id__delete","parameters":[{"name":"project_id","in":"path","required":true,"schema":{"type":"string","title":"Project Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminDeleteResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/projects/memberships/":{"post":{"tags":["Admin"],"summary":"Create project memberships","operationId":"create_project_membership_admin_simple_accounts_projects_memberships__post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSimpleAccountsProjectsMembershipsCreate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAccountsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/projects/{project_id}/memberships/{membership_id}":{"delete":{"tags":["Admin"],"summary":"Delete project membership","operationId":"delete_project_membership_admin_simple_accounts_projects__project_id__memberships__membership_id__delete","parameters":[{"name":"project_id","in":"path","required":true,"schema":{"type":"string","title":"Project Id"}},{"name":"membership_id","in":"path","required":true,"schema":{"type":"string","title":"Membership Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminDeleteResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/api-keys/":{"post":{"tags":["Admin"],"summary":"Create API keys","operationId":"create_api_key_admin_simple_accounts_api_keys__post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSimpleAccountsApiKeysCreate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAccountsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/api-keys/{api_key_id}":{"delete":{"tags":["Admin"],"summary":"Delete API key","operationId":"delete_api_key_admin_simple_accounts_api_keys__api_key_id__delete","parameters":[{"name":"api_key_id","in":"path","required":true,"schema":{"type":"string","title":"Api Key Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminDeleteResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/reset-password":{"post":{"tags":["Admin"],"summary":"Reset user password","operationId":"reset_password_admin_simple_accounts_reset_password_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSimpleAccountsUsersResetPassword"}}},"required":true},"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/transfer-ownership":{"post":{"tags":["Admin"],"summary":"Transfer organization ownership","operationId":"transfer_ownership_admin_simple_accounts_transfer_ownership_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSimpleAccountsOrganizationsTransferOwnership"}}},"required":true},"responses":{"204":{"description":"Successful Response"},"200":{"description":"Partial transfer — some orgs could not be transferred.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSimpleAccountsOrganizationsTransferOwnershipResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/query":{"post":{"tags":["Sessions","Sessions"],"summary":"Query Sessions","operationId":"query_sessions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/":{"delete":{"tags":["Sessions","Sessions"],"summary":"Delete Session","operationId":"delete_session","parameters":[{"name":"session_id","in":"query","required":true,"schema":{"type":"string","title":"Session Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Delete Session"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/archive":{"post":{"tags":["Sessions","Sessions"],"summary":"Archive Session","operationId":"archive_session","parameters":[{"name":"session_id","in":"query","required":true,"schema":{"type":"string","title":"Session Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/unarchive":{"post":{"tags":["Sessions","Sessions"],"summary":"Unarchive Session","operationId":"unarchive_session","parameters":[{"name":"session_id","in":"query","required":true,"schema":{"type":"string","title":"Session Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/health":{"get":{"tags":["Status"],"summary":"Health Check","operationId":"health_check","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/.well-known/jwks.json":{"get":{"tags":["Status"],"summary":"Store Jwks","description":"Public JWKS the object store's OIDC IAM fetches to verify our web-identity tokens.","operationId":"store_jwks","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/access/permissions/check":{"get":{"tags":["Access"],"summary":"Check Permissions","operationId":"check_permissions","parameters":[{"name":"action","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Action"}},{"name":"scope_type","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Scope Type"}},{"name":"scope_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Scope Id"}},{"name":"resource_type","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Resource Type"}},{"name":"resource_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Resource Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/access/roles":{"get":{"tags":["Access"],"summary":"Fetch Roles","description":"Return the effective role catalog per scope (organization,\nworkspace, project). RBAC is an OSS feature, so this is served in both\neditions; the frontend reads the `workspace` scope for the members UI.","operationId":"fetch_access_roles","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":{"items":{"additionalProperties":true,"type":"object"},"type":"array"},"type":"object","title":"Response Fetch Access Roles"}}}}}}},"/projects":{"get":{"tags":["Projects"],"summary":"Get Projects","operationId":"get_projects","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/ProjectsResponse"},"type":"array","title":"Response Get Projects"}}}}}},"post":{"tags":["Projects"],"summary":"Create Project","operationId":"create_project","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProjectRequest"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/projects/{project_id}":{"get":{"tags":["Projects"],"summary":"Get Project","operationId":"get_project","parameters":[{"name":"project_id","in":"path","required":true,"schema":{"type":"string","title":"Project Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Projects"],"summary":"Delete Project","operationId":"delete_project","parameters":[{"name":"project_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Project Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Projects"],"summary":"Update Project","operationId":"update_project","parameters":[{"name":"project_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Project Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProjectRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/profile":{"get":{"tags":["Users"],"summary":"User Profile","operationId":"fetch_user_profile","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}},"delete":{"tags":["Users"],"summary":"Delete User Account","description":"Self-serve deletion of the caller's own account (EE only).\n\nRequires an interactive SuperTokens session. API keys and service tokens are\nrejected: this is an irreversible destructive action, so a leaked or embedded\nintegration key must not be enough to delete the owning account.","operationId":"delete_user_account","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/profile/username":{"put":{"tags":["Users"],"summary":"Update User Username","operationId":"update_user_username","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserUpdate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/profile/reset-password":{"post":{"tags":["Users"],"summary":"Reset User Password","operationId":"reset_user_password","parameters":[{"name":"user_id","in":"query","required":true,"schema":{"type":"string","title":"User Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/keys":{"get":{"tags":["Keys"],"summary":"List Api Keys","description":"List all API keys associated with the authenticated user.\n\nArgs:\n request (Request): The incoming request object.\n\nReturns:\n List[ListAPIKeysResponse]: A list of API Keys associated with the user.","operationId":"list_api_keys","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/ListAPIKeysResponse"},"type":"array","title":"Response List Api Keys"}}}}}},"post":{"tags":["Keys"],"summary":"Create Api Key","description":"Creates an API key for a user.\n\nArgs:\n request (Request): The request object containing the user ID in the request state.\n\nReturns:\n str: The created API key.","operationId":"create_api_key","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"string","title":"Response Create Api Key"}}}}}}},"/keys/{key_prefix}":{"delete":{"tags":["Keys"],"summary":"Delete Api Key","description":"Delete an API key with the given key prefix for the authenticated user.\n\nArgs:\n key_prefix (str): The prefix of the API key to be deleted.\n request (Request): The incoming request object.\n\nReturns:\n dict: A dictionary containing a success message upon successful deletion.\n\nRaises:\n HTTPException: If the API key is not found or does not belong to the user.","operationId":"delete_api_key","parameters":[{"name":"key_prefix","in":"path","required":true,"schema":{"type":"string","title":"Key Prefix"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Delete Api Key"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations":{"get":{"tags":["Organizations"],"summary":"List Organizations","description":"Returns a list of organizations associated with the user's session.\n\nReturns:\n list[Organization]: A list of organizations associated with the user's session.\n\nRaises:\n HTTPException: If there is an error retrieving the organizations from the database.","operationId":"list_organizations","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Organization"},"type":"array","title":"Response List Organizations"}}}}}},"post":{"tags":["Organizations"],"summary":"Create Organization","description":"Create a new organization.","operationId":"create_organization","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateOrganizationPayload"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations/{organization_id}":{"get":{"tags":["Organizations"],"summary":"Fetch Organization Details","description":"Return the details of the organization.","operationId":"fetch_organization_details","parameters":[{"name":"organization_id","in":"path","required":true,"schema":{"type":"string","title":"Organization Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationDetails"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Organizations"],"summary":"Update Organization","operationId":"patch_organization","parameters":[{"name":"organization_id","in":"path","required":true,"schema":{"type":"string","title":"Organization Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Organizations"],"summary":"Update Organization","operationId":"update_organization","parameters":[{"name":"organization_id","in":"path","required":true,"schema":{"type":"string","title":"Organization Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Organizations"],"summary":"Delete Organization","description":"Delete an organization (owner only).","operationId":"delete_organization","parameters":[{"name":"organization_id","in":"path","required":true,"schema":{"type":"string","title":"Organization Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations/{organization_id}/workspaces/{workspace_id}/invite":{"post":{"tags":["Organizations"],"summary":"Invite User To Organization","description":"Assigns a role to a user in an organization.\n\nArgs:\n organization_id (str): The ID of the organization.\n payload (InviteRequest): The payload containing the organization id, user email, and role to assign.\n workspace_id (str): The ID of the workspace.\n\nReturns:\n bool: True if the role was successfully assigned, False otherwise.\n\nRaises:\n HTTPException: If the user does not have permission to perform this action.\n HTTPException: If there is an error assigning the role to the user.","operationId":"invite_user_to_workspace","parameters":[{"name":"organization_id","in":"path","required":true,"schema":{"type":"string","title":"Organization Id"}},{"name":"workspace_id","in":"path","required":true,"schema":{"type":"string","title":"Workspace Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/InviteRequest"},"title":"Payload"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations/{organization_id}/workspaces/{workspace_id}/invite/resend":{"post":{"tags":["Organizations"],"summary":"Resend User Invitation To Organization","description":"Resend an invitation to a user to an Organization.\n\nRaises:\n HTTPException: _description_; status_code: 500\n HTTPException: Invitation not found or has expired; status_code: 400\n HTTPException: You already belong to this organization; status_code: 400\n\nReturns:\n JSONResponse: Resent invitation to user; status_code: 200","operationId":"resend_invitation","parameters":[{"name":"organization_id","in":"path","required":true,"schema":{"type":"string","title":"Organization Id"}},{"name":"workspace_id","in":"path","required":true,"schema":{"type":"string","title":"Workspace Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResendInviteRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations/{organization_id}/workspaces/{workspace_id}/invite/accept":{"post":{"tags":["Organizations"],"summary":"Accept Organization Invitation","description":"Accept an invitation to an organization.\n\nRaises:\n HTTPException: _description_; status_code: 500\n HTTPException: Invitation not found or has expired; status_code: 400\n HTTPException: You already belong to this organization; status_code: 400\n\nReturns:\n JSONResponse: Accepted invitation to workspace; status_code: 200","operationId":"accept_invitation","parameters":[{"name":"organization_id","in":"path","required":true,"schema":{"type":"string","title":"Organization Id"}},{"name":"workspace_id","in":"path","required":true,"schema":{"type":"string","title":"Workspace Id"}},{"name":"project_id","in":"query","required":true,"schema":{"type":"string","title":"Project Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InviteToken"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations/{organization_id}/transfer/{new_owner_id}":{"post":{"tags":["Organizations"],"summary":"Transfer Organization Ownership","description":"Transfer organization ownership to another member.","operationId":"transfer_organization_ownership","parameters":[{"name":"organization_id","in":"path","required":true,"schema":{"type":"string","title":"Organization Id"}},{"name":"new_owner_id","in":"path","required":true,"schema":{"type":"string","title":"New Owner Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations/{organization_id}/workspaces":{"post":{"tags":["Organizations"],"summary":"Create Workspace","description":"Create a new workspace in an organization (owner only).","operationId":"create_workspace","parameters":[{"name":"organization_id","in":"path","required":true,"schema":{"type":"string","title":"Organization Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspace"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations/{organization_id}/workspaces/{workspace_id}":{"put":{"tags":["Organizations"],"summary":"Update Workspace","description":"Update a workspace's details (requires EDIT_WORKSPACE permission).","operationId":"update_workspace","parameters":[{"name":"organization_id","in":"path","required":true,"schema":{"type":"string","title":"Organization Id"}},{"name":"workspace_id","in":"path","required":true,"schema":{"type":"string","title":"Workspace Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspace"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workspaces":{"get":{"tags":["Workspaces"],"summary":"Get Workspace","description":"Get workspace details.\n\nReturns details about the workspace associated with the user's session.\n\nReturns:\n Workspace: The details of the workspace.\n\nRaises:\n HTTPException: If the user does not have permission to perform this action.","operationId":"get_workspace","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Workspace"},"type":"array","title":"Response Get Workspace"}}}}}}},"/workspaces/roles":{"get":{"tags":["Workspaces"],"summary":"Get All Workspace Roles","description":"Get all workspace roles.\n\nReturns a list of all available workspace roles.\n\nReturns:\n List[WorkspaceRoleResponse]: A list of DefaultRole objects representing the available workspace roles.\n\nRaises:\n HTTPException: If an error occurs while retrieving the workspace roles.","operationId":"get_all_workspace_roles","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"additionalProperties":{"type":"string"},"type":"object"},"type":"array","title":"Response Get All Workspace Roles"}}}}}}},"/workspaces/permissions":{"get":{"tags":["Workspaces"],"summary":"Get All Workspace Permissions","description":"Get all available workspace permissions.","operationId":"get_all_workspace_permissions","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Permission"},"type":"array","title":"Response Get All Workspace Permissions"}}}}}}},"/workspaces/{workspace_id}/roles":{"post":{"tags":["Workspaces"],"summary":"Assign Role To User","description":"Assign a role to a user in a workspace.\n\nArgs:\n payload (UserRole): The organization id, user email, and role to assign.\n workspace_id (str): The ID of the workspace.","operationId":"assign_role_to_user","parameters":[{"name":"workspace_id","in":"path","required":true,"schema":{"type":"string","title":"Workspace Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserRole"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Workspaces"],"summary":"Unassign Role From User","description":"Remove a role assignment from a user in a workspace.\n\nArgs:\n email (str): The email of the user.\n organization_id (str): The ID of the organization.\n role (str): The role to remove.\n workspace_id (str): The ID of the workspace.","operationId":"unassign_role_from_user","parameters":[{"name":"workspace_id","in":"path","required":true,"schema":{"type":"string","title":"Workspace Id"}},{"name":"email","in":"query","required":true,"schema":{"type":"string","title":"Email"}},{"name":"organization_id","in":"query","required":true,"schema":{"type":"string","title":"Organization Id"}},{"name":"role","in":"query","required":true,"schema":{"type":"string","title":"Role"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workspaces/{workspace_id}/users":{"delete":{"tags":["Workspaces"],"summary":"Remove User From Workspace","description":"Remove a user from a workspace.\n\nArgs:\n email (str): The email address of the user to be removed\n workspace_id (str): The ID of the workspace.","operationId":"remove_user_from_workspace","parameters":[{"name":"workspace_id","in":"path","required":true,"schema":{"type":"string","title":"Workspace Id"}},{"name":"email","in":"query","required":true,"schema":{"type":"string","title":"Email"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}}},"components":{"schemas":{"AddScenariosRequest":{"properties":{"count":{"type":"integer","title":"Count"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Timestamp"}},"type":"object","required":["count"],"title":"AddScenariosRequest"},"AddStepsRequest":{"properties":{"steps":{"items":{"$ref":"#/components/schemas/EvaluationRunDataStep-Input"},"type":"array","title":"Steps"}},"type":"object","required":["steps"],"title":"AddStepsRequest"},"AdminAccountCreateOptions":{"properties":{"dry_run":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Dry Run","default":false},"idempotency_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Idempotency Key"},"create_identities":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Create Identities"},"create_api_keys":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Create Api Keys"},"return_api_keys":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Return Api Keys"},"seed_defaults":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Seed Defaults","default":true},"reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Reason"}},"type":"object","title":"AdminAccountCreateOptions"},"AdminAccountRead":{"properties":{"users":{"additionalProperties":{"$ref":"#/components/schemas/AdminUserRead"},"type":"object","title":"Users","default":{}},"user_identities":{"additionalProperties":{"$ref":"#/components/schemas/AdminUserIdentityRead"},"type":"object","title":"User Identities","default":{}},"organizations":{"additionalProperties":{"$ref":"#/components/schemas/AdminOrganizationRead"},"type":"object","title":"Organizations","default":{}},"workspaces":{"additionalProperties":{"$ref":"#/components/schemas/AdminWorkspaceRead"},"type":"object","title":"Workspaces","default":{}},"projects":{"additionalProperties":{"$ref":"#/components/schemas/AdminProjectRead"},"type":"object","title":"Projects","default":{}},"organization_memberships":{"additionalProperties":{"$ref":"#/components/schemas/AdminOrganizationMembershipRead"},"type":"object","title":"Organization Memberships","default":{}},"workspace_memberships":{"additionalProperties":{"$ref":"#/components/schemas/AdminWorkspaceMembershipRead"},"type":"object","title":"Workspace Memberships","default":{}},"project_memberships":{"additionalProperties":{"$ref":"#/components/schemas/AdminProjectMembershipRead"},"type":"object","title":"Project Memberships","default":{}},"subscriptions":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminSubscriptionRead"},"type":"object"},{"type":"null"}],"title":"Subscriptions"},"api_keys":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminApiKeyResponse"},"type":"object"},{"type":"null"}],"title":"Api Keys"}},"type":"object","title":"AdminAccountRead","description":"Per-account projection in the full graph response (plural entity maps)."},"AdminAccountsCreate":{"properties":{"options":{"anyOf":[{"$ref":"#/components/schemas/AdminAccountCreateOptions"},{"type":"null"}]},"users":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminUserCreate"},"type":"object"},{"type":"null"}],"title":"Users"},"user_identities":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminUserIdentityCreate"},"type":"object"},{"type":"null"}],"title":"User Identities"},"organizations":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminOrganizationCreate"},"type":"object"},{"type":"null"}],"title":"Organizations"},"workspaces":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminWorkspaceCreate"},"type":"object"},{"type":"null"}],"title":"Workspaces"},"projects":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminProjectCreate"},"type":"object"},{"type":"null"}],"title":"Projects"},"organization_memberships":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminOrganizationMembershipCreate"},"type":"object"},{"type":"null"}],"title":"Organization Memberships"},"workspace_memberships":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminWorkspaceMembershipCreate"},"type":"object"},{"type":"null"}],"title":"Workspace Memberships"},"project_memberships":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminProjectMembershipCreate"},"type":"object"},{"type":"null"}],"title":"Project Memberships"},"api_keys":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminApiKeyCreate"},"type":"object"},{"type":"null"}],"title":"Api Keys"},"subscriptions":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminSubscriptionCreate"},"type":"object"},{"type":"null"}],"title":"Subscriptions"}},"type":"object","title":"AdminAccountsCreate"},"AdminAccountsDelete":{"properties":{"target":{"$ref":"#/components/schemas/AdminAccountsDeleteTarget"},"dry_run":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Dry Run","default":true},"reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Reason"},"confirm":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Confirm"}},"type":"object","required":["target"],"title":"AdminAccountsDelete"},"AdminAccountsDeleteTarget":{"properties":{"user_ids":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"User Ids"},"user_emails":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"User Emails"},"organization_ids":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Organization Ids"},"workspace_ids":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Workspace Ids"},"project_ids":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Project Ids"}},"type":"object","title":"AdminAccountsDeleteTarget"},"AdminAccountsResponse":{"properties":{"accounts":{"items":{"$ref":"#/components/schemas/AdminAccountRead"},"type":"array","title":"Accounts","default":[]},"errors":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminStructuredError"},"type":"array"},{"type":"null"}],"title":"Errors"}},"type":"object","title":"AdminAccountsResponse"},"AdminApiKeyCreate":{"properties":{"project_ref":{"$ref":"#/components/schemas/EntityRef"},"user_ref":{"$ref":"#/components/schemas/EntityRef"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"expires_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Expires At"}},"type":"object","required":["project_ref","user_ref"],"title":"AdminApiKeyCreate"},"AdminApiKeyResponse":{"properties":{"id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Id"},"prefix":{"type":"string","title":"Prefix"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"project_id":{"type":"string","title":"Project Id"},"user_id":{"type":"string","title":"User Id"},"expires_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Expires At"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"revoked_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Revoked At"},"value":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Value"},"returned_once":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Returned Once"}},"type":"object","required":["prefix","project_id","user_id"],"title":"AdminApiKeyResponse"},"AdminDeleteResponse":{"properties":{"dry_run":{"type":"boolean","title":"Dry Run","default":false},"deleted":{"$ref":"#/components/schemas/AdminDeletedEntities","default":{}},"skipped":{"anyOf":[{"$ref":"#/components/schemas/AdminDeletedEntities"},{"type":"null"}]},"errors":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminStructuredError"},"type":"array"},{"type":"null"}],"title":"Errors"}},"type":"object","title":"AdminDeleteResponse"},"AdminDeletedEntities":{"properties":{"users":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminDeletedEntity"},"type":"array"},{"type":"null"}],"title":"Users"},"user_identities":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminDeletedEntity"},"type":"array"},{"type":"null"}],"title":"User Identities"},"organizations":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminDeletedEntity"},"type":"array"},{"type":"null"}],"title":"Organizations"},"workspaces":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminDeletedEntity"},"type":"array"},{"type":"null"}],"title":"Workspaces"},"projects":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminDeletedEntity"},"type":"array"},{"type":"null"}],"title":"Projects"},"organization_memberships":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminDeletedEntity"},"type":"array"},{"type":"null"}],"title":"Organization Memberships"},"workspace_memberships":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminDeletedEntity"},"type":"array"},{"type":"null"}],"title":"Workspace Memberships"},"project_memberships":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminDeletedEntity"},"type":"array"},{"type":"null"}],"title":"Project Memberships"},"api_keys":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminDeletedEntity"},"type":"array"},{"type":"null"}],"title":"Api Keys"}},"type":"object","title":"AdminDeletedEntities"},"AdminDeletedEntity":{"properties":{"id":{"type":"string","title":"Id"},"ref":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ref"}},"type":"object","required":["id"],"title":"AdminDeletedEntity"},"AdminOrganizationCreate":{"properties":{"name":{"type":"string","title":"Name"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"owner_user_ref":{"anyOf":[{"$ref":"#/components/schemas/EntityRef"},{"type":"null"}]},"metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metadata"}},"type":"object","required":["name"],"title":"AdminOrganizationCreate"},"AdminOrganizationMembershipCreate":{"properties":{"organization_ref":{"$ref":"#/components/schemas/EntityRef"},"user_ref":{"$ref":"#/components/schemas/EntityRef"},"role":{"type":"string","title":"Role"}},"type":"object","required":["organization_ref","user_ref","role"],"title":"AdminOrganizationMembershipCreate"},"AdminOrganizationMembershipRead":{"properties":{"id":{"type":"string","title":"Id"},"organization_id":{"type":"string","title":"Organization Id"},"user_id":{"type":"string","title":"User Id"},"role":{"type":"string","title":"Role"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Updated At"}},"type":"object","required":["id","organization_id","user_id","role"],"title":"AdminOrganizationMembershipRead"},"AdminOrganizationRead":{"properties":{"id":{"type":"string","title":"Id"},"name":{"type":"string","title":"Name"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"owner_user_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Owner User Id"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Updated At"},"metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metadata"}},"type":"object","required":["id","name"],"title":"AdminOrganizationRead"},"AdminProjectCreate":{"properties":{"name":{"type":"string","title":"Name"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"organization_ref":{"$ref":"#/components/schemas/EntityRef"},"workspace_ref":{"$ref":"#/components/schemas/EntityRef"},"is_default":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Default"},"metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metadata"}},"type":"object","required":["name","organization_ref","workspace_ref"],"title":"AdminProjectCreate"},"AdminProjectMembershipCreate":{"properties":{"project_ref":{"$ref":"#/components/schemas/EntityRef"},"user_ref":{"$ref":"#/components/schemas/EntityRef"},"role":{"type":"string","title":"Role"}},"type":"object","required":["project_ref","user_ref","role"],"title":"AdminProjectMembershipCreate"},"AdminProjectMembershipRead":{"properties":{"id":{"type":"string","title":"Id"},"project_id":{"type":"string","title":"Project Id"},"user_id":{"type":"string","title":"User Id"},"role":{"type":"string","title":"Role"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Updated At"}},"type":"object","required":["id","project_id","user_id","role"],"title":"AdminProjectMembershipRead"},"AdminProjectRead":{"properties":{"id":{"type":"string","title":"Id"},"name":{"type":"string","title":"Name"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"organization_id":{"type":"string","title":"Organization Id"},"workspace_id":{"type":"string","title":"Workspace Id"},"is_default":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Default"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Updated At"},"metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metadata"}},"type":"object","required":["id","name","organization_id","workspace_id"],"title":"AdminProjectRead"},"AdminSimpleAccountCreate":{"properties":{"options":{"anyOf":[{"$ref":"#/components/schemas/AdminAccountCreateOptions"},{"type":"null"}]},"user":{"$ref":"#/components/schemas/AdminUserCreate"},"user_identities":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminUserIdentityCreate"},"type":"array"},{"type":"null"}],"title":"User Identities"},"organization":{"anyOf":[{"$ref":"#/components/schemas/AdminOrganizationCreate"},{"type":"null"}]},"workspace":{"anyOf":[{"$ref":"#/components/schemas/AdminWorkspaceCreate"},{"type":"null"}]},"project":{"anyOf":[{"$ref":"#/components/schemas/AdminProjectCreate"},{"type":"null"}]},"organization_memberships":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminOrganizationMembershipCreate"},"type":"array"},{"type":"null"}],"title":"Organization Memberships"},"workspace_memberships":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminWorkspaceMembershipCreate"},"type":"array"},{"type":"null"}],"title":"Workspace Memberships"},"project_memberships":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminProjectMembershipCreate"},"type":"array"},{"type":"null"}],"title":"Project Memberships"},"api_keys":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminApiKeyCreate"},"type":"array"},{"type":"null"}],"title":"Api Keys"},"subscription":{"anyOf":[{"$ref":"#/components/schemas/AdminSubscriptionCreate"},{"type":"null"}]}},"type":"object","required":["user"],"title":"AdminSimpleAccountCreate","description":"One account entry in a batch simple-accounts create request."},"AdminSimpleAccountDeleteEntry":{"properties":{"user":{"$ref":"#/components/schemas/EntityRef"}},"type":"object","required":["user"],"title":"AdminSimpleAccountDeleteEntry","description":"One account entry in a batch simple-accounts delete request.\n\nIdentifies the account by its user (typically by id)."},"AdminSimpleAccountRead":{"properties":{"user":{"anyOf":[{"$ref":"#/components/schemas/AdminUserRead"},{"type":"null"}]},"user_identities":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminUserIdentityRead"},"type":"array"},{"type":"null"}],"title":"User Identities"},"organizations":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminOrganizationRead"},"type":"object"},{"type":"null"}],"title":"Organizations"},"workspaces":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminWorkspaceRead"},"type":"object"},{"type":"null"}],"title":"Workspaces"},"projects":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminProjectRead"},"type":"object"},{"type":"null"}],"title":"Projects"},"organization_memberships":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminOrganizationMembershipRead"},"type":"array"},{"type":"null"}],"title":"Organization Memberships"},"workspace_memberships":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminWorkspaceMembershipRead"},"type":"array"},{"type":"null"}],"title":"Workspace Memberships"},"project_memberships":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminProjectMembershipRead"},"type":"array"},{"type":"null"}],"title":"Project Memberships"},"subscriptions":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminSubscriptionRead"},"type":"object"},{"type":"null"}],"title":"Subscriptions"},"api_keys":{"anyOf":[{"additionalProperties":{"type":"string"},"type":"object"},{"type":"null"}],"title":"Api Keys"}},"type":"object","title":"AdminSimpleAccountRead","description":"Per-account entry in the simple-accounts response.\n\n``user`` is a flat object (there is always exactly one per account).\n``organizations``, ``workspaces``, ``projects`` are named dicts (keys match\nthe ref keys used internally, e.g. \"org\", \"wrk\", \"prj\").\n``api_keys`` maps ref names to raw key values (plain strings, not DTOs)."},"AdminSimpleAccountsApiKeysCreate":{"properties":{"options":{"anyOf":[{"$ref":"#/components/schemas/AdminAccountCreateOptions"},{"type":"null"}]},"api_key":{"$ref":"#/components/schemas/AdminApiKeyCreate"}},"type":"object","required":["api_key"],"title":"AdminSimpleAccountsApiKeysCreate"},"AdminSimpleAccountsCreate":{"properties":{"options":{"anyOf":[{"$ref":"#/components/schemas/AdminAccountCreateOptions"},{"type":"null"}]},"accounts":{"additionalProperties":{"$ref":"#/components/schemas/AdminSimpleAccountCreate"},"type":"object","title":"Accounts"}},"type":"object","required":["accounts"],"title":"AdminSimpleAccountsCreate"},"AdminSimpleAccountsDelete":{"properties":{"accounts":{"additionalProperties":{"$ref":"#/components/schemas/AdminSimpleAccountDeleteEntry"},"type":"object","title":"Accounts"},"dry_run":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Dry Run","default":false},"reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Reason"},"confirm":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Confirm"}},"type":"object","required":["accounts"],"title":"AdminSimpleAccountsDelete"},"AdminSimpleAccountsOrganizationsCreate":{"properties":{"options":{"anyOf":[{"$ref":"#/components/schemas/AdminAccountCreateOptions"},{"type":"null"}]},"organization":{"$ref":"#/components/schemas/AdminOrganizationCreate"},"owner":{"anyOf":[{"$ref":"#/components/schemas/AdminUserCreate"},{"type":"null"}]}},"type":"object","required":["organization"],"title":"AdminSimpleAccountsOrganizationsCreate"},"AdminSimpleAccountsOrganizationsMembershipsCreate":{"properties":{"options":{"anyOf":[{"$ref":"#/components/schemas/AdminAccountCreateOptions"},{"type":"null"}]},"membership":{"$ref":"#/components/schemas/AdminOrganizationMembershipCreate"}},"type":"object","required":["membership"],"title":"AdminSimpleAccountsOrganizationsMembershipsCreate"},"AdminSimpleAccountsOrganizationsTransferOwnership":{"properties":{"organizations":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/EntityRef"},"type":"object"},{"type":"null"}],"title":"Organizations"},"users":{"additionalProperties":{"$ref":"#/components/schemas/EntityRef"},"type":"object","title":"Users"},"include_workspaces":{"anyOf":[{"type":"string","const":"all"},{"items":{"type":"string"},"type":"array"}],"title":"Include Workspaces","default":"all"},"include_projects":{"anyOf":[{"type":"string","const":"all"},{"items":{"type":"string"},"type":"array"}],"title":"Include Projects","default":"all"},"reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Reason"},"recovery":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Recovery"}},"type":"object","required":["users"],"title":"AdminSimpleAccountsOrganizationsTransferOwnership"},"AdminSimpleAccountsOrganizationsTransferOwnershipResponse":{"properties":{"transferred":{"items":{"type":"string"},"type":"array","title":"Transferred","default":[]},"errors":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminStructuredError"},"type":"array"},{"type":"null"}],"title":"Errors"}},"type":"object","title":"AdminSimpleAccountsOrganizationsTransferOwnershipResponse"},"AdminSimpleAccountsProjectsCreate":{"properties":{"options":{"anyOf":[{"$ref":"#/components/schemas/AdminAccountCreateOptions"},{"type":"null"}]},"project":{"$ref":"#/components/schemas/AdminProjectCreate"}},"type":"object","required":["project"],"title":"AdminSimpleAccountsProjectsCreate"},"AdminSimpleAccountsProjectsMembershipsCreate":{"properties":{"options":{"anyOf":[{"$ref":"#/components/schemas/AdminAccountCreateOptions"},{"type":"null"}]},"membership":{"$ref":"#/components/schemas/AdminProjectMembershipCreate"}},"type":"object","required":["membership"],"title":"AdminSimpleAccountsProjectsMembershipsCreate"},"AdminSimpleAccountsResponse":{"properties":{"accounts":{"additionalProperties":{"$ref":"#/components/schemas/AdminSimpleAccountRead"},"type":"object","title":"Accounts","default":{}},"errors":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminStructuredError"},"type":"array"},{"type":"null"}],"title":"Errors"}},"type":"object","title":"AdminSimpleAccountsResponse"},"AdminSimpleAccountsUsersCreate":{"properties":{"options":{"anyOf":[{"$ref":"#/components/schemas/AdminAccountCreateOptions"},{"type":"null"}]},"user":{"$ref":"#/components/schemas/AdminUserCreate"}},"type":"object","required":["user"],"title":"AdminSimpleAccountsUsersCreate"},"AdminSimpleAccountsUsersIdentitiesCreate":{"properties":{"options":{"anyOf":[{"$ref":"#/components/schemas/AdminAccountCreateOptions"},{"type":"null"}]},"user_ref":{"$ref":"#/components/schemas/EntityRef"},"user_identity":{"$ref":"#/components/schemas/AdminUserIdentityCreate"}},"type":"object","required":["user_ref","user_identity"],"title":"AdminSimpleAccountsUsersIdentitiesCreate"},"AdminSimpleAccountsUsersResetPassword":{"properties":{"user_identities":{"items":{"$ref":"#/components/schemas/AdminUserIdentityCreate"},"type":"array","title":"User Identities"}},"type":"object","required":["user_identities"],"title":"AdminSimpleAccountsUsersResetPassword"},"AdminSimpleAccountsWorkspacesCreate":{"properties":{"options":{"anyOf":[{"$ref":"#/components/schemas/AdminAccountCreateOptions"},{"type":"null"}]},"workspace":{"$ref":"#/components/schemas/AdminWorkspaceCreate"}},"type":"object","required":["workspace"],"title":"AdminSimpleAccountsWorkspacesCreate"},"AdminSimpleAccountsWorkspacesMembershipsCreate":{"properties":{"options":{"anyOf":[{"$ref":"#/components/schemas/AdminAccountCreateOptions"},{"type":"null"}]},"membership":{"$ref":"#/components/schemas/AdminWorkspaceMembershipCreate"}},"type":"object","required":["membership"],"title":"AdminSimpleAccountsWorkspacesMembershipsCreate"},"AdminStructuredError":{"properties":{"code":{"type":"string","title":"Code"},"message":{"type":"string","title":"Message"},"details":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Details"}},"type":"object","required":["code","message"],"title":"AdminStructuredError"},"AdminSubscriptionCreate":{"properties":{"plan":{"type":"string","title":"Plan"}},"type":"object","required":["plan"],"title":"AdminSubscriptionCreate"},"AdminSubscriptionRead":{"properties":{"plan":{"type":"string","title":"Plan"},"active":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Active"}},"type":"object","required":["plan"],"title":"AdminSubscriptionRead"},"AdminUserCreate":{"properties":{"email":{"type":"string","title":"Email"},"username":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Username"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"is_admin":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Admin"},"is_root":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Root"},"metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metadata"}},"type":"object","required":["email"],"title":"AdminUserCreate"},"AdminUserIdentityCreate":{"properties":{"user_ref":{"anyOf":[{"$ref":"#/components/schemas/EntityRef"},{"type":"null"}]},"method":{"type":"string","title":"Method"},"subject":{"type":"string","title":"Subject"},"domain":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Domain"},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email"},"password":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Password"},"verified":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Verified"},"provider_user_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider User Id"},"claims":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Claims"}},"type":"object","required":["method","subject"],"title":"AdminUserIdentityCreate"},"AdminUserIdentityRead":{"properties":{"id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Id"},"user_id":{"type":"string","title":"User Id"},"method":{"type":"string","title":"Method"},"subject":{"type":"string","title":"Subject"},"domain":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Domain"},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email"},"status":{"type":"string","enum":["created","linked","pending_confirmation","skipped","failed"],"title":"Status","default":"created"},"verified":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Verified"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Updated At"}},"type":"object","required":["user_id","method","subject"],"title":"AdminUserIdentityRead"},"AdminUserRead":{"properties":{"id":{"type":"string","title":"Id"},"uid":{"type":"string","title":"Uid"},"email":{"type":"string","title":"Email"},"username":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Username"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"is_admin":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Admin"},"is_root":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Root"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Updated At"},"metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metadata"}},"type":"object","required":["id","uid","email"],"title":"AdminUserRead"},"AdminWorkspaceCreate":{"properties":{"name":{"type":"string","title":"Name"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"organization_ref":{"$ref":"#/components/schemas/EntityRef"},"metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metadata"}},"type":"object","required":["name","organization_ref"],"title":"AdminWorkspaceCreate"},"AdminWorkspaceMembershipCreate":{"properties":{"workspace_ref":{"$ref":"#/components/schemas/EntityRef"},"user_ref":{"$ref":"#/components/schemas/EntityRef"},"role":{"type":"string","title":"Role"}},"type":"object","required":["workspace_ref","user_ref","role"],"title":"AdminWorkspaceMembershipCreate"},"AdminWorkspaceMembershipRead":{"properties":{"id":{"type":"string","title":"Id"},"workspace_id":{"type":"string","title":"Workspace Id"},"user_id":{"type":"string","title":"User Id"},"role":{"type":"string","title":"Role"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Updated At"}},"type":"object","required":["id","workspace_id","user_id","role"],"title":"AdminWorkspaceMembershipRead"},"AdminWorkspaceRead":{"properties":{"id":{"type":"string","title":"Id"},"name":{"type":"string","title":"Name"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"organization_id":{"type":"string","title":"Organization Id"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Updated At"},"metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metadata"}},"type":"object","required":["id","name","organization_id"],"title":"AdminWorkspaceRead"},"AgentMountQueryRequest":{"properties":{"artifact_id":{"type":"string","title":"Artifact Id"},"name":{"type":"string","title":"Name","default":"default"}},"type":"object","required":["artifact_id"],"title":"AgentMountQueryRequest"},"AgentTemplateOverlay":{"properties":{"tools":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Tools","description":"Platform tool configs and `@ag.embed` tool references."},"skills":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Skills","description":"`@ag.embed` references to authoring skills."},"sandbox":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Sandbox","description":"Sandbox section overlay, e.g. `{permissions: {...}}`."}},"type":"object","title":"AgentTemplateOverlay","description":"A documented subset of the `parameters.agent` authoring shape.\n\nCarries the platform-owned tools, authoring skills, and sandbox elevation the playground\nlayers on top of the draft for the build kit. Entries are intentionally open (platform-op\nconfigs and `@ag.embed` references), so they are typed loosely: the full `parameters.agent`\nauthoring template has no shared Pydantic model today (it rides as free-form\n`data.parameters`), and the SDK's runtime `AgentTemplate` is the flattened parse with\ndifferent field names, so neither can be reused 1:1 to type this overlay."},"Analytics":{"properties":{"count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Count","default":0},"duration":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Duration","default":0.0},"costs":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Costs","default":0.0},"tokens":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Tokens","default":0.0}},"type":"object","title":"Analytics"},"AnalyticsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of time buckets returned.","default":0},"buckets":{"items":{"$ref":"#/components/schemas/MetricsBucket"},"type":"array","title":"Buckets","description":"Time-bucketed aggregates. Each bucket's `metrics` dict is keyed by the dotted `path` of the corresponding `MetricSpec`, ordered oldest to newest.","default":[]},"query":{"$ref":"#/components/schemas/TracingQuery","description":"The resolved query used to compute the buckets."},"specs":{"items":{"$ref":"#/components/schemas/MetricSpec"},"type":"array","title":"Specs","description":"The resolved metric specs applied in each bucket.","default":[]}},"type":"object","title":"AnalyticsResponse","description":"Analytics response with user-specified metric specs."},"Annotation":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"span_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Id"},"trace_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Trace Id"},"attributes":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Attributes"},"origin":{"$ref":"#/components/schemas/SimpleTraceOrigin","default":"custom"},"kind":{"$ref":"#/components/schemas/SimpleTraceKind","default":"adhoc"},"channel":{"$ref":"#/components/schemas/SimpleTraceChannel","default":"api"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"data":{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object","title":"Data"},"references":{"$ref":"#/components/schemas/SimpleTraceReferences"},"links":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/OTelLink-Output"},"type":"object"},{"items":{"$ref":"#/components/schemas/OTelLink-Output"},"type":"array"}],"title":"Links"}},"type":"object","required":["data","references","links"],"title":"Annotation"},"AnnotationCreate":{"properties":{"origin":{"$ref":"#/components/schemas/SimpleTraceOrigin","default":"custom"},"kind":{"$ref":"#/components/schemas/SimpleTraceKind","default":"adhoc"},"channel":{"$ref":"#/components/schemas/SimpleTraceChannel","default":"api"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"data":{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object","title":"Data"},"references":{"$ref":"#/components/schemas/SimpleTraceReferences"},"links":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"object"},{"items":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"array"}],"title":"Links"}},"type":"object","required":["data","references","links"],"title":"AnnotationCreate"},"AnnotationCreateRequest":{"properties":{"annotation":{"$ref":"#/components/schemas/AnnotationCreate"}},"type":"object","required":["annotation"],"title":"AnnotationCreateRequest"},"AnnotationEdit":{"properties":{"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"data":{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object","title":"Data"},"references":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceReferences"},{"type":"null"}]},"links":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"object"},{"items":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"array"},{"type":"null"}],"title":"Links"}},"type":"object","required":["data"],"title":"AnnotationEdit"},"AnnotationEditRequest":{"properties":{"annotation":{"$ref":"#/components/schemas/AnnotationEdit"}},"type":"object","required":["annotation"],"title":"AnnotationEditRequest"},"AnnotationLinkResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"annotation_link":{"anyOf":[{"$ref":"#/components/schemas/OTelLink-Output"},{"type":"null"}]}},"type":"object","title":"AnnotationLinkResponse"},"AnnotationQuery":{"properties":{"origin":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceOrigin"},{"type":"null"}]},"kind":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceKind"},{"type":"null"}]},"channel":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceChannel"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"references":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceReferences"},{"type":"null"}]},"links":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"object"},{"items":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"array"},{"type":"null"}],"title":"Links"}},"type":"object","title":"AnnotationQuery"},"AnnotationQueryRequest":{"properties":{"annotation":{"anyOf":[{"$ref":"#/components/schemas/AnnotationQuery"},{"type":"null"}]},"annotation_links":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"array"},{"type":"null"}],"title":"Annotation Links"},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"AnnotationQueryRequest"},"AnnotationResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"annotation":{"anyOf":[{"$ref":"#/components/schemas/Annotation"},{"type":"null"}]}},"type":"object","title":"AnnotationResponse"},"AnnotationsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"annotations":{"items":{"$ref":"#/components/schemas/Annotation"},"type":"array","title":"Annotations","default":[]}},"type":"object","title":"AnnotationsResponse"},"Application":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/ApplicationArtifactFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"Application"},"ApplicationArtifactFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false}},"type":"object","title":"ApplicationArtifactFlags","description":"Application flags - is_application=True; other booleans use their normal defaults unless explicitly set."},"ApplicationArtifactQueryFlags":{"properties":{"is_application":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Application"},"is_evaluator":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Evaluator"},"is_snippet":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Snippet"}},"type":"object","title":"ApplicationArtifactQueryFlags","description":"Application query flags - filter for is_application=True."},"ApplicationCatalogPreset":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"key":{"type":"string","title":"Key"},"categories":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Categories"},"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowCatalogFlags"},{"type":"null"}]},"data":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevisionData-Output"},{"type":"null"}]}},"type":"object","required":["key"],"title":"ApplicationCatalogPreset"},"ApplicationCatalogPresetResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when found, `0` otherwise.","default":0},"preset":{"anyOf":[{"$ref":"#/components/schemas/ApplicationCatalogPreset"},{"type":"null"}],"description":"Catalog preset definition."}},"type":"object","title":"ApplicationCatalogPresetResponse","description":"Single preset response envelope."},"ApplicationCatalogPresetsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of presets returned.","default":0},"presets":{"items":{"$ref":"#/components/schemas/ApplicationCatalogPreset"},"type":"array","title":"Presets","description":"Named parameter sets for the template. Use a preset's `data` as the first revision when creating an application from a template."}},"type":"object","title":"ApplicationCatalogPresetsResponse","description":"List of catalog presets scoped to one template."},"ApplicationCatalogTemplate":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"key":{"type":"string","title":"Key"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"categories":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Categories"},"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowCatalogFlags"},{"type":"null"}]},"data":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevisionData-Output"},{"type":"null"}]}},"type":"object","required":["key"],"title":"ApplicationCatalogTemplate"},"ApplicationCatalogTemplateResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when found, `0` otherwise.","default":0},"template":{"anyOf":[{"$ref":"#/components/schemas/ApplicationCatalogTemplate"},{"type":"null"}],"description":"Catalog template definition."}},"type":"object","title":"ApplicationCatalogTemplateResponse","description":"Single template response envelope."},"ApplicationCatalogTemplatesResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of templates returned.","default":0},"templates":{"items":{"$ref":"#/components/schemas/ApplicationCatalogTemplate"},"type":"array","title":"Templates","description":"Built-in and custom templates an application can be created from. Each template carries a `key`, a `uri`, and the JSON Schemas that applications of that type expose."}},"type":"object","title":"ApplicationCatalogTemplatesResponse","description":"List of catalog templates."},"ApplicationCatalogType":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"key":{"type":"string","title":"Key"},"json_schema":{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object","title":"Json Schema"}},"type":"object","required":["key","json_schema"],"title":"ApplicationCatalogType"},"ApplicationCatalogTypesResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of types returned.","default":0},"types":{"items":{"$ref":"#/components/schemas/ApplicationCatalogType"},"type":"array","title":"Types","description":"Shared JSON Schema building blocks referenced by templates (for example `message`, `prompt-template`)."}},"type":"object","title":"ApplicationCatalogTypesResponse","description":"List of catalog types."},"ApplicationCreate":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/ApplicationFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"ApplicationCreate"},"ApplicationCreateRequest":{"properties":{"application":{"$ref":"#/components/schemas/ApplicationCreate","description":"Artifact-level fields for the new application: `slug`, `name`, `description`, `flags`, `tags`, `meta`. The `slug` must be unique within the project."}},"type":"object","required":["application"],"title":"ApplicationCreateRequest","description":"Request body for creating an application artifact.\n\nApplications are versioned resources; creating one produces an empty artifact.\nUse `POST /simple/applications/` if you want to create the artifact, a default\nvariant, and a first committed revision in a single call.\nSee the [Applications guide](/reference/api-guide/applications)."},"ApplicationEdit":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/ApplicationFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"ApplicationEdit"},"ApplicationEditRequest":{"properties":{"application":{"$ref":"#/components/schemas/ApplicationEdit","description":"Artifact fields to update. The `id` must match the `application_id` in the URL path."}},"type":"object","required":["application"],"title":"ApplicationEditRequest","description":"Request body for editing an application artifact.\n\nOnly artifact-level fields (flags, tags, meta) can be edited here. Editing\nthe `name` is currently disabled. To change the prompt or model parameters,\ncommit a new revision on a variant with `/applications/revisions/commit`."},"ApplicationFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false},"is_managed":{"type":"boolean","title":"Is Managed","default":false},"is_custom":{"type":"boolean","title":"Is Custom","default":false},"is_llm":{"type":"boolean","title":"Is Llm","default":false},"is_hook":{"type":"boolean","title":"Is Hook","default":false},"is_code":{"type":"boolean","title":"Is Code","default":false},"is_match":{"type":"boolean","title":"Is Match","default":false},"is_feedback":{"type":"boolean","title":"Is Feedback","default":false},"is_agent":{"type":"boolean","title":"Is Agent","default":false},"is_skill":{"type":"boolean","title":"Is Skill","default":false},"is_chat":{"type":"boolean","title":"Is Chat","default":false},"has_url":{"type":"boolean","title":"Has Url","default":false},"has_script":{"type":"boolean","title":"Has Script","default":false},"has_handler":{"type":"boolean","title":"Has Handler","default":false},"is_static":{"type":"boolean","title":"Is Static","default":false}},"type":"object","title":"ApplicationFlags","description":"Legacy full application flag set."},"ApplicationQuery":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/ApplicationArtifactQueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"slugs":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Slugs"}},"type":"object","title":"ApplicationQuery"},"ApplicationQueryRequest":{"properties":{"application":{"anyOf":[{"$ref":"#/components/schemas/ApplicationQuery"},{"type":"null"}],"description":"Attribute filter. Accepts `slug`, `slugs`, `flags`, `tags`, `meta`. All fields are AND-ed."},"application_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Application Refs","description":"Restrict the query to specific applications by `id` or `slug`. Combined with the `application` filter with AND semantics."},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived","description":"When `true`, include soft-deleted applications. Defaults to `false`."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor pagination and time-range controls."}},"type":"object","title":"ApplicationQueryRequest","description":"Request body for `POST /applications/query`.\n\nReturns artifact rows only. For rows that include the currently resolved\nvariant, revision, and `data` payload merged in, use\n`POST /simple/applications/query`.\nSee [Query Pattern](/reference/api-guide/query-pattern)."},"ApplicationResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when the application was found, `0` otherwise.","default":0},"application":{"anyOf":[{"$ref":"#/components/schemas/Application"},{"type":"null"}],"description":"The application artifact, or `null` if not found."}},"type":"object","title":"ApplicationResponse","description":"Single-application response envelope."},"ApplicationRevision-Input":{"properties":{"application_variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Variant Slug"},"workflow_variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Variant Slug"},"application_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Slug"},"workflow_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Slug"},"application_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Variant Id"},"workflow_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"},"application_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Variant Slug"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"author":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Author"},"date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Date"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"flags":{"anyOf":[{"$ref":"#/components/schemas/ApplicationRevisionFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/ApplicationRevisionData-Input"},{"type":"null"}]}},"type":"object","title":"ApplicationRevision"},"ApplicationRevision-Output":{"properties":{"application_variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Variant Slug"},"workflow_variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Variant Slug"},"application_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Slug"},"workflow_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Slug"},"application_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Variant Id"},"workflow_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"},"application_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Variant Slug"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"author":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Author"},"date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Date"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"flags":{"anyOf":[{"$ref":"#/components/schemas/ApplicationRevisionFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/ApplicationRevisionData-Output"},{"type":"null"}]}},"type":"object","title":"ApplicationRevision"},"ApplicationRevisionCommit":{"properties":{"application_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Variant Id"},"workflow_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"},"application_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/ApplicationFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"data":{"anyOf":[{"$ref":"#/components/schemas/ApplicationRevisionData-Input"},{"type":"null"}]},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"},"delta":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevisionDelta"},{"type":"null"}]},"base_revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Base Revision Id","description":"The revision this change was built on. Omit it to keep today's last-write-wins behavior. Send it on a legacy delta and the commit is refused with `409` when the variant's head has moved since: sending the field is how a caller asks for that check. An ordered delta requires it."}},"type":"object","title":"ApplicationRevisionCommit"},"ApplicationRevisionCommitRequest":{"properties":{"application_revision":{"$ref":"#/components/schemas/ApplicationRevisionCommit","description":"Commit payload. Must include `application_variant_id` and `data`. `message` is a human-readable commit message. `slug` is optional; if omitted, the server generates one."}},"type":"object","required":["application_revision"],"title":"ApplicationRevisionCommitRequest","description":"Request body for committing a new revision on a variant.\n\nThe commit becomes the variant's new tip. Revisions are immutable once\ncommitted; to change behavior, commit another revision.\nSee [Versioning](/reference/api-guide/versioning#committing-a-revision)."},"ApplicationRevisionCreate":{"properties":{"application_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Variant Id"},"workflow_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"},"application_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/ApplicationFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"ApplicationRevisionCreate"},"ApplicationRevisionCreateRequest":{"properties":{"application_revision":{"$ref":"#/components/schemas/ApplicationRevisionCreate","description":"Revision fields. Must reference the parent variant."}},"type":"object","required":["application_revision"],"title":"ApplicationRevisionCreateRequest","description":"Request body for creating a revision row without committing it.\n\nPrefer `POST /applications/revisions/commit` for normal use — commit creates\na revision and advances the variant's tip. The plain create endpoint exists\nfor advanced workflows that populate revision rows out of band."},"ApplicationRevisionData-Input":{"properties":{"uri":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Uri"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"headers":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/Reference"}]},"type":"object"},{"type":"null"}],"title":"Headers"},"runtime":{"anyOf":[{"type":"string","enum":["python","typescript","javascript"]},{"type":"null"}],"title":"Runtime"},"script":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Script"},"schemas":{"anyOf":[{"$ref":"#/components/schemas/JsonSchemas-Input"},{"type":"null"}]},"parameters":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Parameters"}},"additionalProperties":false,"type":"object","title":"ApplicationRevisionData"},"ApplicationRevisionData-Output":{"properties":{"uri":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Uri"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"headers":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/Reference"}]},"type":"object"},{"type":"null"}],"title":"Headers"},"runtime":{"anyOf":[{"type":"string","enum":["python","typescript","javascript"]},{"type":"null"}],"title":"Runtime"},"script":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Script"},"schemas":{"anyOf":[{"$ref":"#/components/schemas/JsonSchemas-Output"},{"type":"null"}]},"parameters":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Parameters"}},"additionalProperties":false,"type":"object","title":"ApplicationRevisionData"},"ApplicationRevisionDeployRequest":{"properties":{"application_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Application reference. If provided, the latest revision of the default variant is deployed."},"application_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Variant reference. Its latest revision is deployed."},"application_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Revision reference. The exact revision is deployed."},"environment_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Target environment (for example `{\"slug\": \"production\"}`)."},"environment_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Target environment variant."},"environment_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Target environment revision; advanced use only."},"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key","description":"Deployment key inside the environment revision. Defaults to `{application_slug}.revision`."},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message","description":"Optional commit message attached to the environment revision."}},"type":"object","title":"ApplicationRevisionDeployRequest","description":"Request body for `POST /applications/revisions/deploy`.\n\nAttaches an application revision to an environment under a key. Subsequent\ncalls to `/applications/revisions/retrieve` with the matching\n`environment_ref` resolve to this revision.\nSee the [Applications guide](/reference/api-guide/applications#deployment)."},"ApplicationRevisionEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/ApplicationFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"ApplicationRevisionEdit"},"ApplicationRevisionEditRequest":{"properties":{"application_revision":{"$ref":"#/components/schemas/ApplicationRevisionEdit","description":"Full revision body. Edit replaces the editable fields in a single PUT, so include every editable field even if its value is unchanged. `id` must match the `application_revision_id` in the URL path. `data`, `author`, `date`, and `message` are immutable."}},"type":"object","required":["application_revision"],"title":"ApplicationRevisionEditRequest","description":"Request body for editing a revision's header fields.\n\nRevisions are immutable snapshots of the application's configuration;\n`data`, `author`, `date`, and `message` cannot be edited. Use this only to\ncorrect metadata such as `description` or `tags`."},"ApplicationRevisionFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false},"is_managed":{"type":"boolean","title":"Is Managed","default":false},"is_custom":{"type":"boolean","title":"Is Custom","default":false},"is_llm":{"type":"boolean","title":"Is Llm","default":false},"is_hook":{"type":"boolean","title":"Is Hook","default":false},"is_code":{"type":"boolean","title":"Is Code","default":false},"is_match":{"type":"boolean","title":"Is Match","default":false},"is_feedback":{"type":"boolean","title":"Is Feedback","default":false},"is_agent":{"type":"boolean","title":"Is Agent","default":false},"is_skill":{"type":"boolean","title":"Is Skill","default":false},"is_chat":{"type":"boolean","title":"Is Chat","default":false},"has_url":{"type":"boolean","title":"Has Url","default":false},"has_script":{"type":"boolean","title":"Has Script","default":false},"has_handler":{"type":"boolean","title":"Has Handler","default":false},"is_static":{"type":"boolean","title":"Is Static","default":false}},"type":"object","title":"ApplicationRevisionFlags"},"ApplicationRevisionQuery":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/ApplicationRevisionQueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"slugs":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Slugs"},"author":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Author"},"authors":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Authors"},"date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Date"},"dates":{"anyOf":[{"items":{"type":"string","format":"date-time"},"type":"array"},{"type":"null"}],"title":"Dates"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"}},"type":"object","title":"ApplicationRevisionQuery"},"ApplicationRevisionQueryFlags":{"properties":{"is_application":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Application"},"is_evaluator":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Evaluator"},"is_snippet":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Snippet"},"is_managed":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Managed"},"is_custom":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Custom"},"is_llm":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Llm"},"is_hook":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Hook"},"is_code":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Code"},"is_match":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Match"},"is_feedback":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Feedback"},"is_agent":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Agent"},"is_skill":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Skill"},"is_chat":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Chat"},"has_url":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Url"},"has_script":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Script"},"has_handler":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Handler"},"is_static":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Static"}},"type":"object","title":"ApplicationRevisionQueryFlags"},"ApplicationRevisionQueryRequest":{"properties":{"application_revision":{"anyOf":[{"$ref":"#/components/schemas/ApplicationRevisionQuery"},{"type":"null"}],"description":"Attribute filter. Includes standard fields (`slug`, `slugs`, `flags`) plus revision-specific ones (`author`, `authors`, `date`, `dates`, `message`)."},"application_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Application Refs","description":"Scope to revisions belonging to these applications."},"application_variant_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Application Variant Refs","description":"Scope to revisions belonging to these variants."},"application_revision_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Application Revision Refs","description":"Restrict to specific revisions by `id` or by `slug` + `version`."},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived","description":"When `true`, include archived revisions. Defaults to `false`."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor pagination and time-range controls."}},"type":"object","title":"ApplicationRevisionQueryRequest","description":"Request body for `POST /applications/revisions/query`.\n\nReturns committed revisions across one or more variants. For the ordered\nlog of a single variant, use `POST /applications/revisions/log`."},"ApplicationRevisionResolveRequest":{"properties":{"application_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Application reference."},"application_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Variant reference; resolves the latest revision on it."},"application_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Revision reference; resolves that exact revision."},"application_revision":{"anyOf":[{"$ref":"#/components/schemas/ApplicationRevision-Input"},{"type":"null"}],"description":"Resolve the references embedded in this revision payload directly, without fetching it first. Only `data` is used; id and metadata are ignored."},"max_depth":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Depth","description":"Maximum nesting depth for embedded references. Protects against runaway recursion. Defaults to `10`.","default":10},"max_embeds":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Embeds","description":"Maximum total number of embedded references to follow. Defaults to `100`.","default":100},"error_policy":{"anyOf":[{"$ref":"#/components/schemas/ErrorPolicy"},{"type":"null"}],"description":"How to handle resolution errors. `exception` (default) aborts; `placeholder` substitutes a marker; `keep` leaves the original reference untouched.","default":"exception"}},"type":"object","title":"ApplicationRevisionResolveRequest","description":"Request body for `POST /applications/revisions/resolve`.\n\nFetches a revision and resolves any embedded references (snippets, linked\nrevisions) inside its `data`. Use when clients need the fully-inlined\nconfiguration instead of the raw stored form."},"ApplicationRevisionResolveResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when a revision was resolved, `0` otherwise.","default":0},"application_revision":{"anyOf":[{"$ref":"#/components/schemas/ApplicationRevision-Output"},{"type":"null"}],"description":"The revision with embedded references inlined into `data`."},"resolution_info":{"anyOf":[{"$ref":"#/components/schemas/ResolutionInfo"},{"type":"null"}],"description":"Diagnostic info about which references were resolved."},"retrieval_info":{"anyOf":[{"$ref":"#/components/schemas/RetrievalInfo"},{"type":"null"}],"description":"References (artifact / variant / revision) actually used to retrieve this revision."}},"type":"object","title":"ApplicationRevisionResolveResponse","description":"Response for `POST /applications/revisions/resolve`."},"ApplicationRevisionResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when a revision was found, `0` otherwise.","default":0},"application_revision":{"anyOf":[{"$ref":"#/components/schemas/ApplicationRevision-Output"},{"type":"null"}],"description":"The application revision, including its `data` payload (prompt, model parameters, schemas, URL)."},"resolution_info":{"anyOf":[{"$ref":"#/components/schemas/ResolutionInfo"},{"type":"null"}],"description":"Present only when the request set `resolve: true`. Describes which embedded references were resolved and any errors that occurred."},"retrieval_info":{"anyOf":[{"$ref":"#/components/schemas/RetrievalInfo"},{"type":"null"}],"description":"References used to retrieve the top-level revision."}},"type":"object","title":"ApplicationRevisionResponse","description":"Single-revision response envelope."},"ApplicationRevisionRetrieveRequest":{"properties":{"application_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Application artifact to look up. Identifies the artifact by `id` or `slug` (both project-unique). When no variant_ref or revision_ref is provided, returns the latest revision of the application's default variant."},"application_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Application variant to look up. Identifies the variant by `id` or `slug` (both project-unique). When no revision_ref is provided, returns the latest revision of this variant."},"application_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Application revision to look up. `id` alone identifies a revision (project-unique). `slug` alone identifies a revision (project-unique). `version` alone is a per-variant sequence number and is **not** sufficient on its own; it must be combined with an `application_variant_ref`. Sending only `version` without a variant ref returns HTTP 400."},"environment_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Environment reference. Returns the revision currently deployed to that environment under the given `key`."},"environment_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Environment variant reference; used together with `environment_ref`."},"environment_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Environment revision reference; used to pin to a specific environment commit instead of the current tip."},"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key","description":"Deployment key inside the environment revision. When omitted and `application_ref` is supplied, the server derives it as `{application_slug}.revision`."},"resolve":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Resolve","description":"When `true`, resolve embedded references in the returned revision's `data` (for example, snippet references)."}},"type":"object","title":"ApplicationRevisionRetrieveRequest","description":"Request body for `POST /applications/revisions/retrieve`.\n\nResolves to a single revision by one or more reference types. Every\nreference supplied must agree with the resolved revision; contradictions\nreturn HTTP 400. See the [Applications guide](/reference/api-guide/applications#invocation)."},"ApplicationRevisionsLog":{"properties":{"application_revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Revision Id"},"workflow_revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Revision Id"},"application_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Variant Id"},"workflow_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"},"application_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"depth":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Depth"}},"type":"object","title":"ApplicationRevisionsLog"},"ApplicationRevisionsLogRequest":{"properties":{"application_revisions":{"$ref":"#/components/schemas/ApplicationRevisionsLog","description":"Filter for the log. Typically set `application_variant_id` to list the revision history of a single variant; optionally set `application_revision_id` + `depth` to walk back a bounded number of commits from a specific revision."}},"type":"object","required":["application_revisions"],"title":"ApplicationRevisionsLogRequest","description":"Request body for `POST /applications/revisions/log`.\n\nReturns the ordered list of revisions committed to a variant, newest first.\nEach entry carries commit metadata and the full revision record."},"ApplicationRevisionsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of revisions in this page.","default":0},"application_revisions":{"items":{"$ref":"#/components/schemas/ApplicationRevision-Output"},"type":"array","title":"Application Revisions","description":"Application revisions matching the query or log."}},"type":"object","title":"ApplicationRevisionsResponse","description":"Paginated list of application revisions."},"ApplicationVariant":{"properties":{"application_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Slug"},"workflow_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Slug"},"application_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/ApplicationVariantFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"ApplicationVariant"},"ApplicationVariantCreate":{"properties":{"application_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/ApplicationFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"ApplicationVariantCreate"},"ApplicationVariantCreateRequest":{"properties":{"application_variant":{"$ref":"#/components/schemas/ApplicationVariantCreate","description":"Variant fields. Must include `application_id` (the artifact the variant belongs to) and a `slug` unique within the project."}},"type":"object","required":["application_variant"],"title":"ApplicationVariantCreateRequest","description":"Request body for creating a variant on an existing application."},"ApplicationVariantEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/ApplicationFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"ApplicationVariantEdit"},"ApplicationVariantEditRequest":{"properties":{"application_variant":{"$ref":"#/components/schemas/ApplicationVariantEdit","description":"Full variant body. Edit replaces the artifact-level fields in a single PUT, so include every editable field even if its value is unchanged. `id` must match the `application_variant_id` in the URL path; `slug` is immutable. Configuration changes (prompt, model parameters) go through `/applications/revisions/commit`, not this endpoint."}},"type":"object","required":["application_variant"],"title":"ApplicationVariantEditRequest","description":"Request body for editing a variant's artifact-level fields."},"ApplicationVariantFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false}},"type":"object","title":"ApplicationVariantFlags"},"ApplicationVariantFork":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/ApplicationFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"ApplicationVariantFork"},"ApplicationVariantForkRequest":{"properties":{"application_variant":{"$ref":"#/components/schemas/ApplicationVariantFork","description":"Config for the new variant (slug, name, description, flags)."},"application_variant_ref":{"$ref":"#/components/schemas/Reference","description":"Source variant to fork from."},"application_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Pin the fork to this revision; defaults to the source variant's head."}},"type":"object","required":["application_variant","application_variant_ref"],"title":"ApplicationVariantForkRequest"},"ApplicationVariantResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when a variant was found, `0` otherwise.","default":0},"application_variant":{"anyOf":[{"$ref":"#/components/schemas/ApplicationVariant"},{"type":"null"}],"description":"The application variant, or `null`."}},"type":"object","title":"ApplicationVariantResponse","description":"Single-variant response envelope."},"ApplicationVariantsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of variants in this page.","default":0},"application_variants":{"items":{"$ref":"#/components/schemas/ApplicationVariant"},"type":"array","title":"Application Variants","description":"Application variants matching the query."}},"type":"object","title":"ApplicationVariantsResponse","description":"Paginated list of application variants."},"ApplicationsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of applications in this page.","default":0},"applications":{"items":{"$ref":"#/components/schemas/Application"},"type":"array","title":"Applications","description":"Application artifacts matching the query."}},"type":"object","title":"ApplicationsResponse","description":"Paginated list of application artifacts."},"ArchiveMount":{"properties":{"mount_id":{"type":"string","format":"uuid","title":"Mount Id"},"prefix":{"type":"string","title":"Prefix","default":""},"path":{"type":"string","title":"Path","default":""}},"type":"object","required":["mount_id"],"title":"ArchiveMount","description":"One mount to include in an archive. `path` scopes it to a folder within the mount (\"\" = the\nwhole mount); `prefix` places its files under `prefix/` in the zip (the folded drive layout)."},"Body_configs_fetch_variants_configs_fetch_post":{"properties":{"variant_ref":{"anyOf":[{"$ref":"#/components/schemas/ReferenceRequestModel-Input"},{"type":"null"}]},"environment_ref":{"anyOf":[{"$ref":"#/components/schemas/ReferenceRequestModel-Input"},{"type":"null"}]},"application_ref":{"anyOf":[{"$ref":"#/components/schemas/ReferenceRequestModel-Input"},{"type":"null"}]}},"type":"object","title":"Body_configs_fetch_variants_configs_fetch_post"},"Body_create_simple_testset_from_file":{"properties":{"file":{"type":"string","contentMediaType":"application/octet-stream","title":"File"},"file_type":{"type":"string","enum":["csv","json"],"title":"File Type","default":"csv"},"testset_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Testset Slug"},"testset_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Testset Name"},"testset_description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Testset Description"},"testset_tags":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Testset Tags"},"testset_meta":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Testset Meta"}},"type":"object","required":["file"],"title":"Body_create_simple_testset_from_file"},"Body_create_testset_revision_from_file":{"properties":{"file":{"type":"string","contentMediaType":"application/octet-stream","title":"File"},"file_type":{"type":"string","enum":["csv","json"],"title":"File Type","default":"csv"},"include_testcases":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Testcases"}},"type":"object","required":["file"],"title":"Body_create_testset_revision_from_file"},"Body_edit_simple_testset_from_file":{"properties":{"file":{"type":"string","contentMediaType":"application/octet-stream","title":"File"},"file_type":{"type":"string","enum":["csv","json"],"title":"File Type","default":"csv"},"testset_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Testset Name"},"testset_description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Testset Description"},"testset_tags":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Testset Tags"},"testset_meta":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Testset Meta"}},"type":"object","required":["file"],"title":"Body_edit_simple_testset_from_file"},"Body_upload_mount_file":{"properties":{"file":{"type":"string","contentMediaType":"application/octet-stream","title":"File"}},"type":"object","required":["file"],"title":"Body_upload_mount_file"},"Body_upload_session_mount_file":{"properties":{"file":{"type":"string","contentMediaType":"application/octet-stream","title":"File"}},"type":"object","required":["file"],"title":"Body_upload_session_mount_file"},"Bucket":{"properties":{"timestamp":{"type":"string","format":"date-time","title":"Timestamp"},"interval":{"type":"integer","title":"Interval"},"total":{"$ref":"#/components/schemas/Analytics"},"errors":{"$ref":"#/components/schemas/Analytics"}},"type":"object","required":["timestamp","interval","total","errors"],"title":"Bucket"},"BuiltinToolConfig":{"properties":{"render":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Render"},"permission":{"anyOf":[{"type":"string","enum":["allow","ask","deny"]},{"type":"null"}],"title":"Permission"},"type":{"type":"string","const":"builtin","title":"Type","default":"builtin"},"name":{"type":"string","minLength":1,"title":"Name"}},"additionalProperties":false,"type":"object","required":["name"],"title":"BuiltinToolConfig","description":"Legacy entry, accepted so revisions written before the rework still parse.\n\nBuilt-in tools are always active and are no longer configured here; the resolver drops\nevery entry with a warning. Keep this arm until the dual-read window closes."},"CapabilitiesQuery":{"properties":{"use_cases":{"items":{"type":"string"},"type":"array","title":"Use Cases"},"provider":{"type":"string","title":"Provider","default":"composio"},"limit_alternatives":{"type":"integer","minimum":0.0,"title":"Limit Alternatives","default":3}},"type":"object","required":["use_cases"],"title":"CapabilitiesQuery","description":"Request body for ``POST /tools/discover``.\n\nThe response is the core ``CapabilitiesResult`` (see\n``docs/design/agent-workflows/projects/tool-discovery/design.md``). Project scope\ncomes from the caller's auth, not the body."},"CapabilitiesResult":{"properties":{"capabilities":{"items":{"$ref":"#/components/schemas/Capability"},"type":"array","title":"Capabilities"},"connections":{"items":{"$ref":"#/components/schemas/ConnectionRequirement"},"type":"array","title":"Connections"},"guidance":{"$ref":"#/components/schemas/CapabilityGuidance"},"ready":{"type":"boolean","title":"Ready","default":false},"notes":{"items":{"type":"string"},"type":"array","title":"Notes"}},"type":"object","title":"CapabilitiesResult","description":"The ``discover_tools`` response (Agenta-native)."},"Capability":{"properties":{"use_case":{"type":"string","title":"Use Case"},"integration":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Integration"},"tool":{"anyOf":[{"$ref":"#/components/schemas/DiscoveredTool"},{"type":"null"}]},"alternatives":{"items":{"$ref":"#/components/schemas/DiscoveredAlternative"},"type":"array","title":"Alternatives"},"connection":{"anyOf":[{"$ref":"#/components/schemas/CapabilityConnection"},{"type":"null"}]},"difficulty":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Difficulty"},"note":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Note"}},"type":"object","required":["use_case"],"title":"Capability","description":"One use_case resolved to a best-match tool, alternatives, and its state."},"CapabilityConnection":{"properties":{"state":{"$ref":"#/components/schemas/ToolConnectionState"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","required":["state"],"title":"CapabilityConnection","description":"The connection state for a capability's primary integration."},"CapabilityGuidance":{"properties":{"plan_steps":{"items":{"type":"string"},"type":"array","title":"Plan Steps"},"pitfalls":{"items":{"type":"string"},"type":"array","title":"Pitfalls"}},"type":"object","title":"CapabilityGuidance","description":"Structured operating knowledge the setup agent composes into ``agents_md``.\n\nComposio slugs in the text are mapped to the same ``integration.action`` names\nused elsewhere, so nothing Composio leaks."},"CollectStatusResponse":{"properties":{"status":{"type":"string","title":"Status","description":"Readiness string. `ready` means the router is mounted and accepts OTLP ingest."}},"type":"object","required":["status"],"title":"CollectStatusResponse","description":"OTLP endpoint readiness response."},"CommandMode":{"type":"string","enum":["send","steer","cancel","attach"],"title":"CommandMode","description":"Derived from the inputs/data × force matrix."},"CommitWarning":{"properties":{"code":{"type":"string","title":"Code"},"message":{"type":"string","title":"Message"},"target":{"anyOf":[{"items":{},"type":"array"},{"type":"null"}],"title":"Target"},"operation_index":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Operation Index"}},"type":"object","required":["code","message"],"title":"CommitWarning","description":"One thing the caller should know about a commit that still succeeded.\n\nThe codes are the engine's (change-set.md 7.1) plus `no_change`, which the commit\nwrapper owns. `target` carries contract-shaped segments, so a caller can act on the\nwarning without parsing its prose."},"ComparisonOperator":{"type":"string","enum":["is","is_not"],"title":"ComparisonOperator"},"Condition":{"properties":{"field":{"type":"string","title":"Field"},"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key"},"value":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"number"},{"type":"boolean"},{"items":{},"type":"array"},{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Value"},"operator":{"anyOf":[{"$ref":"#/components/schemas/ComparisonOperator"},{"$ref":"#/components/schemas/NumericOperator"},{"$ref":"#/components/schemas/StringOperator"},{"$ref":"#/components/schemas/ListOperator"},{"$ref":"#/components/schemas/DictOperator"},{"$ref":"#/components/schemas/ExistenceOperator"},{"type":"null"}],"title":"Operator","default":"is"},"options":{"anyOf":[{"$ref":"#/components/schemas/TextOptions"},{"$ref":"#/components/schemas/ListOptions"},{"type":"null"}],"title":"Options"}},"type":"object","required":["field"],"title":"Condition"},"ConfigResponseModel":{"properties":{"params":{"additionalProperties":true,"type":"object","title":"Params"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"application_ref":{"anyOf":[{"$ref":"#/components/schemas/ReferenceRequestModel-Output"},{"type":"null"}]},"service_ref":{"anyOf":[{"$ref":"#/components/schemas/ReferenceRequestModel-Output"},{"type":"null"}]},"variant_ref":{"anyOf":[{"$ref":"#/components/schemas/ReferenceRequestModel-Output"},{"type":"null"}]},"environment_ref":{"anyOf":[{"$ref":"#/components/schemas/ReferenceRequestModel-Output"},{"type":"null"}]},"application_lifecycle":{"anyOf":[{"$ref":"#/components/schemas/LegacyLifecycleDTO"},{"type":"null"}]},"service_lifecycle":{"anyOf":[{"$ref":"#/components/schemas/LegacyLifecycleDTO"},{"type":"null"}]},"variant_lifecycle":{"anyOf":[{"$ref":"#/components/schemas/LegacyLifecycleDTO"},{"type":"null"}]},"environment_lifecycle":{"anyOf":[{"$ref":"#/components/schemas/LegacyLifecycleDTO"},{"type":"null"}]}},"type":"object","title":"ConfigResponseModel"},"ConnectAffordance":{"properties":{"endpoint":{"type":"string","title":"Endpoint","default":"POST /tools/connections/"},"body":{"additionalProperties":true,"type":"object","title":"Body"}},"type":"object","required":["body"],"title":"ConnectAffordance","description":"The Agenta create-connection call to run when a connection is missing.\n\nSpeaks Agenta, not Composio: it points at ``POST /tools/connections/`` (which\nreturns a ``redirect_url``), never at ``COMPOSIO_MANAGE_CONNECTIONS``."},"ConnectionRequirement":{"properties":{"integration":{"type":"string","title":"Integration"},"state":{"$ref":"#/components/schemas/ToolConnectionState"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"connect":{"anyOf":[{"$ref":"#/components/schemas/ConnectAffordance"},{"type":"null"}]}},"type":"object","required":["integration","state"],"title":"ConnectionRequirement","description":"One integration's connection state, deduped across the result."},"CreateOrganizationPayload":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},"type":"object","title":"CreateOrganizationPayload"},"CreateProjectRequest":{"properties":{"name":{"type":"string","title":"Name"},"make_default":{"type":"boolean","title":"Make Default","default":false}},"type":"object","required":["name"],"title":"CreateProjectRequest"},"CreateSecretDTO":{"properties":{"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"header":{"$ref":"#/components/schemas/Header"},"secret":{"$ref":"#/components/schemas/SecretDTO"},"write_only":{"type":"boolean","title":"Write Only","default":true}},"additionalProperties":false,"type":"object","required":["header","secret"],"title":"CreateSecretDTO"},"CreateWorkspace":{"properties":{"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Type"}},"type":"object","required":["name"],"title":"CreateWorkspace"},"CredentialResult":{"properties":{"status":{"$ref":"#/components/schemas/CredentialStatus"},"message":{"type":"string","title":"Message"}},"type":"object","required":["status","message"],"title":"CredentialResult"},"CredentialStatus":{"type":"string","enum":["valid","invalid","unknown"],"title":"CredentialStatus","description":"Did the provider accept this credential?\n\n`unknown` is an honest answer, not a failure: it means Agenta found no free,\nread-only endpoint that proves the credential works. A public catalog endpoint\nanswering successfully never raises the status above `unknown`."},"CustomModelSettingsDTO":{"properties":{"slug":{"type":"string","title":"Slug"},"extras":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Extras"}},"type":"object","required":["slug"],"title":"CustomModelSettingsDTO"},"CustomProviderDTO":{"properties":{"kind":{"$ref":"#/components/schemas/CustomProviderKind"},"provider":{"$ref":"#/components/schemas/CustomProviderSettingsDTO"},"models":{"items":{"$ref":"#/components/schemas/CustomModelSettingsDTO"},"type":"array","title":"Models"},"harnesses":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Harnesses"},"provider_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider Slug"},"model_keys":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Model Keys"}},"type":"object","required":["kind","provider","models"],"title":"CustomProviderDTO"},"CustomProviderKind":{"type":"string","enum":["custom","azure","bedrock","sagemaker","vertex_ai","openai","cohere","anyscale","deepinfra","alephalpha","groq","minimax","mistral","mistralai","anthropic","perplexityai","together_ai","openrouter","gemini"],"title":"CustomProviderKind"},"CustomProviderSettingsDTO":{"properties":{"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key"},"extras":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Extras"}},"type":"object","title":"CustomProviderSettingsDTO"},"CustomSecretDTO":{"properties":{"secret":{"$ref":"#/components/schemas/CustomSecretSettingsDTO"}},"type":"object","required":["secret"],"title":"CustomSecretDTO"},"CustomSecretFormat":{"type":"string","enum":["text","json"],"title":"CustomSecretFormat"},"CustomSecretSettingsDTO":{"properties":{"format":{"$ref":"#/components/schemas/CustomSecretFormat"},"content":{"anyOf":[{"type":"string"},{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"number"},{"type":"boolean"},{"type":"null"}]},"type":"object"},{"type":"null"}],"title":"Content"}},"type":"object","required":["format"],"title":"CustomSecretSettingsDTO"},"DictOperator":{"type":"string","enum":["has","has_not"],"title":"DictOperator"},"DiscoverRequest":{"properties":{"email":{"type":"string","format":"email","title":"Email"}},"type":"object","required":["email"],"title":"DiscoverRequest"},"DiscoverResponse":{"properties":{"exists":{"type":"boolean","title":"Exists"},"methods":{"additionalProperties":{"anyOf":[{"type":"boolean"},{"$ref":"#/components/schemas/SSOProviders"}]},"type":"object","title":"Methods"}},"type":"object","required":["exists","methods"],"title":"DiscoverResponse"},"DiscoveredAlternative":{"properties":{"integration":{"type":"string","title":"Integration"},"action":{"type":"string","title":"Action"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"provider_action":{"type":"string","title":"Provider Action"}},"type":"object","required":["integration","action","provider_action"],"title":"DiscoveredAlternative","description":"A companion/prerequisite tool the one-line request omitted (Agenta-shaped)."},"DiscoveredTool":{"properties":{"type":{"type":"string","const":"gateway","title":"Type","default":"gateway"},"provider":{"type":"string","title":"Provider","default":"composio"},"integration":{"type":"string","title":"Integration"},"action":{"type":"string","title":"Action"},"connection":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Connection"},"input_schema":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Input Schema"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"provider_action":{"type":"string","title":"Provider Action"}},"type":"object","required":["integration","action","provider_action"],"title":"DiscoveredTool","description":"A discovered tool, already shaped as a ``GatewayToolConfig`` plus the\nmodel-facing extras the setup agent needs. ``connection`` is filled only when\nthe integration's state is ``ready``; otherwise the agent resolves it first."},"DiscoveredTriggerAlternative":{"properties":{"integration":{"type":"string","title":"Integration"},"event_key":{"type":"string","title":"Event Key"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"provider_event":{"type":"string","title":"Provider Event"}},"type":"object","required":["integration","event_key","provider_event"],"title":"DiscoveredTriggerAlternative"},"DiscoveredTriggerEvent":{"properties":{"type":{"type":"string","const":"trigger","title":"Type","default":"trigger"},"provider":{"type":"string","title":"Provider","default":"composio"},"integration":{"type":"string","title":"Integration"},"event_key":{"type":"string","title":"Event Key"},"trigger_config":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Trigger Config"},"payload":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Payload"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"provider_event":{"type":"string","title":"Provider Event"}},"type":"object","required":["integration","event_key","provider_event"],"title":"DiscoveredTriggerEvent"},"DiscoveryResult":{"properties":{"status":{"$ref":"#/components/schemas/DiscoveryStatus"},"models":{"items":{"type":"string"},"type":"array","title":"Models"}},"type":"object","required":["status"],"title":"DiscoveryResult"},"DiscoveryStatus":{"type":"string","enum":["fetched","unsupported","failed"],"title":"DiscoveryStatus","description":"Which model identifiers did the provider return?\n\n`unsupported` means the provider offers no model-list endpoint; `failed` means one\nexists but this attempt did not get an answer. Either way the caller keeps the\nshipped catalog rather than narrowing the user's model choice."},"EntityRef":{"properties":{"ref":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ref"},"id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email"}},"type":"object","title":"EntityRef","description":"Polymorphic reference that can point to a request-local key, an\nexisting persisted ID, a stable slug, or an email address.\nExactly one field must be set."},"Environment":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"Environment"},"EnvironmentCreate":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"EnvironmentCreate"},"EnvironmentCreateRequest":{"properties":{"environment":{"$ref":"#/components/schemas/EnvironmentCreate"}},"type":"object","required":["environment"],"title":"EnvironmentCreateRequest"},"EnvironmentEdit":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"EnvironmentEdit"},"EnvironmentEditRequest":{"properties":{"environment":{"$ref":"#/components/schemas/EnvironmentEdit"}},"type":"object","required":["environment"],"title":"EnvironmentEditRequest"},"EnvironmentFlags":{"properties":{"is_guarded":{"type":"boolean","title":"Is Guarded","default":false}},"type":"object","title":"EnvironmentFlags"},"EnvironmentQueryFlags":{"properties":{"is_guarded":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Guarded"}},"type":"object","title":"EnvironmentQueryFlags"},"EnvironmentResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"environment":{"anyOf":[{"$ref":"#/components/schemas/Environment"},{"type":"null"}]}},"type":"object","title":"EnvironmentResponse"},"EnvironmentRevision-Input":{"properties":{"environment_variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Environment Variant Slug"},"variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Variant Slug"},"environment_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Environment Slug"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"environment_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"environment_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"author":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Author"},"date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Date"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentRevisionData"},{"type":"null"}]}},"type":"object","title":"EnvironmentRevision"},"EnvironmentRevision-Output":{"properties":{"environment_variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Environment Variant Slug"},"variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Variant Slug"},"environment_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Environment Slug"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"environment_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"environment_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"author":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Author"},"date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Date"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentRevisionData"},{"type":"null"}]}},"type":"object","title":"EnvironmentRevision"},"EnvironmentRevisionCommit":{"properties":{"environment_revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Revision Id"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"},"environment_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"environment_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"data":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentRevisionData"},{"type":"null"}]},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"delta":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentRevisionDelta"},{"type":"null"}]}},"type":"object","title":"EnvironmentRevisionCommit"},"EnvironmentRevisionCommitRequest":{"properties":{"environment_revision":{"$ref":"#/components/schemas/EnvironmentRevisionCommit"}},"type":"object","required":["environment_revision"],"title":"EnvironmentRevisionCommitRequest"},"EnvironmentRevisionCreate":{"properties":{"environment_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"environment_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"EnvironmentRevisionCreate"},"EnvironmentRevisionCreateRequest":{"properties":{"environment_revision":{"$ref":"#/components/schemas/EnvironmentRevisionCreate"}},"type":"object","required":["environment_revision"],"title":"EnvironmentRevisionCreateRequest"},"EnvironmentRevisionData":{"properties":{"references":{"anyOf":[{"additionalProperties":{"additionalProperties":{"$ref":"#/components/schemas/Reference"},"type":"object"},"type":"object"},{"type":"null"}],"title":"References"}},"additionalProperties":false,"type":"object","title":"EnvironmentRevisionData","description":"Per-app references for environment revision data.\n\nKeys are app-scoped identifiers (e.g., ``\"pre.revision\"``).\nValues are dicts of entity-type → Reference, providing full traceability::\n\n {\n \"pre.revision\": {\n \"application\": Reference(id=..., slug=..., version=...),\n \"application_variant\": Reference(id=..., slug=..., version=...),\n \"application_revision\": Reference(id=..., slug=..., version=...),\n },\n ...\n }"},"EnvironmentRevisionDelta":{"properties":{"set":{"anyOf":[{"additionalProperties":{"additionalProperties":{"$ref":"#/components/schemas/Reference"},"type":"object"},"type":"object"},{"type":"null"}],"title":"Set"},"remove":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Remove"}},"type":"object","title":"EnvironmentRevisionDelta","description":"Delta operations on environment revision references.\n\n- ``set``: references to add or update (key → dict of entity → Reference).\n- ``remove``: reference keys to remove."},"EnvironmentRevisionEdit":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"EnvironmentRevisionEdit"},"EnvironmentRevisionEditRequest":{"properties":{"environment_revision":{"$ref":"#/components/schemas/EnvironmentRevisionEdit"}},"type":"object","required":["environment_revision"],"title":"EnvironmentRevisionEditRequest"},"EnvironmentRevisionResolveRequest":{"properties":{"environment_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"environment_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"environment_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"environment_revision":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentRevision-Input"},{"type":"null"}],"description":"Resolve the references embedded in this revision payload directly, without fetching it first. Only `data` is used; id and metadata are ignored."},"max_depth":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Depth","default":10},"max_embeds":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Embeds","default":100},"error_policy":{"anyOf":[{"$ref":"#/components/schemas/ErrorPolicy"},{"type":"null"}],"default":"exception"}},"type":"object","title":"EnvironmentRevisionResolveRequest"},"EnvironmentRevisionResolveResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"environment_revision":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentRevision-Output"},{"type":"null"}]},"resolution_info":{"anyOf":[{"$ref":"#/components/schemas/ResolutionInfo"},{"type":"null"}]},"retrieval_info":{"anyOf":[{"$ref":"#/components/schemas/RetrievalInfo"},{"type":"null"}]}},"type":"object","title":"EnvironmentRevisionResolveResponse"},"EnvironmentRevisionResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"environment_revision":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentRevision-Output"},{"type":"null"}]},"resolution_info":{"anyOf":[{"$ref":"#/components/schemas/ResolutionInfo"},{"type":"null"}]},"retrieval_info":{"anyOf":[{"$ref":"#/components/schemas/RetrievalInfo"},{"type":"null"}]}},"type":"object","title":"EnvironmentRevisionResponse"},"EnvironmentRevisionRetrieveRequest":{"properties":{"environment_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Environment artifact to look up. Identifies the artifact by `id` or `slug` (both project-unique). When no variant_ref or revision_ref is provided, returns the latest revision of an arbitrary variant of this environment."},"environment_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Environment variant to look up. Identifies the variant by `id` or `slug` (both project-unique). When no revision_ref is provided, returns the latest revision of this variant."},"environment_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Environment revision to look up. `id` alone identifies a revision (project-unique). `slug` alone identifies a revision (project-unique). `version` alone is a per-variant sequence number and is **not** sufficient on its own; it must be combined with an `environment_variant_ref`. Sending only `version` without a variant ref returns HTTP 400."},"resolve":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Resolve"}},"type":"object","title":"EnvironmentRevisionRetrieveRequest"},"EnvironmentRevisionsLog":{"properties":{"environment_revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Revision Id"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"},"environment_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"environment_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"depth":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Depth"}},"type":"object","title":"EnvironmentRevisionsLog"},"EnvironmentRevisionsLogRequest":{"properties":{"environment_revisions":{"$ref":"#/components/schemas/EnvironmentRevisionsLog"}},"type":"object","required":["environment_revisions"],"title":"EnvironmentRevisionsLogRequest"},"EnvironmentRevisionsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"environment_revisions":{"items":{"$ref":"#/components/schemas/EnvironmentRevision-Output"},"type":"array","title":"Environment Revisions","default":[]}},"type":"object","title":"EnvironmentRevisionsResponse"},"EnvironmentVariant":{"properties":{"environment_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Environment Slug"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"environment_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"EnvironmentVariant"},"EnvironmentVariantCreate":{"properties":{"environment_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"EnvironmentVariantCreate"},"EnvironmentVariantCreateRequest":{"properties":{"environment_variant":{"$ref":"#/components/schemas/EnvironmentVariantCreate"}},"type":"object","required":["environment_variant"],"title":"EnvironmentVariantCreateRequest"},"EnvironmentVariantEdit":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"EnvironmentVariantEdit"},"EnvironmentVariantEditRequest":{"properties":{"environment_variant":{"$ref":"#/components/schemas/EnvironmentVariantEdit"}},"type":"object","required":["environment_variant"],"title":"EnvironmentVariantEditRequest"},"EnvironmentVariantFork":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"EnvironmentVariantFork"},"EnvironmentVariantForkRequest":{"properties":{"environment_variant":{"$ref":"#/components/schemas/EnvironmentVariantFork","description":"Config for the new variant (slug, name, description, flags)."},"environment_variant_ref":{"$ref":"#/components/schemas/Reference","description":"Source variant to fork from."},"environment_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Pin the fork to this revision; defaults to the source variant's head."}},"type":"object","required":["environment_variant","environment_variant_ref"],"title":"EnvironmentVariantForkRequest"},"EnvironmentVariantResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"environment_variant":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentVariant"},{"type":"null"}]}},"type":"object","title":"EnvironmentVariantResponse"},"EnvironmentVariantsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"environment_variants":{"items":{"$ref":"#/components/schemas/EnvironmentVariant"},"type":"array","title":"Environment Variants","default":[]}},"type":"object","title":"EnvironmentVariantsResponse"},"EnvironmentsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"environments":{"items":{"$ref":"#/components/schemas/Environment"},"type":"array","title":"Environments","default":[]}},"type":"object","title":"EnvironmentsResponse"},"ErrorPolicy":{"type":"string","enum":["exception","placeholder","keep"],"title":"ErrorPolicy"},"EvaluationMetrics":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}],"default":"pending"},"data":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Data"},"interval":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Timestamp"},"scenario_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Scenario Id"},"run_id":{"type":"string","format":"uuid","title":"Run Id"}},"type":"object","required":["run_id"],"title":"EvaluationMetrics"},"EvaluationMetricsCreate":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"version":{"type":"string","title":"Version","default":"2025-07-14"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}]},"data":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Data"},"interval":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Timestamp"},"scenario_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Scenario Id"},"run_id":{"type":"string","format":"uuid","title":"Run Id"}},"type":"object","required":["run_id"],"title":"EvaluationMetricsCreate"},"EvaluationMetricsIdsRequest":{"properties":{"metrics_ids":{"items":{"type":"string","format":"uuid"},"type":"array","title":"Metrics Ids"}},"type":"object","required":["metrics_ids"],"title":"EvaluationMetricsIdsRequest"},"EvaluationMetricsIdsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"metrics_ids":{"items":{"type":"string","format":"uuid"},"type":"array","title":"Metrics Ids","default":[]}},"type":"object","title":"EvaluationMetricsIdsResponse"},"EvaluationMetricsQuery":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}]},"statuses":{"anyOf":[{"items":{"$ref":"#/components/schemas/EvaluationStatus"},"type":"array"},{"type":"null"}],"title":"Statuses"},"interval":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"},"intervals":{"anyOf":[{"items":{"type":"integer"},"type":"array"},{"type":"null"}],"title":"Intervals"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Timestamp"},"timestamps":{"anyOf":[{"items":{"type":"string","format":"date-time"},"type":"array"},{"type":"boolean"},{"type":"null"}],"title":"Timestamps"},"scenario_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Scenario Id"},"scenario_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"boolean"},{"type":"null"}],"title":"Scenario Ids"},"run_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Run Id"},"run_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Run Ids"},"ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Ids"}},"type":"object","title":"EvaluationMetricsQuery"},"EvaluationMetricsQueryRequest":{"properties":{"metrics":{"anyOf":[{"$ref":"#/components/schemas/EvaluationMetricsQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"EvaluationMetricsQueryRequest"},"EvaluationMetricsRefresh":{"properties":{"interval":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Timestamp"},"timestamps":{"anyOf":[{"items":{"type":"string","format":"date-time"},"type":"array"},{"type":"null"}],"title":"Timestamps"},"scenario_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Scenario Id"},"scenario_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Scenario Ids"},"run_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Run Id"},"run_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Run Ids"}},"type":"object","title":"EvaluationMetricsRefresh"},"EvaluationMetricsRefreshRequest":{"properties":{"metrics":{"$ref":"#/components/schemas/EvaluationMetricsRefresh"}},"type":"object","required":["metrics"],"title":"EvaluationMetricsRefreshRequest"},"EvaluationMetricsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"metrics":{"items":{"$ref":"#/components/schemas/EvaluationMetrics"},"type":"array","title":"Metrics","default":[]}},"type":"object","title":"EvaluationMetricsResponse"},"EvaluationMetricsSetRequest":{"properties":{"metrics":{"items":{"$ref":"#/components/schemas/EvaluationMetricsCreate"},"type":"array","title":"Metrics"}},"type":"object","required":["metrics"],"title":"EvaluationMetricsSetRequest"},"EvaluationQueue":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluationQueueFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}],"default":"pending"},"data":{"anyOf":[{"$ref":"#/components/schemas/EvaluationQueueData"},{"type":"null"}]},"run_id":{"type":"string","format":"uuid","title":"Run Id"}},"type":"object","required":["run_id"],"title":"EvaluationQueue"},"EvaluationQueueCreate":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluationQueueFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"version":{"type":"string","title":"Version","default":"2025-07-14"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}],"default":"pending"},"data":{"anyOf":[{"$ref":"#/components/schemas/EvaluationQueueData"},{"type":"null"}]},"run_id":{"type":"string","format":"uuid","title":"Run Id"}},"type":"object","required":["run_id"],"title":"EvaluationQueueCreate"},"EvaluationQueueData":{"properties":{"user_ids":{"anyOf":[{"items":{"items":{"type":"string","format":"uuid"},"type":"array"},"type":"array"},{"type":"null"}],"title":"User Ids"},"scenario_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Scenario Ids"},"step_keys":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Step Keys"},"batch_size":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Batch Size"},"batch_offset":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Batch Offset"}},"type":"object","title":"EvaluationQueueData"},"EvaluationQueueEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluationQueueFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"version":{"type":"string","title":"Version","default":"2025-07-14"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}]},"data":{"anyOf":[{"$ref":"#/components/schemas/EvaluationQueueData"},{"type":"null"}]}},"type":"object","title":"EvaluationQueueEdit"},"EvaluationQueueEditRequest":{"properties":{"queue":{"$ref":"#/components/schemas/EvaluationQueueEdit"}},"type":"object","required":["queue"],"title":"EvaluationQueueEditRequest"},"EvaluationQueueFlags":{"properties":{"is_sequential":{"type":"boolean","title":"Is Sequential","default":false},"is_default":{"type":"boolean","title":"Is Default","default":false}},"type":"object","title":"EvaluationQueueFlags"},"EvaluationQueueIdResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"queue_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Queue Id"}},"type":"object","title":"EvaluationQueueIdResponse"},"EvaluationQueueIdsRequest":{"properties":{"queue_ids":{"items":{"type":"string","format":"uuid"},"type":"array","title":"Queue Ids"}},"type":"object","required":["queue_ids"],"title":"EvaluationQueueIdsRequest"},"EvaluationQueueIdsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"queue_ids":{"items":{"type":"string","format":"uuid"},"type":"array","title":"Queue Ids","default":[]}},"type":"object","title":"EvaluationQueueIdsResponse"},"EvaluationQueueQuery":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluationQueueQueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"},"user_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"User Id"},"user_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"User Ids"},"run_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Run Id"},"run_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Run Ids"},"ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Ids"}},"type":"object","title":"EvaluationQueueQuery"},"EvaluationQueueQueryFlags":{"properties":{"is_sequential":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Sequential"},"is_default":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Default"}},"type":"object","title":"EvaluationQueueQueryFlags"},"EvaluationQueueQueryRequest":{"properties":{"queue":{"anyOf":[{"$ref":"#/components/schemas/EvaluationQueueQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"EvaluationQueueQueryRequest"},"EvaluationQueueResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"queue":{"anyOf":[{"$ref":"#/components/schemas/EvaluationQueue"},{"type":"null"}]}},"type":"object","title":"EvaluationQueueResponse"},"EvaluationQueueScenariosQuery":{"properties":{"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"user_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"User Id"},"user_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"User Ids"}},"type":"object","title":"EvaluationQueueScenariosQuery"},"EvaluationQueueScenariosQueryRequest":{"properties":{"queue":{"anyOf":[{"$ref":"#/components/schemas/EvaluationQueueScenariosQuery"},{"type":"null"}]},"scenario":{"anyOf":[{"$ref":"#/components/schemas/EvaluationScenarioQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"EvaluationQueueScenariosQueryRequest"},"EvaluationQueuesCreateRequest":{"properties":{"queues":{"items":{"$ref":"#/components/schemas/EvaluationQueueCreate"},"type":"array","title":"Queues"}},"type":"object","required":["queues"],"title":"EvaluationQueuesCreateRequest"},"EvaluationQueuesEditRequest":{"properties":{"queues":{"items":{"$ref":"#/components/schemas/EvaluationQueueEdit"},"type":"array","title":"Queues"}},"type":"object","required":["queues"],"title":"EvaluationQueuesEditRequest"},"EvaluationQueuesResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"queues":{"items":{"$ref":"#/components/schemas/EvaluationQueue"},"type":"array","title":"Queues","default":[]}},"type":"object","title":"EvaluationQueuesResponse"},"EvaluationResult":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"hash_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Hash Id"},"trace_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Trace Id"},"testcase_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testcase Id"},"error":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Error"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}],"default":"pending"},"interval":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Timestamp"},"repeat_idx":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Repeat Idx","default":0},"step_key":{"type":"string","title":"Step Key"},"scenario_id":{"type":"string","format":"uuid","title":"Scenario Id"},"run_id":{"type":"string","format":"uuid","title":"Run Id"}},"type":"object","required":["step_key","scenario_id","run_id"],"title":"EvaluationResult"},"EvaluationResultCreate":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"version":{"type":"string","title":"Version","default":"2025-07-14"},"hash_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Hash Id"},"trace_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Trace Id"},"testcase_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testcase Id"},"error":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Error"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}]},"interval":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Timestamp"},"repeat_idx":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Repeat Idx","default":0},"step_key":{"type":"string","title":"Step Key"},"scenario_id":{"type":"string","format":"uuid","title":"Scenario Id"},"run_id":{"type":"string","format":"uuid","title":"Run Id"}},"type":"object","required":["step_key","scenario_id","run_id"],"title":"EvaluationResultCreate"},"EvaluationResultIdResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"result_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Result Id"}},"type":"object","title":"EvaluationResultIdResponse"},"EvaluationResultIdsRequest":{"properties":{"result_ids":{"items":{"type":"string","format":"uuid"},"type":"array","title":"Result Ids"}},"type":"object","required":["result_ids"],"title":"EvaluationResultIdsRequest"},"EvaluationResultIdsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"result_ids":{"items":{"type":"string","format":"uuid"},"type":"array","title":"Result Ids","default":[]}},"type":"object","title":"EvaluationResultIdsResponse"},"EvaluationResultQuery":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}]},"statuses":{"anyOf":[{"items":{"$ref":"#/components/schemas/EvaluationStatus"},"type":"array"},{"type":"null"}],"title":"Statuses"},"interval":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"},"intervals":{"anyOf":[{"items":{"type":"integer"},"type":"array"},{"type":"null"}],"title":"Intervals"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Timestamp"},"timestamps":{"anyOf":[{"items":{"type":"string","format":"date-time"},"type":"array"},{"type":"null"}],"title":"Timestamps"},"repeat_idx":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Repeat Idx"},"repeat_idxs":{"anyOf":[{"items":{"type":"integer"},"type":"array"},{"type":"null"}],"title":"Repeat Idxs"},"step_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Step Key"},"step_keys":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Step Keys"},"scenario_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Scenario Id"},"scenario_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Scenario Ids"},"run_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Run Id"},"run_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Run Ids"},"ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Ids"}},"type":"object","title":"EvaluationResultQuery"},"EvaluationResultQueryRequest":{"properties":{"result":{"anyOf":[{"$ref":"#/components/schemas/EvaluationResultQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"EvaluationResultQueryRequest"},"EvaluationResultResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"result":{"anyOf":[{"$ref":"#/components/schemas/EvaluationResult"},{"type":"null"}]}},"type":"object","title":"EvaluationResultResponse"},"EvaluationResultsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"results":{"items":{"$ref":"#/components/schemas/EvaluationResult"},"type":"array","title":"Results","default":[]}},"type":"object","title":"EvaluationResultsResponse"},"EvaluationResultsSetRequest":{"properties":{"results":{"items":{"$ref":"#/components/schemas/EvaluationResultCreate"},"type":"array","title":"Results"}},"type":"object","required":["results"],"title":"EvaluationResultsSetRequest"},"EvaluationRun":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}],"default":"pending"},"data":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunData-Output"},{"type":"null"}]}},"type":"object","title":"EvaluationRun"},"EvaluationRunCreate":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"version":{"type":"string","title":"Version","default":"2025-07-14"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}]},"data":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunData-Input"},{"type":"null"}]}},"type":"object","title":"EvaluationRunCreate"},"EvaluationRunData-Input":{"properties":{"steps":{"anyOf":[{"items":{"$ref":"#/components/schemas/EvaluationRunDataStep-Input"},"type":"array"},{"type":"null"}],"title":"Steps"},"repeats":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Repeats","default":1},"concurrency":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunDataConcurrency"},{"type":"null"}]},"mappings":{"anyOf":[{"items":{"$ref":"#/components/schemas/EvaluationRunDataMapping"},"type":"array"},{"type":"null"}],"title":"Mappings"}},"type":"object","title":"EvaluationRunData"},"EvaluationRunData-Output":{"properties":{"steps":{"anyOf":[{"items":{"$ref":"#/components/schemas/EvaluationRunDataStep-Output"},"type":"array"},{"type":"null"}],"title":"Steps"},"repeats":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Repeats","default":1},"concurrency":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunDataConcurrency"},{"type":"null"}]},"mappings":{"anyOf":[{"items":{"$ref":"#/components/schemas/EvaluationRunDataMapping"},"type":"array"},{"type":"null"}],"title":"Mappings"}},"type":"object","title":"EvaluationRunData"},"EvaluationRunDataConcurrency":{"properties":{"batch_size":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Batch Size"},"max_retries":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Retries"},"retry_delay":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Retry Delay"}},"type":"object","title":"EvaluationRunDataConcurrency"},"EvaluationRunDataMapping":{"properties":{"column":{"$ref":"#/components/schemas/EvaluationRunDataMappingColumn"},"step":{"$ref":"#/components/schemas/EvaluationRunDataMappingStep"}},"type":"object","required":["column","step"],"title":"EvaluationRunDataMapping"},"EvaluationRunDataMappingColumn":{"properties":{"kind":{"type":"string","title":"Kind"},"name":{"type":"string","title":"Name"}},"type":"object","required":["kind","name"],"title":"EvaluationRunDataMappingColumn"},"EvaluationRunDataMappingStep":{"properties":{"key":{"type":"string","title":"Key"},"path":{"type":"string","title":"Path"}},"type":"object","required":["key","path"],"title":"EvaluationRunDataMappingStep"},"EvaluationRunDataStep-Input":{"properties":{"key":{"type":"string","title":"Key"},"type":{"type":"string","enum":["input","invocation","annotation"],"title":"Type"},"origin":{"type":"string","enum":["custom","human","auto"],"title":"Origin"},"references":{"additionalProperties":{"$ref":"#/components/schemas/Reference"},"type":"object","title":"References"},"inputs":{"anyOf":[{"items":{"$ref":"#/components/schemas/EvaluationRunDataStepInputKey"},"type":"array"},{"type":"null"}],"title":"Inputs"},"schemas":{"anyOf":[{"$ref":"#/components/schemas/JsonSchemas-Input"},{"type":"null"}]}},"type":"object","required":["key","type","origin","references"],"title":"EvaluationRunDataStep"},"EvaluationRunDataStep-Output":{"properties":{"key":{"type":"string","title":"Key"},"type":{"type":"string","enum":["input","invocation","annotation"],"title":"Type"},"origin":{"type":"string","enum":["custom","human","auto"],"title":"Origin"},"references":{"additionalProperties":{"$ref":"#/components/schemas/Reference"},"type":"object","title":"References"},"inputs":{"anyOf":[{"items":{"$ref":"#/components/schemas/EvaluationRunDataStepInputKey"},"type":"array"},{"type":"null"}],"title":"Inputs"},"schemas":{"anyOf":[{"$ref":"#/components/schemas/JsonSchemas-Output"},{"type":"null"}]}},"type":"object","required":["key","type","origin","references"],"title":"EvaluationRunDataStep"},"EvaluationRunDataStepInputKey":{"properties":{"key":{"type":"string","title":"Key"}},"type":"object","required":["key"],"title":"EvaluationRunDataStepInputKey"},"EvaluationRunEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"version":{"type":"string","title":"Version","default":"2025-07-14"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}]},"data":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunData-Input"},{"type":"null"}]}},"type":"object","title":"EvaluationRunEdit"},"EvaluationRunEditRequest":{"properties":{"run":{"$ref":"#/components/schemas/EvaluationRunEdit"}},"type":"object","required":["run"],"title":"EvaluationRunEditRequest"},"EvaluationRunFlags":{"properties":{"is_live":{"type":"boolean","title":"Is Live","default":false},"is_active":{"type":"boolean","title":"Is Active","default":false},"is_closed":{"type":"boolean","title":"Is Closed","default":false},"is_queue":{"type":"boolean","title":"Is Queue","default":false},"is_cached":{"type":"boolean","title":"Is Cached","default":false},"is_split":{"type":"boolean","title":"Is Split","default":false},"has_queries":{"type":"boolean","title":"Has Queries","default":false},"has_testsets":{"type":"boolean","title":"Has Testsets","default":false},"has_traces":{"type":"boolean","title":"Has Traces","default":false},"has_testcases":{"type":"boolean","title":"Has Testcases","default":false},"has_evaluators":{"type":"boolean","title":"Has Evaluators","default":false},"has_custom":{"type":"boolean","title":"Has Custom","default":false},"has_human":{"type":"boolean","title":"Has Human","default":false},"has_auto":{"type":"boolean","title":"Has Auto","default":false}},"type":"object","title":"EvaluationRunFlags"},"EvaluationRunIdResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"run_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Run Id"}},"type":"object","title":"EvaluationRunIdResponse"},"EvaluationRunIdsRequest":{"properties":{"run_ids":{"items":{"type":"string","format":"uuid"},"type":"array","title":"Run Ids"}},"type":"object","required":["run_ids"],"title":"EvaluationRunIdsRequest"},"EvaluationRunIdsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"run_ids":{"items":{"type":"string","format":"uuid"},"type":"array","title":"Run Ids","default":[]}},"type":"object","title":"EvaluationRunIdsResponse"},"EvaluationRunQuery":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunQueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}]},"statuses":{"anyOf":[{"items":{"$ref":"#/components/schemas/EvaluationStatus"},"type":"array"},{"type":"null"}],"title":"Statuses"},"references":{"anyOf":[{"items":{"additionalProperties":{"$ref":"#/components/schemas/Reference"},"type":"object"},"type":"array"},{"type":"null"}],"title":"References"},"ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Ids"}},"type":"object","title":"EvaluationRunQuery"},"EvaluationRunQueryFlags":{"properties":{"is_live":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Live"},"is_active":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Active"},"is_closed":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Closed"},"is_queue":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Queue"},"is_cached":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Cached"},"is_split":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Split"},"has_queries":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Queries"},"has_testsets":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Testsets"},"has_traces":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Traces"},"has_testcases":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Testcases"},"has_evaluators":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Evaluators"},"has_custom":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Custom"},"has_human":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Human"},"has_auto":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Auto"}},"type":"object","title":"EvaluationRunQueryFlags"},"EvaluationRunQueryRequest":{"properties":{"run":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"EvaluationRunQueryRequest"},"EvaluationRunResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"run":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRun"},{"type":"null"}]}},"type":"object","title":"EvaluationRunResponse"},"EvaluationRunsCreateRequest":{"properties":{"runs":{"items":{"$ref":"#/components/schemas/EvaluationRunCreate"},"type":"array","title":"Runs"}},"type":"object","required":["runs"],"title":"EvaluationRunsCreateRequest"},"EvaluationRunsEditRequest":{"properties":{"runs":{"items":{"$ref":"#/components/schemas/EvaluationRunEdit"},"type":"array","title":"Runs"}},"type":"object","required":["runs"],"title":"EvaluationRunsEditRequest"},"EvaluationRunsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"runs":{"items":{"$ref":"#/components/schemas/EvaluationRun"},"type":"array","title":"Runs","default":[]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"EvaluationRunsResponse"},"EvaluationScenario":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}],"default":"pending"},"interval":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Timestamp"},"run_id":{"type":"string","format":"uuid","title":"Run Id"}},"type":"object","required":["run_id"],"title":"EvaluationScenario"},"EvaluationScenarioCreate":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"version":{"type":"string","title":"Version","default":"2025-07-14"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}]},"interval":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Timestamp"},"run_id":{"type":"string","format":"uuid","title":"Run Id"}},"type":"object","required":["run_id"],"title":"EvaluationScenarioCreate"},"EvaluationScenarioEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"version":{"type":"string","title":"Version","default":"2025-07-14"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}]},"interval":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Timestamp"}},"type":"object","title":"EvaluationScenarioEdit"},"EvaluationScenarioEditRequest":{"properties":{"scenario":{"$ref":"#/components/schemas/EvaluationScenarioEdit"}},"type":"object","required":["scenario"],"title":"EvaluationScenarioEditRequest"},"EvaluationScenarioIdResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"scenario_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Scenario Id"}},"type":"object","title":"EvaluationScenarioIdResponse"},"EvaluationScenarioIdsRequest":{"properties":{"scenario_ids":{"items":{"type":"string","format":"uuid"},"type":"array","title":"Scenario Ids"}},"type":"object","required":["scenario_ids"],"title":"EvaluationScenarioIdsRequest"},"EvaluationScenarioIdsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"scenario_ids":{"items":{"type":"string","format":"uuid"},"type":"array","title":"Scenario Ids","default":[]}},"type":"object","title":"EvaluationScenarioIdsResponse"},"EvaluationScenarioQuery":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}]},"statuses":{"anyOf":[{"items":{"$ref":"#/components/schemas/EvaluationStatus"},"type":"array"},{"type":"null"}],"title":"Statuses"},"interval":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"},"intervals":{"anyOf":[{"items":{"type":"integer"},"type":"array"},{"type":"null"}],"title":"Intervals"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Timestamp"},"timestamps":{"anyOf":[{"items":{"type":"string","format":"date-time"},"type":"array"},{"type":"null"}],"title":"Timestamps"},"run_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Run Id"},"run_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Run Ids"},"ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Ids"}},"type":"object","title":"EvaluationScenarioQuery"},"EvaluationScenarioQueryRequest":{"properties":{"scenario":{"anyOf":[{"$ref":"#/components/schemas/EvaluationScenarioQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"EvaluationScenarioQueryRequest"},"EvaluationScenarioResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"scenario":{"anyOf":[{"$ref":"#/components/schemas/EvaluationScenario"},{"type":"null"}]}},"type":"object","title":"EvaluationScenarioResponse"},"EvaluationScenariosCreateRequest":{"properties":{"scenarios":{"items":{"$ref":"#/components/schemas/EvaluationScenarioCreate"},"type":"array","title":"Scenarios"}},"type":"object","required":["scenarios"],"title":"EvaluationScenariosCreateRequest"},"EvaluationScenariosEditRequest":{"properties":{"scenarios":{"items":{"$ref":"#/components/schemas/EvaluationScenarioEdit"},"type":"array","title":"Scenarios"}},"type":"object","required":["scenarios"],"title":"EvaluationScenariosEditRequest"},"EvaluationScenariosResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"scenarios":{"items":{"$ref":"#/components/schemas/EvaluationScenario"},"type":"array","title":"Scenarios","default":[]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"EvaluationScenariosResponse"},"EvaluationStatus":{"type":"string","enum":["pending","queued","running","success","failure","errors","cancelled"],"title":"EvaluationStatus"},"Evaluator":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorArtifactFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"Evaluator"},"EvaluatorArtifactFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false}},"type":"object","title":"EvaluatorArtifactFlags"},"EvaluatorArtifactQueryFlags":{"properties":{"is_application":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Application"},"is_evaluator":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Evaluator"},"is_snippet":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Snippet"}},"type":"object","title":"EvaluatorArtifactQueryFlags"},"EvaluatorCatalogPreset":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"key":{"type":"string","title":"Key"},"categories":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Categories"},"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowCatalogFlags"},{"type":"null"}]},"data":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevisionData-Output"},{"type":"null"}]}},"type":"object","required":["key"],"title":"EvaluatorCatalogPreset"},"EvaluatorCatalogPresetResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"1 when a preset is returned, 0 otherwise.","default":0},"preset":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorCatalogPreset"},{"type":"null"}],"description":"The catalog preset, or null when none matched."}},"type":"object","title":"EvaluatorCatalogPresetResponse","description":"Envelope for a single catalog preset."},"EvaluatorCatalogPresetsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of presets in `presets`.","default":0},"presets":{"items":{"$ref":"#/components/schemas/EvaluatorCatalogPreset"},"type":"array","title":"Presets","description":"Named parameter presets defined against a template."}},"type":"object","title":"EvaluatorCatalogPresetsResponse","description":"Envelope for a list of catalog presets."},"EvaluatorCatalogTemplate":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"key":{"type":"string","title":"Key"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"categories":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Categories"},"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowCatalogFlags"},{"type":"null"}]},"data":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevisionData-Output"},{"type":"null"}]}},"type":"object","required":["key"],"title":"EvaluatorCatalogTemplate"},"EvaluatorCatalogTemplateResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"1 when a template is returned, 0 otherwise.","default":0},"template":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorCatalogTemplate"},{"type":"null"}],"description":"The catalog template, or null when none matched."}},"type":"object","title":"EvaluatorCatalogTemplateResponse","description":"Envelope for a single catalog template."},"EvaluatorCatalogTemplatesResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of templates in `templates`.","default":0},"templates":{"items":{"$ref":"#/components/schemas/EvaluatorCatalogTemplate"},"type":"array","title":"Templates","description":"Evaluator catalog templates (blueprints for creating evaluators)."}},"type":"object","title":"EvaluatorCatalogTemplatesResponse","description":"Envelope for a list of catalog templates."},"EvaluatorCatalogType":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"key":{"type":"string","title":"Key"},"json_schema":{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object","title":"Json Schema"}},"type":"object","required":["key","json_schema"],"title":"EvaluatorCatalogType"},"EvaluatorCatalogTypesResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of types in `types`.","default":0},"types":{"items":{"$ref":"#/components/schemas/EvaluatorCatalogType"},"type":"array","title":"Types","description":"JSON schema types the evaluator catalog understands."}},"type":"object","title":"EvaluatorCatalogTypesResponse","description":"Envelope for a list of catalog types."},"EvaluatorCreate":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"EvaluatorCreate"},"EvaluatorCreateRequest":{"properties":{"evaluator":{"$ref":"#/components/schemas/EvaluatorCreate","description":"Evaluator payload (slug, name, flags, data). Slug is required and scoped to the project."}},"type":"object","required":["evaluator"],"title":"EvaluatorCreateRequest","description":"Body for creating an evaluator artifact.\n\nCreating an evaluator also provisions its first variant and its initial\nrevision. The evaluator shares the artifact / variant / revision model\nused across versioned resources — see the Versioning guide."},"EvaluatorEdit":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"EvaluatorEdit"},"EvaluatorEditRequest":{"properties":{"evaluator":{"$ref":"#/components/schemas/EvaluatorEdit","description":"Evaluator edit payload. Requires the evaluator `id`. Renaming is temporarily disabled."}},"type":"object","required":["evaluator"],"title":"EvaluatorEditRequest","description":"Body for editing the metadata of an existing evaluator artifact."},"EvaluatorFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false},"is_managed":{"type":"boolean","title":"Is Managed","default":false},"is_custom":{"type":"boolean","title":"Is Custom","default":false},"is_llm":{"type":"boolean","title":"Is Llm","default":false},"is_hook":{"type":"boolean","title":"Is Hook","default":false},"is_code":{"type":"boolean","title":"Is Code","default":false},"is_match":{"type":"boolean","title":"Is Match","default":false},"is_feedback":{"type":"boolean","title":"Is Feedback","default":false},"is_agent":{"type":"boolean","title":"Is Agent","default":false},"is_skill":{"type":"boolean","title":"Is Skill","default":false},"is_chat":{"type":"boolean","title":"Is Chat","default":false},"has_url":{"type":"boolean","title":"Has Url","default":false},"has_script":{"type":"boolean","title":"Has Script","default":false},"has_handler":{"type":"boolean","title":"Has Handler","default":false},"is_static":{"type":"boolean","title":"Is Static","default":false}},"type":"object","title":"EvaluatorFlags","description":"Legacy full evaluator flag set."},"EvaluatorQuery":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorArtifactQueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"slugs":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Slugs"}},"type":"object","title":"EvaluatorQuery"},"EvaluatorQueryRequest":{"properties":{"evaluator":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorQuery"},{"type":"null"}],"description":"Filter on evaluator attributes (flags, tags, meta)."},"evaluator_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Evaluator Refs","description":"Restrict the query to these evaluators. Accepts `id` or `slug` per reference."},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived","description":"When true, include soft-deleted evaluators in the response."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor-based pagination controls (limit, order, next, newest, oldest)."}},"type":"object","title":"EvaluatorQueryRequest","description":"Body for filtering evaluators. See the Query Pattern guide for field semantics."},"EvaluatorResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"1 when an evaluator is returned, 0 otherwise.","default":0},"evaluator":{"anyOf":[{"$ref":"#/components/schemas/Evaluator"},{"type":"null"}],"description":"The evaluator artifact, or null when none matched."}},"type":"object","title":"EvaluatorResponse","description":"Envelope for a single evaluator response."},"EvaluatorRevision-Input":{"properties":{"evaluator_variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Evaluator Variant Slug"},"workflow_variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Variant Slug"},"evaluator_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Evaluator Slug"},"workflow_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Slug"},"evaluator_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Variant Id"},"workflow_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"},"evaluator_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Variant Slug"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"author":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Author"},"date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Date"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorRevisionFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorRevisionData-Input"},{"type":"null"}]}},"type":"object","title":"EvaluatorRevision"},"EvaluatorRevision-Output":{"properties":{"evaluator_variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Evaluator Variant Slug"},"workflow_variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Variant Slug"},"evaluator_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Evaluator Slug"},"workflow_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Slug"},"evaluator_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Variant Id"},"workflow_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"},"evaluator_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Variant Slug"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"author":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Author"},"date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Date"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorRevisionFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorRevisionData-Output"},{"type":"null"}]}},"type":"object","title":"EvaluatorRevision"},"EvaluatorRevisionCommit":{"properties":{"evaluator_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Variant Id"},"workflow_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"},"evaluator_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"data":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorRevisionData-Input"},{"type":"null"}]},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"},"delta":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevisionDelta"},{"type":"null"}]},"base_revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Base Revision Id","description":"The revision this change was built on. Omit it to keep today's last-write-wins behavior. Send it on a legacy delta and the commit is refused with `409` when the variant's head has moved since: sending the field is how a caller asks for that check. An ordered delta requires it."}},"type":"object","title":"EvaluatorRevisionCommit"},"EvaluatorRevisionCommitRequest":{"properties":{"evaluator_revision":{"$ref":"#/components/schemas/EvaluatorRevisionCommit","description":"Commit payload carrying the `evaluator_variant_id`, optional commit `message`, and the revision `data`."}},"type":"object","required":["evaluator_revision"],"title":"EvaluatorRevisionCommitRequest","description":"Body for committing a new revision on a variant."},"EvaluatorRevisionCreate":{"properties":{"evaluator_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Variant Id"},"workflow_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"},"evaluator_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"EvaluatorRevisionCreate"},"EvaluatorRevisionCreateRequest":{"properties":{"evaluator_revision":{"$ref":"#/components/schemas/EvaluatorRevisionCreate","description":"Revision payload. Requires the parent `evaluator_variant_id` and a `data` object."}},"type":"object","required":["evaluator_revision"],"title":"EvaluatorRevisionCreateRequest","description":"Body for creating a new revision (commit) on an evaluator variant."},"EvaluatorRevisionData-Input":{"properties":{"uri":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Uri"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"headers":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/Reference"}]},"type":"object"},{"type":"null"}],"title":"Headers"},"runtime":{"anyOf":[{"type":"string","enum":["python","typescript","javascript"]},{"type":"null"}],"title":"Runtime"},"script":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Script"},"schemas":{"anyOf":[{"$ref":"#/components/schemas/JsonSchemas-Input"},{"type":"null"}]},"parameters":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Parameters"}},"additionalProperties":false,"type":"object","title":"EvaluatorRevisionData"},"EvaluatorRevisionData-Output":{"properties":{"uri":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Uri"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"headers":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/Reference"}]},"type":"object"},{"type":"null"}],"title":"Headers"},"runtime":{"anyOf":[{"type":"string","enum":["python","typescript","javascript"]},{"type":"null"}],"title":"Runtime"},"script":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Script"},"schemas":{"anyOf":[{"$ref":"#/components/schemas/JsonSchemas-Output"},{"type":"null"}]},"parameters":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Parameters"}},"additionalProperties":false,"type":"object","title":"EvaluatorRevisionData"},"EvaluatorRevisionDeployRequest":{"properties":{"evaluator_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Evaluator to deploy (latest revision)."},"evaluator_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Variant to deploy (latest revision on this variant)."},"evaluator_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Specific revision to deploy."},"environment_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Target environment."},"environment_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Target environment variant."},"environment_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Target environment revision."},"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key","description":"Named key under which the revision is pinned. Defaults to `.revision`."},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message","description":"Commit message stored on the environment revision that records the deployment."}},"type":"object","title":"EvaluatorRevisionDeployRequest","description":"Body for pinning an evaluator revision into an environment revision under a key."},"EvaluatorRevisionEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"EvaluatorRevisionEdit"},"EvaluatorRevisionEditRequest":{"properties":{"evaluator_revision":{"$ref":"#/components/schemas/EvaluatorRevisionEdit","description":"Revision edit payload. Requires the revision `id`."}},"type":"object","required":["evaluator_revision"],"title":"EvaluatorRevisionEditRequest","description":"Body for editing a revision's mutable fields (currently limited; payload data is immutable)."},"EvaluatorRevisionFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false},"is_managed":{"type":"boolean","title":"Is Managed","default":false},"is_custom":{"type":"boolean","title":"Is Custom","default":false},"is_llm":{"type":"boolean","title":"Is Llm","default":false},"is_hook":{"type":"boolean","title":"Is Hook","default":false},"is_code":{"type":"boolean","title":"Is Code","default":false},"is_match":{"type":"boolean","title":"Is Match","default":false},"is_feedback":{"type":"boolean","title":"Is Feedback","default":false},"is_agent":{"type":"boolean","title":"Is Agent","default":false},"is_skill":{"type":"boolean","title":"Is Skill","default":false},"is_chat":{"type":"boolean","title":"Is Chat","default":false},"has_url":{"type":"boolean","title":"Has Url","default":false},"has_script":{"type":"boolean","title":"Has Script","default":false},"has_handler":{"type":"boolean","title":"Has Handler","default":false},"is_static":{"type":"boolean","title":"Is Static","default":false}},"type":"object","title":"EvaluatorRevisionFlags"},"EvaluatorRevisionQuery":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorRevisionQueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"slugs":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Slugs"},"author":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Author"},"authors":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Authors"},"date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Date"},"dates":{"anyOf":[{"items":{"type":"string","format":"date-time"},"type":"array"},{"type":"null"}],"title":"Dates"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"}},"type":"object","title":"EvaluatorRevisionQuery"},"EvaluatorRevisionQueryFlags":{"properties":{"is_application":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Application"},"is_evaluator":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Evaluator"},"is_snippet":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Snippet"},"is_managed":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Managed"},"is_custom":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Custom"},"is_llm":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Llm"},"is_hook":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Hook"},"is_code":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Code"},"is_match":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Match"},"is_feedback":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Feedback"},"is_agent":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Agent"},"is_skill":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Skill"},"is_chat":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Chat"},"has_url":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Url"},"has_script":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Script"},"has_handler":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Handler"},"is_static":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Static"}},"type":"object","title":"EvaluatorRevisionQueryFlags"},"EvaluatorRevisionQueryRequest":{"properties":{"evaluator_revision":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorRevisionQuery"},{"type":"null"}],"description":"Filter on revision attributes."},"evaluator_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Evaluator Refs","description":"Restrict to revisions under these evaluators."},"evaluator_variant_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Evaluator Variant Refs","description":"Restrict to revisions under these variants."},"evaluator_revision_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Evaluator Revision Refs","description":"Restrict to these specific revisions."},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived","description":"When true, include soft-deleted revisions."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor-based pagination controls."}},"type":"object","title":"EvaluatorRevisionQueryRequest","description":"Body for filtering evaluator revisions. Supports scoping to evaluators, variants, or specific revisions."},"EvaluatorRevisionResolveRequest":{"properties":{"evaluator_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Resolve the latest revision of this evaluator."},"evaluator_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Resolve the latest revision on this variant."},"evaluator_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Resolve this specific revision."},"evaluator_revision":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorRevision-Input"},{"type":"null"}],"description":"Resolve the references embedded in this revision payload directly, without fetching it first. Only `data` is used; id and metadata are ignored."},"max_depth":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Depth","description":"Maximum recursion depth when following embedded references. Defaults to 10.","default":10},"max_embeds":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Embeds","description":"Maximum number of embeds to resolve. Defaults to 100.","default":100},"error_policy":{"anyOf":[{"$ref":"#/components/schemas/ErrorPolicy"},{"type":"null"}],"description":"How to handle embed-resolution errors (`exception` or `fallback`).","default":"exception"}},"type":"object","title":"EvaluatorRevisionResolveRequest","description":"Body for resolving embedded references on an evaluator revision's `data`."},"EvaluatorRevisionResolveResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"1 when a revision was resolved, 0 otherwise.","default":0},"evaluator_revision":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorRevision-Output"},{"type":"null"}],"description":"The resolved revision."},"resolution_info":{"anyOf":[{"$ref":"#/components/schemas/ResolutionInfo"},{"type":"null"}],"description":"Diagnostic information about the resolution pass (depth, embed count, errors)."},"retrieval_info":{"anyOf":[{"$ref":"#/components/schemas/RetrievalInfo"},{"type":"null"}],"description":"References (artifact / variant / revision) actually used to retrieve this revision."}},"type":"object","title":"EvaluatorRevisionResolveResponse","description":"Envelope for a resolved evaluator revision."},"EvaluatorRevisionResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"1 when a revision is returned, 0 otherwise.","default":0},"evaluator_revision":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorRevision-Output"},{"type":"null"}],"description":"The evaluator revision, or null when none matched."},"resolution_info":{"anyOf":[{"$ref":"#/components/schemas/ResolutionInfo"},{"type":"null"}],"description":"Embed-resolution metadata. Populated when `resolve=true` was requested."},"retrieval_info":{"anyOf":[{"$ref":"#/components/schemas/RetrievalInfo"},{"type":"null"}],"description":"References used to retrieve the top-level revision."}},"type":"object","title":"EvaluatorRevisionResponse","description":"Envelope for a single evaluator revision."},"EvaluatorRevisionRetrieveRequest":{"properties":{"evaluator_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Evaluator artifact to look up. Identifies the artifact by `id` or `slug` (both project-unique). When no variant_ref or revision_ref is provided, returns the latest revision of the evaluator's default variant."},"evaluator_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Evaluator variant to look up. Identifies the variant by `id` or `slug` (both project-unique). When no revision_ref is provided, returns the latest revision of this variant."},"evaluator_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Evaluator revision to look up. `id` alone identifies a revision (project-unique). `slug` alone identifies a revision (project-unique). `version` alone is a per-variant sequence number and is **not** sufficient on its own; it must be combined with an `evaluator_variant_ref`. Sending only `version` without a variant ref returns HTTP 400."},"environment_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Environment to resolve through. Requires `key`."},"environment_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Environment variant to resolve through. Requires `key`."},"environment_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Specific environment revision to resolve through. Requires `key`."},"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key","description":"Named deployment key inside the environment revision. Required with environment refs."},"resolve":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Resolve","description":"When true, resolve embedded references on the returned revision's `data`."}},"type":"object","title":"EvaluatorRevisionRetrieveRequest","description":"Body for retrieving one revision, either by direct reference or through an environment key.\n\nProvide an evaluator / variant / revision reference, an environment\nreference (with `key` derived from the evaluator slug by default), or a\ncombination of both. Every reference supplied must agree with the\nresolved revision; contradictions return HTTP 400."},"EvaluatorRevisionsLog":{"properties":{"evaluator_revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Revision Id"},"workflow_revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Revision Id"},"evaluator_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Variant Id"},"workflow_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"},"evaluator_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"depth":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Depth"}},"type":"object","title":"EvaluatorRevisionsLog"},"EvaluatorRevisionsLogRequest":{"properties":{"evaluator_revisions":{"$ref":"#/components/schemas/EvaluatorRevisionsLog","description":"Log request scoped to an evaluator / variant / revision by id, slug, or version."}},"type":"object","required":["evaluator_revisions"],"title":"EvaluatorRevisionsLogRequest","description":"Body for listing the revision log of an evaluator variant."},"EvaluatorRevisionsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of revisions in `evaluator_revisions`.","default":0},"evaluator_revisions":{"items":{"$ref":"#/components/schemas/EvaluatorRevision-Output"},"type":"array","title":"Evaluator Revisions","description":"Matching evaluator revisions."}},"type":"object","title":"EvaluatorRevisionsResponse","description":"Envelope for a list of evaluator revisions."},"EvaluatorTemplate":{"properties":{"name":{"type":"string","title":"Name","description":"Human-readable template name."},"key":{"type":"string","title":"Key","description":"Stable template identifier, used to create evaluators from the template."},"direct_use":{"type":"boolean","title":"Direct Use","description":"Whether the template can be used without further configuration."},"settings_presets":{"anyOf":[{"items":{"additionalProperties":true,"type":"object"},"type":"array"},{"type":"null"}],"title":"Settings Presets","description":"Preset parameter configurations shipped with the template."},"settings_template":{"additionalProperties":true,"type":"object","title":"Settings Template","description":"JSON Schema describing the template's configurable parameters."},"outputs_schema":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Outputs Schema","description":"JSON Schema describing the template's evaluator output shape."},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","description":"Template description."},"oss":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Oss","description":"True when the template is available in OSS builds.","default":false},"requires_llm_api_keys":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Requires Llm Api Keys","description":"True when the template calls an LLM provider and requires an API key.","default":false},"tags":{"items":{"type":"string"},"type":"array","title":"Tags","description":"Tags for grouping templates."},"archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Archived","description":"True when the template is deprecated. Hidden unless `include_archived=true`.","default":false}},"type":"object","required":["name","key","direct_use","settings_template"],"title":"EvaluatorTemplate","description":"Static evaluator template definition (built-in evaluator types).\n\nTemplates are shipped with the product and describe the available\nevaluator types. They are read-only and separate from user-owned\nevaluator artifacts."},"EvaluatorTemplatesResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of templates in `templates`.","default":0},"templates":{"items":{"$ref":"#/components/schemas/EvaluatorTemplate"},"type":"array","title":"Templates","description":"Built-in evaluator templates."}},"type":"object","title":"EvaluatorTemplatesResponse","description":"Envelope for a list of evaluator templates."},"EvaluatorVariant":{"properties":{"evaluator_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Evaluator Slug"},"workflow_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Slug"},"evaluator_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorVariantFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"EvaluatorVariant"},"EvaluatorVariantCreate":{"properties":{"evaluator_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"EvaluatorVariantCreate"},"EvaluatorVariantCreateRequest":{"properties":{"evaluator_variant":{"$ref":"#/components/schemas/EvaluatorVariantCreate","description":"Variant payload. Requires the parent `evaluator_id`."}},"type":"object","required":["evaluator_variant"],"title":"EvaluatorVariantCreateRequest","description":"Body for creating a new variant on an existing evaluator."},"EvaluatorVariantEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"EvaluatorVariantEdit"},"EvaluatorVariantEditRequest":{"properties":{"evaluator_variant":{"$ref":"#/components/schemas/EvaluatorVariantEdit","description":"Variant edit payload. Requires the variant `id`."}},"type":"object","required":["evaluator_variant"],"title":"EvaluatorVariantEditRequest","description":"Body for editing a variant's metadata."},"EvaluatorVariantFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false}},"type":"object","title":"EvaluatorVariantFlags"},"EvaluatorVariantFork":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorVariantFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"EvaluatorVariantFork"},"EvaluatorVariantForkRequest":{"properties":{"evaluator_variant":{"$ref":"#/components/schemas/EvaluatorVariantFork","description":"Config for the new variant (slug, name, description, flags)."},"evaluator_variant_ref":{"$ref":"#/components/schemas/Reference","description":"Source variant to fork from."},"evaluator_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Pin the fork to this revision; defaults to the source variant's head."}},"type":"object","required":["evaluator_variant","evaluator_variant_ref"],"title":"EvaluatorVariantForkRequest"},"EvaluatorVariantResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"1 when a variant is returned, 0 otherwise.","default":0},"evaluator_variant":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorVariant"},{"type":"null"}],"description":"The evaluator variant, or null when none matched."}},"type":"object","title":"EvaluatorVariantResponse","description":"Envelope for a single evaluator variant."},"EvaluatorVariantsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of variants in `evaluator_variants`.","default":0},"evaluator_variants":{"items":{"$ref":"#/components/schemas/EvaluatorVariant"},"type":"array","title":"Evaluator Variants","description":"Matching evaluator variants."}},"type":"object","title":"EvaluatorVariantsResponse","description":"Envelope for a list of evaluator variants."},"EvaluatorsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of evaluators in `evaluators`.","default":0},"evaluators":{"items":{"$ref":"#/components/schemas/Evaluator"},"type":"array","title":"Evaluators","description":"Matching evaluator artifacts."}},"type":"object","title":"EvaluatorsResponse","description":"Envelope for a list of evaluators."},"Event":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"event_id":{"type":"string","format":"uuid","title":"Event Id"},"request_id":{"type":"string","format":"uuid","title":"Request Id"},"request_type":{"$ref":"#/components/schemas/RequestType"},"event_type":{"$ref":"#/components/schemas/EventType"},"timestamp":{"type":"string","format":"date-time","title":"Timestamp"},"status_code":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status Code"},"status_message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status Message"},"attributes":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Attributes"}},"type":"object","required":["event_id","request_id","request_type","event_type","timestamp"],"title":"Event"},"EventQuery":{"properties":{"request_type":{"anyOf":[{"$ref":"#/components/schemas/RequestType"},{"type":"null"}]},"request_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Request Id"},"event_type":{"anyOf":[{"$ref":"#/components/schemas/EventType"},{"type":"null"}]},"event_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Event Id"}},"type":"object","title":"EventQuery"},"EventQueryRequest":{"properties":{"event":{"anyOf":[{"$ref":"#/components/schemas/EventQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"EventQueryRequest"},"EventType":{"type":"string","enum":["unknown","webhooks.subscriptions.tested","traces.fetched","traces.queried","queries.revisions.retrieved","queries.revisions.fetched","queries.revisions.queried","queries.revisions.logged","queries.revisions.committed","testcases.fetched","testcases.queried","testsets.revisions.retrieved","testsets.revisions.fetched","testsets.revisions.queried","testsets.revisions.logged","testsets.revisions.committed","workflows.revisions.retrieved","workflows.revisions.fetched","workflows.revisions.queried","workflows.revisions.logged","workflows.revisions.committed","environments.revisions.retrieved","environments.revisions.fetched","environments.revisions.queried","environments.revisions.logged","environments.revisions.committed"],"title":"EventType"},"EventsQueryResponse":{"properties":{"count":{"type":"integer","title":"Count"},"events":{"items":{"$ref":"#/components/schemas/Event"},"type":"array","title":"Events"}},"type":"object","required":["count","events"],"title":"EventsQueryResponse"},"ExistenceOperator":{"type":"string","enum":["exists","not_exists"],"title":"ExistenceOperator"},"Filtering-Input":{"properties":{"operator":{"$ref":"#/components/schemas/LogicalOperator","default":"and"},"conditions":{"items":{"anyOf":[{"$ref":"#/components/schemas/Condition"},{"$ref":"#/components/schemas/Filtering-Input"}]},"type":"array","title":"Conditions","default":[]}},"type":"object","title":"Filtering"},"Filtering-Output":{"properties":{"operator":{"$ref":"#/components/schemas/LogicalOperator","default":"and"},"conditions":{"items":{"anyOf":[{"$ref":"#/components/schemas/Condition"},{"$ref":"#/components/schemas/Filtering-Output"}]},"type":"array","title":"Conditions","default":[]}},"type":"object","title":"Filtering"},"Focus":{"type":"string","enum":["trace","span"],"title":"Focus"},"Folder":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"kind":{"anyOf":[{"$ref":"#/components/schemas/FolderKind"},{"type":"null"}],"description":"Resource family this folder organizes. Only `applications` is defined today, and it also covers workflows, evaluators, and testsets (they share the artifact table)."},"path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path","description":"Dot-separated materialized path built from the folder's slug and its ancestors' slugs. Read-only; derived by the server."},"parent_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Parent Id","description":"Id of the parent folder, or `null` for a root folder."}},"type":"object","title":"Folder"},"FolderCreate":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"kind":{"anyOf":[{"$ref":"#/components/schemas/FolderKind"},{"type":"null"}],"description":"Resource family the folder organizes. Defaults to `applications` when omitted."},"parent_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Parent Id","description":"Id of the parent folder. Omit or set to `null` to create a root folder."}},"type":"object","title":"FolderCreate"},"FolderCreateRequest":{"properties":{"folder":{"$ref":"#/components/schemas/FolderCreate","description":"Folder to create. `slug` is required; `parent_id` nests the new folder under an existing one."}},"type":"object","required":["folder"],"title":"FolderCreateRequest"},"FolderEdit":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"kind":{"anyOf":[{"$ref":"#/components/schemas/FolderKind"},{"type":"null"}],"description":"Resource family. Must match the current folder's kind; defaults to `applications`."},"parent_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Parent Id","description":"New parent folder id. Include the key with a `null` value to move the folder to the root; omit the key to keep the existing parent."}},"type":"object","title":"FolderEdit"},"FolderEditRequest":{"properties":{"folder":{"$ref":"#/components/schemas/FolderEdit","description":"Folder edit payload. `id` must match the path parameter. Only fields present in the payload are changed."}},"type":"object","required":["folder"],"title":"FolderEditRequest"},"FolderIdResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` if a folder was deleted, `0` if no folder matched.","default":0},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id","description":"Id of the deleted folder. Omitted when nothing was deleted."}},"type":"object","title":"FolderIdResponse"},"FolderKind":{"type":"string","enum":["applications"],"title":"FolderKind"},"FolderQuery":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id","description":"Match a single folder id."},"ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Ids","description":"Match any of the given folder ids."},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug","description":"Match a folder by slug, regardless of its position in the tree."},"slugs":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Slugs","description":"Match folders whose slug is in the given list."},"kind":{"anyOf":[{"$ref":"#/components/schemas/FolderKind"},{"type":"null"}],"description":"Match folders of a single resource family."},"kinds":{"anyOf":[{"type":"boolean"},{"items":{"$ref":"#/components/schemas/FolderKind"},"type":"array"},{"type":"null"}],"title":"Kinds","description":"Filter by presence of a kind. `false` returns folders with no kind, `true` returns folders where `kind` is set, and an array restricts to the given kinds."},"parent_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Parent Id","description":"Match folders whose parent is this id. Send `null` to return only root folders."},"parent_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Parent Ids","description":"Match folders whose parent is any of the given ids."},"path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path","description":"Exact match on the materialized `path` (e.g. `support.prod`)."},"paths":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Paths","description":"Exact match on any of the given paths."},"prefix":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Prefix","description":"Subtree lookup: returns the folder at this path and every descendant."},"prefixes":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Prefixes","description":"Subtree lookup across multiple prefixes, OR-ed together."}},"type":"object","title":"FolderQuery"},"FolderQueryRequest":{"properties":{"folder":{"$ref":"#/components/schemas/FolderQuery","description":"Filter object. Any combination of `id`/`ids`, `slug`/`slugs`, `kind`/`kinds`, `parent_id`/`parent_ids`, `path`/`paths`, and `prefix`/`prefixes` narrows the result."}},"type":"object","required":["folder"],"title":"FolderQueryRequest"},"FolderResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of folders returned (`0` or `1`).","default":0},"folder":{"anyOf":[{"$ref":"#/components/schemas/Folder"},{"type":"null"}],"description":"The folder, when found. Omitted when `count` is `0`."}},"type":"object","title":"FolderResponse"},"FoldersResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of folders in `folders`.","default":0},"folders":{"items":{"$ref":"#/components/schemas/Folder"},"type":"array","title":"Folders","description":"Matching folders for the query. Ordering is not guaranteed."}},"type":"object","title":"FoldersResponse"},"Format":{"type":"string","enum":["agenta","opentelemetry"],"title":"Format"},"Formatting":{"properties":{"focus":{"anyOf":[{"$ref":"#/components/schemas/Focus"},{"type":"null"}]},"format":{"anyOf":[{"$ref":"#/components/schemas/Format"},{"type":"null"}]}},"type":"object","title":"Formatting"},"FullJson-Input":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"number"},{"type":"boolean"},{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"items":{"$ref":"#/components/schemas/FullJson-Input"},"type":"array"},{"type":"null"}]},"FullJson-Output":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"number"},{"type":"boolean"},{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"items":{"$ref":"#/components/schemas/FullJson-Output"},"type":"array"},{"type":"null"}]},"GatewayToolConfig":{"properties":{"render":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Render"},"permission":{"anyOf":[{"type":"string","enum":["allow","ask","deny"]},{"type":"null"}],"title":"Permission"},"type":{"type":"string","const":"gateway","title":"Type","default":"gateway"},"provider":{"type":"string","minLength":1,"title":"Provider","default":"composio"},"integration":{"type":"string","minLength":1,"title":"Integration"},"action":{"type":"string","minLength":1,"title":"Action"},"connection":{"type":"string","minLength":1,"title":"Connection"},"name":{"anyOf":[{"type":"string","minLength":1},{"type":"null"}],"title":"Name"}},"additionalProperties":false,"type":"object","required":["integration","action","connection"],"title":"GatewayToolConfig"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"HarnessKind":{"type":"string","enum":["pi_core","claude","pi_agenta","codex"],"title":"HarnessKind","description":"The coding agent program a run drives. A backend declares which it supports.\n\n``pi_core`` is plain Pi; ``pi_agenta`` is Pi with Agenta's forced skills, prompt, and\npolicy. Both drive the same ``pi`` ACP agent in the runner; ``claude`` drives Claude Code."},"Header":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},"type":"object","title":"Header"},"InviteRequest":{"properties":{"email":{"type":"string","title":"Email"},"roles":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Roles"}},"type":"object","required":["email"],"title":"InviteRequest"},"InviteToken":{"properties":{"token":{"type":"string","title":"Token"},"email":{"type":"string","title":"Email"}},"type":"object","required":["token","email"],"title":"InviteToken"},"Invocation":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"span_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Id"},"trace_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Trace Id"},"attributes":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Attributes"},"origin":{"$ref":"#/components/schemas/SimpleTraceOrigin","default":"custom"},"kind":{"$ref":"#/components/schemas/SimpleTraceKind","default":"adhoc"},"channel":{"$ref":"#/components/schemas/SimpleTraceChannel","default":"api"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"data":{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object","title":"Data"},"references":{"$ref":"#/components/schemas/SimpleTraceReferences"},"links":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/OTelLink-Output"},"type":"object"},{"items":{"$ref":"#/components/schemas/OTelLink-Output"},"type":"array"},{"type":"null"}],"title":"Links"}},"type":"object","required":["data","references","links"],"title":"Invocation"},"InvocationCreate":{"properties":{"origin":{"$ref":"#/components/schemas/SimpleTraceOrigin","default":"custom"},"kind":{"$ref":"#/components/schemas/SimpleTraceKind","default":"adhoc"},"channel":{"$ref":"#/components/schemas/SimpleTraceChannel","default":"api"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"data":{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object","title":"Data"},"references":{"$ref":"#/components/schemas/SimpleTraceReferences"},"links":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"object"},{"items":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"array"},{"type":"null"}],"title":"Links"}},"type":"object","required":["data","references","links"],"title":"InvocationCreate"},"InvocationCreateRequest":{"properties":{"invocation":{"$ref":"#/components/schemas/InvocationCreate"}},"type":"object","required":["invocation"],"title":"InvocationCreateRequest"},"InvocationEdit":{"properties":{"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"data":{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object","title":"Data"},"references":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceReferences"},{"type":"null"}]},"links":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"object"},{"items":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"array"},{"type":"null"}],"title":"Links"}},"type":"object","required":["data"],"title":"InvocationEdit"},"InvocationEditRequest":{"properties":{"invocation":{"$ref":"#/components/schemas/InvocationEdit"}},"type":"object","required":["invocation"],"title":"InvocationEditRequest"},"InvocationLinkResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"invocation_link":{"anyOf":[{"$ref":"#/components/schemas/OTelLink-Output"},{"type":"null"}]}},"type":"object","title":"InvocationLinkResponse"},"InvocationQuery":{"properties":{"origin":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceOrigin"},{"type":"null"}]},"kind":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceKind"},{"type":"null"}]},"channel":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceChannel"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"references":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceReferences"},{"type":"null"}]},"links":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"object"},{"items":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"array"},{"type":"null"}],"title":"Links"}},"type":"object","title":"InvocationQuery"},"InvocationQueryRequest":{"properties":{"invocation":{"anyOf":[{"$ref":"#/components/schemas/InvocationQuery"},{"type":"null"}]},"invocation_links":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"array"},{"type":"null"}],"title":"Invocation Links"},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"InvocationQueryRequest"},"InvocationResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"invocation":{"anyOf":[{"$ref":"#/components/schemas/Invocation"},{"type":"null"}]}},"type":"object","title":"InvocationResponse"},"InvocationsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"invocations":{"items":{"$ref":"#/components/schemas/Invocation"},"type":"array","title":"Invocations","default":[]}},"type":"object","title":"InvocationsResponse"},"JsonSchemas-Input":{"properties":{"parameters":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Parameters"},"inputs":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Inputs"},"outputs":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Outputs"}},"type":"object","title":"JsonSchemas"},"JsonSchemas-Output":{"properties":{"parameters":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Parameters"},"inputs":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Inputs"},"outputs":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Outputs"}},"type":"object","title":"JsonSchemas"},"LabelJson-Input":{"anyOf":[{"type":"boolean"},{"type":"string"},{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"}]},"LabelJson-Output":{"anyOf":[{"type":"boolean"},{"type":"string"},{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"}]},"LegacyLifecycleDTO":{"properties":{"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Updated At"},"updated_by_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Updated By Id"},"updated_by":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Updated By"}},"type":"object","title":"LegacyLifecycleDTO"},"ListAPIKeysResponse":{"properties":{"prefix":{"type":"string","title":"Prefix"},"created_at":{"type":"string","title":"Created At"},"last_used_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Last Used At"},"expiration_date":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Expiration Date"}},"type":"object","required":["prefix","created_at"],"title":"ListAPIKeysResponse"},"ListOperator":{"type":"string","enum":["in","not_in"],"title":"ListOperator"},"ListOptions":{"properties":{"all":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"All","default":false}},"type":"object","title":"ListOptions"},"LogicalOperator":{"type":"string","enum":["and","or","not","nand","nor"],"title":"LogicalOperator"},"MetricSpec":{"properties":{"type":{"$ref":"#/components/schemas/MetricType","default":"none"},"path":{"type":"string","title":"Path","default":"*"},"bins":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Bins"},"vmin":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Vmin"},"vmax":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Vmax"},"edge":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Edge"}},"type":"object","title":"MetricSpec"},"MetricType":{"type":"string","enum":["numeric/continuous","numeric/discrete","binary","categorical/single","categorical/multiple","string","json","none","*"],"title":"MetricType"},"MetricsBucket":{"properties":{"timestamp":{"type":"string","format":"date-time","title":"Timestamp"},"interval":{"type":"integer","title":"Interval"},"metrics":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Metrics"}},"type":"object","required":["timestamp","interval"],"title":"MetricsBucket"},"Mount":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"project_id":{"type":"string","format":"uuid","title":"Project Id"},"session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Session Id"},"agent_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Id"},"purpose":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Purpose"},"data":{"$ref":"#/components/schemas/MountData"},"flags":{"$ref":"#/components/schemas/MountFlags"},"tags":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta"}},"type":"object","required":["project_id"],"title":"Mount"},"MountArchiveRequest":{"properties":{"mounts":{"items":{"$ref":"#/components/schemas/ArchiveMount"},"type":"array","title":"Mounts"},"filename":{"type":"string","title":"Filename","default":"files.zip"}},"type":"object","title":"MountArchiveRequest","description":"Zip several mounts into ONE archive (the drive folds cwd + agent-files into one tree)."},"MountCreateRequest":{"properties":{"mount":{"$ref":"#/components/schemas/PublicMountCreate"}},"type":"object","required":["mount"],"title":"MountCreateRequest"},"MountCredentials":{"properties":{"endpoint":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Endpoint"},"region":{"type":"string","title":"Region","default":"us-east-1"},"bucket":{"type":"string","title":"Bucket"},"prefix":{"type":"string","title":"Prefix"},"access_key":{"type":"string","title":"Access Key"},"secret_key":{"type":"string","title":"Secret Key"},"session_token":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Session Token"},"expires_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Expires At"}},"type":"object","required":["bucket","prefix","access_key","secret_key"],"title":"MountCredentials","description":"Short-lived, prefix-scoped credentials for a single mount.\n\nSigned API-side from the store's STS endpoint; the master key never leaves the\nAPI. Scoped to `///*` and expires within minutes,\nso a leak grants only this mount's prefix for a short window."},"MountCredentialsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"mount":{"anyOf":[{"$ref":"#/components/schemas/Mount"},{"type":"null"}]},"credentials":{"anyOf":[{"$ref":"#/components/schemas/MountCredentials"},{"type":"null"}]}},"type":"object","title":"MountCredentialsResponse"},"MountData":{"properties":{},"type":"object","title":"MountData"},"MountEdit":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"flags":{"$ref":"#/components/schemas/MountFlags"},"tags":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta"}},"type":"object","title":"MountEdit"},"MountEditRequest":{"properties":{"mount":{"$ref":"#/components/schemas/MountEdit"}},"type":"object","required":["mount"],"title":"MountEditRequest"},"MountFileDeletedResponse":{"properties":{"deleted":{"type":"string","title":"Deleted"},"count":{"type":"integer","title":"Count","default":0}},"type":"object","required":["deleted"],"title":"MountFileDeletedResponse"},"MountFileWrittenResponse":{"properties":{"path":{"type":"string","title":"Path"},"size":{"type":"integer","title":"Size","default":0}},"type":"object","required":["path"],"title":"MountFileWrittenResponse"},"MountFlags":{"properties":{},"type":"object","title":"MountFlags"},"MountFolderCreatedResponse":{"properties":{"path":{"type":"string","title":"Path"}},"type":"object","required":["path"],"title":"MountFolderCreatedResponse"},"MountQuery":{"properties":{"session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Session Id"},"agent_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Id"},"include_archived":{"type":"boolean","title":"Include Archived","default":false}},"type":"object","title":"MountQuery"},"MountQueryRequest":{"properties":{"mount":{"anyOf":[{"$ref":"#/components/schemas/MountQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"MountQueryRequest"},"MountResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"mount":{"anyOf":[{"$ref":"#/components/schemas/Mount"},{"type":"null"}]}},"type":"object","title":"MountResponse"},"MountsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"mounts":{"items":{"$ref":"#/components/schemas/Mount"},"type":"array","title":"Mounts"}},"type":"object","title":"MountsResponse"},"NumericOperator":{"type":"string","enum":["eq","neq","gt","lt","gte","lte","btwn"],"title":"NumericOperator"},"OTelEvent-Input":{"properties":{"name":{"type":"string","title":"Name"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"integer"}],"title":"Timestamp"},"attributes":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Attributes"}},"type":"object","required":["name","timestamp"],"title":"OTelEvent"},"OTelEvent-Output":{"properties":{"name":{"type":"string","title":"Name"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"integer"}],"title":"Timestamp"},"attributes":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Attributes"}},"type":"object","required":["name","timestamp"],"title":"OTelEvent"},"OTelHash-Input":{"properties":{"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"attributes":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Attributes"}},"type":"object","title":"OTelHash"},"OTelHash-Output":{"properties":{"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"attributes":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Attributes"}},"type":"object","title":"OTelHash"},"OTelLink-Input":{"properties":{"span_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Id"},"trace_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Trace Id"},"attributes":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Attributes"}},"type":"object","title":"OTelLink"},"OTelLink-Output":{"properties":{"span_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Id"},"trace_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Trace Id"},"attributes":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Attributes"}},"type":"object","title":"OTelLink"},"OTelLinksResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of spans that were accepted and published to the ingest stream. Compare against the number of spans you sent to detect partial failures.","default":0},"links":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelLink-Output"},"type":"array"},{"type":"null"}],"title":"Links","description":"List of `(trace_id, span_id)` pairs for the accepted spans, in submission order."}},"type":"object","title":"OTelLinksResponse","description":"Response from span ingestion.\n\n`count` reflects how many spans were successfully parsed and published\nto the ingest stream. If you submitted N spans and see `count < N`,\nsome spans failed server-side validation and were not persisted (check\nserver logs for details). See [Tracing — Async write\ncontract](/reference/api-guide/tracing#async-write-contract-202) for\nthe full semantics of the `202 Accepted` response."},"OTelReference-Input":{"properties":{"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"attributes":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Attributes"}},"type":"object","title":"OTelReference"},"OTelReference-Output":{"properties":{"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"attributes":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Attributes"}},"type":"object","title":"OTelReference"},"OTelSpanKind":{"type":"string","enum":["SPAN_KIND_UNSPECIFIED","SPAN_KIND_INTERNAL","SPAN_KIND_SERVER","SPAN_KIND_CLIENT","SPAN_KIND_PRODUCER","SPAN_KIND_CONSUMER"],"title":"OTelSpanKind"},"OTelStatusCode":{"type":"string","enum":["STATUS_CODE_UNSET","STATUS_CODE_OK","STATUS_CODE_ERROR"],"title":"OTelStatusCode"},"OTelTracingRequest":{"properties":{"spans":{"anyOf":[{"items":{"$ref":"#/components/schemas/Span-Input"},"type":"array"},{"type":"null"}],"title":"Spans","description":"Flat list of spans. Use this when you already have a flat list and parent/child relationships are expressed via each span's `parent_id`."},"traces":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/SpansTree-Input"},"type":"object"},{"type":"null"}],"title":"Traces","description":"Nested tree of spans keyed by `trace_id` → span name, with children under each node's `spans` field. This matches the shape returned by `POST /tracing/spans/query` with `focus=\"trace\"`."}},"type":"object","title":"OTelTracingRequest","description":"Ingest or query payload for OpenTelemetry-style spans.\n\nExactly one of `spans` or `traces` should be provided. Use `spans`\nfor a flat list (parent/child linked via `parent_id`); use `traces`\nfor a nested tree (keyed by `trace_id` then by span name, children\nhanging off each node's `spans` field). The two shapes are\ninterchangeable and the query endpoint returns the `traces` shape by\ndefault.\n\nSee [Tracing](/reference/api-guide/tracing) for the full attribute\nnamespace and the async ingest contract."},"OTelTracingResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Total number of matching traces or spans in the window.","default":0},"spans":{"anyOf":[{"items":{"$ref":"#/components/schemas/Span-Output"},"type":"array"},{"type":"null"}],"title":"Spans","description":"Flat list of spans, populated when the query was run with `focus=\"span\"`."},"traces":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/SpansTree-Output"},"type":"object"},{"type":"null"}],"title":"Traces","description":"Nested tree of spans keyed by `trace_id` → span name, populated when the query was run with `focus=\"trace\"` (default)."}},"type":"object","title":"OTelTracingResponse","description":"Response from span/trace queries.\n\nExactly one of `spans` or `traces` is populated, controlled by the\n`focus` field in the request (`\"span\"` for flat lists, `\"trace\"` for\nnested trees). The shapes here match what the ingest endpoint accepts,\nso you can round-trip data between environments."},"OldAnalyticsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of time buckets returned.","default":0},"buckets":{"items":{"$ref":"#/components/schemas/Bucket"},"type":"array","title":"Buckets","description":"Time-bucketed aggregates with fixed fields (`total`, `errors`) holding `count`, `duration`, `costs`, and `tokens`, ordered oldest to newest.","default":[]}},"type":"object","title":"OldAnalyticsResponse","description":"Legacy analytics response with a fixed metric schema."},"Organization":{"properties":{"id":{"type":"string","title":"Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"flags":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta"},"owner_id":{"type":"string","title":"Owner Id"},"members":{"items":{"type":"string"},"type":"array","title":"Members"},"invitations":{"items":{},"type":"array","title":"Invitations"},"workspaces":{"items":{"type":"string"},"type":"array","title":"Workspaces"}},"type":"object","required":["id","owner_id"],"title":"Organization"},"OrganizationDetails":{"properties":{"id":{"type":"string","title":"Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"flags":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta"},"owner_id":{"type":"string","title":"Owner Id"},"members":{"items":{"type":"string"},"type":"array","title":"Members"},"invitations":{"items":{},"type":"array","title":"Invitations"},"workspaces":{"items":{"type":"string"},"type":"array","title":"Workspaces"},"default_workspace":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Default Workspace"}},"type":"object","required":["id","owner_id"],"title":"OrganizationDetails"},"OrganizationDomainCreate":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"domain":{"type":"string","title":"Domain"}},"type":"object","required":["domain"],"title":"OrganizationDomainCreate","description":"Request model for creating a domain."},"OrganizationDomainResponse":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"slug":{"type":"string","title":"Slug"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"flags":{"additionalProperties":true,"type":"object","title":"Flags"},"token":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Token"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"organization_id":{"type":"string","format":"uuid","title":"Organization Id"}},"type":"object","required":["id","slug","name","description","flags","token","created_at","updated_at","organization_id"],"title":"OrganizationDomainResponse","description":"Response model for a domain."},"OrganizationDomainVerify":{"properties":{"domain_id":{"type":"string","title":"Domain Id"}},"type":"object","required":["domain_id"],"title":"OrganizationDomainVerify","description":"Request model for verifying a domain."},"OrganizationProviderCreate":{"properties":{"slug":{"type":"string","pattern":"^[a-z-]+$","title":"Slug"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"flags":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Flags"},"settings":{"additionalProperties":true,"type":"object","title":"Settings"}},"type":"object","required":["slug","settings"],"title":"OrganizationProviderCreate","description":"Request model for creating an SSO provider."},"OrganizationProviderResponse":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"slug":{"type":"string","title":"Slug"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"flags":{"additionalProperties":true,"type":"object","title":"Flags"},"settings":{"additionalProperties":true,"type":"object","title":"Settings"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"organization_id":{"type":"string","format":"uuid","title":"Organization Id"}},"type":"object","required":["id","slug","name","description","flags","settings","created_at","updated_at","organization_id"],"title":"OrganizationProviderResponse","description":"Response model for an SSO provider."},"OrganizationProviderUpdate":{"properties":{"slug":{"anyOf":[{"type":"string","pattern":"^[a-z-]+$"},{"type":"null"}],"title":"Slug"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"flags":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Flags"},"settings":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Settings"}},"type":"object","title":"OrganizationProviderUpdate","description":"Request model for updating an SSO provider."},"OrganizationUpdate":{"properties":{"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"flags":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Flags"},"updated_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Updated At"}},"type":"object","title":"OrganizationUpdate"},"Permission":{"type":"string","enum":["view_applications","edit_application","run_service","view_webhooks","edit_webhooks","view_secret","edit_secret","view_spans","edit_spans","view_folders","edit_folders","view_api_keys","edit_api_keys","view_workspace","edit_workspace","create_workspace","delete_workspace","modify_user_roles","add_new_user_to_workspace","edit_organization","delete_organization","add_new_user_to_organization","reset_password","view_billing","edit_billing","view_workflows","edit_workflows","run_workflows","view_evaluators","edit_evaluators","view_environments","edit_environments","deploy_environments","view_queries","edit_queries","view_testsets","edit_testsets","view_annotations","edit_annotations","view_invocations","edit_invocations","view_evaluation_runs","edit_evaluation_runs","view_evaluation_scenarios","edit_evaluation_scenarios","view_evaluation_results","edit_evaluation_results","view_evaluation_metrics","edit_evaluation_metrics","view_evaluation_queues","edit_evaluation_queues","view_events","view_tools","edit_tools","run_tools","view_triggers","edit_triggers","run_triggers","view_sessions","edit_sessions","run_sessions","view_mounts","edit_mounts","use_mounts"],"title":"Permission"},"PlaygroundBuildKitContext":{"properties":{"agent_template_overlay":{"anyOf":[{"$ref":"#/components/schemas/AgentTemplateOverlay"},{"type":"null"}],"description":"Partial `parameters.agent` overlay applied by the playground only."}},"type":"object","title":"PlaygroundBuildKitContext","description":"Read-only playground build-kit context for one inspect/fetch response."},"PopulateSliceRequest":{"properties":{"results":{"items":{"$ref":"#/components/schemas/EvaluationResultCreate"},"type":"array","title":"Results"}},"type":"object","required":["results"],"title":"PopulateSliceRequest"},"ProbeProviderRequest":{"properties":{"kind":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Kind","description":"Provider kind, e.g. 'openai', 'azure', 'custom'. Optional when `secret_id` is given: the stored kind is used unless this overrides it."},"provider":{"$ref":"#/components/schemas/ProviderCredentials"},"secret_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Secret Id","description":"Test the credential stored under this secret, in the caller's project. Fields sent in `provider` override the stored ones."}},"type":"object","title":"ProbeProviderRequest","description":"The credential to test. It is spent on one read and never persisted.\n\n`kind` is a StandardProviderKind or CustomProviderKind value; `provider` carries the\nsame field vocabulary the vault stores, so a card can probe what it is about to save\nwithout reshaping it.\n\n`secret_id` names a connection already stored in the caller's project, and is how a\nwrite-only connection is testable at all: its value never comes back to the browser,\nso there is nothing for the card to send. The stored kind and credentials are the\nbase; anything typed in this request replaces the stored value for that field, which\nis what lets a card test an edit — a new base URL, say — before saving it."},"ProbeProviderResponse":{"properties":{"credential":{"$ref":"#/components/schemas/CredentialResult"},"discovery":{"$ref":"#/components/schemas/DiscoveryResult"},"fetched_at":{"type":"string","format":"date-time","title":"Fetched At"}},"type":"object","required":["credential","discovery","fetched_at"],"title":"ProbeProviderResponse"},"ProbeSliceRequest":{"properties":{"scenario_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Scenario Ids"},"step_keys":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Step Keys"},"repeat_idxs":{"anyOf":[{"items":{"type":"integer"},"type":"array"},{"type":"null"}],"title":"Repeat Idxs"}},"type":"object","title":"ProbeSliceRequest"},"ProcessSliceRequest":{"properties":{"scenario_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Scenario Ids"},"step_keys":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Step Keys"},"repeat_idxs":{"anyOf":[{"items":{"type":"integer"},"type":"array"},{"type":"null"}],"title":"Repeat Idxs"},"overwrite":{"type":"boolean","title":"Overwrite","default":false}},"type":"object","title":"ProcessSliceRequest"},"ProjectsResponse":{"properties":{"organization_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Organization Id"},"organization_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Organization Name"},"workspace_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workspace Id"},"workspace_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workspace Name"},"project_id":{"type":"string","format":"uuid","title":"Project Id"},"project_name":{"type":"string","title":"Project Name"},"is_default_project":{"type":"boolean","title":"Is Default Project","default":false},"user_role":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"User Role"},"is_demo":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Demo"}},"type":"object","required":["project_id","project_name"],"title":"ProjectsResponse"},"ProviderCredentials":{"properties":{"key":{"anyOf":[{"type":"string","format":"password","writeOnly":true},{"type":"null"}],"title":"Key"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"extras":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Extras"}},"type":"object","title":"ProviderCredentials","description":"Credentials in transit only. Never persisted here, never logged, never echoed.\n\n`key` is a `SecretStr` and `extras` is kept out of `repr`, so an accidental log line\nor traceback that carries this object cannot print the credential. Unwrap the key with\n`.get_secret_value()` at the point it is put on the wire, never earlier."},"PruneSliceRequest":{"properties":{"scenario_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Scenario Ids"},"step_keys":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Step Keys"},"repeat_idxs":{"anyOf":[{"items":{"type":"integer"},"type":"array"},{"type":"null"}],"title":"Repeat Idxs"}},"type":"object","title":"PruneSliceRequest"},"PublicMountCreate":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Session Id"},"agent_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Id"},"flags":{"$ref":"#/components/schemas/MountFlags"},"tags":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta"}},"additionalProperties":false,"type":"object","title":"PublicMountCreate"},"PublicSecretManagementDTO":{"properties":{"policy":{"$ref":"#/components/schemas/SecretManagementPolicy"}},"additionalProperties":false,"type":"object","required":["policy"],"title":"PublicSecretManagementDTO"},"PublicSecretResponseDTO":{"properties":{"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"kind":{"$ref":"#/components/schemas/SecretKind"},"data":{"anyOf":[{"$ref":"#/components/schemas/StandardProviderDTO"},{"$ref":"#/components/schemas/CustomProviderDTO"},{"$ref":"#/components/schemas/SSOProviderDTO"},{"$ref":"#/components/schemas/WebhookProviderDTO"},{"$ref":"#/components/schemas/CustomSecretDTO"}],"title":"Data"},"header":{"$ref":"#/components/schemas/Header"},"lifecycle":{"anyOf":[{"$ref":"#/components/schemas/LegacyLifecycleDTO"},{"type":"null"}]},"write_only":{"type":"boolean","title":"Write Only","default":false},"management":{"anyOf":[{"$ref":"#/components/schemas/PublicSecretManagementDTO"},{"type":"null"}]},"value_status":{"$ref":"#/components/schemas/SecretValueStatus"}},"type":"object","required":["kind","data","header","value_status"],"title":"PublicSecretResponseDTO","description":"Caller-facing representation after grant-aware value projection."},"QueriesResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"queries":{"items":{"$ref":"#/components/schemas/Query"},"type":"array","title":"Queries","default":[]}},"type":"object","title":"QueriesResponse"},"Query":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/QueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"Query"},"QueryCreate":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/QueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"QueryCreate"},"QueryCreateRequest":{"properties":{"query":{"$ref":"#/components/schemas/QueryCreate"}},"type":"object","required":["query"],"title":"QueryCreateRequest"},"QueryEdit":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/QueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"QueryEdit"},"QueryEditRequest":{"properties":{"query":{"$ref":"#/components/schemas/QueryEdit"}},"type":"object","required":["query"],"title":"QueryEditRequest"},"QueryFlags":{"properties":{},"type":"object","title":"QueryFlags"},"QueryQueryFlags":{"properties":{},"type":"object","title":"QueryQueryFlags"},"QueryResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"query":{"anyOf":[{"$ref":"#/components/schemas/Query"},{"type":"null"}]}},"type":"object","title":"QueryResponse"},"QueryRevision":{"properties":{"query_variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Query Variant Slug"},"variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Variant Slug"},"query_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Query Slug"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"query_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Query Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"query_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Query Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"author":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Author"},"date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Date"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/QueryRevisionData-Output"},{"type":"null"}]}},"type":"object","title":"QueryRevision"},"QueryRevisionCommit":{"properties":{"query_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Query Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"query_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Query Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"data":{"anyOf":[{"$ref":"#/components/schemas/QueryRevisionData-Input"},{"type":"null"}]},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"}},"type":"object","title":"QueryRevisionCommit"},"QueryRevisionCommitRequest":{"properties":{"query_revision":{"$ref":"#/components/schemas/QueryRevisionCommit"}},"type":"object","required":["query_revision"],"title":"QueryRevisionCommitRequest"},"QueryRevisionCreate":{"properties":{"query_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Query Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"query_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Query Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"QueryRevisionCreate"},"QueryRevisionCreateRequest":{"properties":{"query_revision":{"$ref":"#/components/schemas/QueryRevisionCreate"}},"type":"object","required":["query_revision"],"title":"QueryRevisionCreateRequest"},"QueryRevisionData-Input":{"properties":{"formatting":{"anyOf":[{"$ref":"#/components/schemas/Formatting"},{"type":"null"}]},"filtering":{"anyOf":[{"$ref":"#/components/schemas/Filtering-Input"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]},"trace_ids":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Trace Ids"},"traces":{"anyOf":[{"items":{"$ref":"#/components/schemas/Trace-Input"},"type":"array"},{"type":"null"}],"title":"Traces"}},"additionalProperties":false,"type":"object","title":"QueryRevisionData"},"QueryRevisionData-Output":{"properties":{"formatting":{"anyOf":[{"$ref":"#/components/schemas/Formatting"},{"type":"null"}]},"filtering":{"anyOf":[{"$ref":"#/components/schemas/Filtering-Output"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]},"trace_ids":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Trace Ids"},"traces":{"anyOf":[{"items":{"$ref":"#/components/schemas/Trace-Output"},"type":"array"},{"type":"null"}],"title":"Traces"}},"additionalProperties":false,"type":"object","title":"QueryRevisionData"},"QueryRevisionEdit":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"QueryRevisionEdit"},"QueryRevisionEditRequest":{"properties":{"query_revision":{"$ref":"#/components/schemas/QueryRevisionEdit"}},"type":"object","required":["query_revision"],"title":"QueryRevisionEditRequest"},"QueryRevisionQuery":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"slugs":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Slugs"},"author":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Author"},"authors":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Authors"},"date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Date"},"dates":{"anyOf":[{"items":{"type":"string","format":"date-time"},"type":"array"},{"type":"null"}],"title":"Dates"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"}},"type":"object","title":"QueryRevisionQuery"},"QueryRevisionQueryRequest":{"properties":{"query_revision":{"anyOf":[{"$ref":"#/components/schemas/QueryRevisionQuery"},{"type":"null"}]},"query_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Query Refs"},"query_variant_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Query Variant Refs"},"query_revision_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Query Revision Refs"},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"QueryRevisionQueryRequest"},"QueryRevisionResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"query_revision":{"anyOf":[{"$ref":"#/components/schemas/QueryRevision"},{"type":"null"}]},"retrieval_info":{"anyOf":[{"$ref":"#/components/schemas/RetrievalInfo"},{"type":"null"}]}},"type":"object","title":"QueryRevisionResponse"},"QueryRevisionRetrieveRequest":{"properties":{"query_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Query artifact to look up. Identifies the artifact by `id` or `slug` (both project-unique). When no variant_ref or revision_ref is provided, returns the latest revision of an arbitrary variant of this query."},"query_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Query variant to look up. Identifies the variant by `id` or `slug` (both project-unique). When no revision_ref is provided, returns the latest revision of this variant."},"query_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Query revision to look up. `id` alone identifies a revision (project-unique). `slug` alone identifies a revision (project-unique). `version` alone is a per-variant sequence number and is **not** sufficient on its own; it must be combined with a `query_variant_ref`. Sending only `version` without a variant ref returns HTTP 400."},"include_trace_ids":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Trace Ids"},"include_traces":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Traces"},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"QueryRevisionRetrieveRequest"},"QueryRevisionsLog":{"properties":{"query_revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Query Revision Id"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"},"query_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Query Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"query_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Query Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"depth":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Depth"}},"type":"object","title":"QueryRevisionsLog"},"QueryRevisionsLogRequest":{"properties":{"query_revisions":{"$ref":"#/components/schemas/QueryRevisionsLog"}},"type":"object","required":["query_revisions"],"title":"QueryRevisionsLogRequest"},"QueryRevisionsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"query_revisions":{"items":{"$ref":"#/components/schemas/QueryRevision"},"type":"array","title":"Query Revisions","default":[]}},"type":"object","title":"QueryRevisionsResponse"},"QueryVariant":{"properties":{"query_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Query Slug"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"query_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Query Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"QueryVariant"},"QueryVariantCreate":{"properties":{"query_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Query Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"QueryVariantCreate"},"QueryVariantCreateRequest":{"properties":{"query_variant":{"$ref":"#/components/schemas/QueryVariantCreate"}},"type":"object","required":["query_variant"],"title":"QueryVariantCreateRequest"},"QueryVariantEdit":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"QueryVariantEdit"},"QueryVariantEditRequest":{"properties":{"query_variant":{"$ref":"#/components/schemas/QueryVariantEdit"}},"type":"object","required":["query_variant"],"title":"QueryVariantEditRequest"},"QueryVariantFork":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"QueryVariantFork"},"QueryVariantForkRequest":{"properties":{"query_variant":{"$ref":"#/components/schemas/QueryVariantFork","description":"Config for the new variant (slug, name, description, flags)."},"query_variant_ref":{"$ref":"#/components/schemas/Reference","description":"Source variant to fork from."},"query_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Pin the fork to this revision; defaults to the source variant's head."}},"type":"object","required":["query_variant","query_variant_ref"],"title":"QueryVariantForkRequest"},"QueryVariantQuery":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"slugs":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Slugs"}},"type":"object","title":"QueryVariantQuery"},"QueryVariantQueryRequest":{"properties":{"query_variant":{"anyOf":[{"$ref":"#/components/schemas/QueryVariantQuery"},{"type":"null"}]},"query_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Query Refs"},"query_variant_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Query Variant Refs"},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"QueryVariantQueryRequest"},"QueryVariantResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"query_variant":{"anyOf":[{"$ref":"#/components/schemas/QueryVariant"},{"type":"null"}]}},"type":"object","title":"QueryVariantResponse"},"QueryVariantsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"query_variants":{"items":{"$ref":"#/components/schemas/QueryVariant"},"type":"array","title":"Query Variants","default":[]}},"type":"object","title":"QueryVariantsResponse"},"Reference":{"properties":{"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"Reference"},"ReferenceRequestModel-Input":{"properties":{"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"commit_message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Commit Message"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"ReferenceRequestModel"},"ReferenceRequestModel-Output":{"properties":{"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"version":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Version"},"commit_message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Commit Message"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"ReferenceRequestModel"},"RefreshSliceRequest":{"properties":{"scenario_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Scenario Ids"},"step_keys":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Step Keys"},"repeat_idxs":{"anyOf":[{"items":{"type":"integer"},"type":"array"},{"type":"null"}],"title":"Repeat Idxs"}},"type":"object","title":"RefreshSliceRequest"},"RemoveScenariosRequest":{"properties":{"scenario_ids":{"items":{"type":"string","format":"uuid"},"type":"array","title":"Scenario Ids"}},"type":"object","required":["scenario_ids"],"title":"RemoveScenariosRequest"},"RemoveStepsRequest":{"properties":{"step_keys":{"items":{"type":"string"},"type":"array","title":"Step Keys"}},"type":"object","required":["step_keys"],"title":"RemoveStepsRequest"},"RequestType":{"type":"string","enum":["unknown","router","worker"],"title":"RequestType"},"ResendInviteRequest":{"properties":{"email":{"type":"string","title":"Email"}},"type":"object","required":["email"],"title":"ResendInviteRequest"},"ResolutionInfo":{"properties":{"references_used":{"items":{"additionalProperties":{"$ref":"#/components/schemas/Reference"},"type":"object"},"type":"array","title":"References Used"},"depth_reached":{"type":"integer","title":"Depth Reached"},"embeds_resolved":{"type":"integer","title":"Embeds Resolved"},"errors":{"items":{"type":"string"},"type":"array","title":"Errors","default":[]}},"type":"object","required":["references_used","depth_reached","embeds_resolved"],"title":"ResolutionInfo"},"ResolvedTool":{"properties":{"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"input_schema":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Input Schema"},"call_ref":{"type":"string","title":"Call Ref"},"read_only":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Read Only"}},"type":"object","required":["name","call_ref"],"title":"ResolvedTool","description":"A runnable reference resolved into a model-ready tool spec.\n\n``call_ref`` is the ``tools.{provider}.{integration}.{action}.{connection}`` slug\nthe execution bridge sends back to ``POST /tools/call``."},"RetrievalInfo":{"properties":{"references":{"additionalProperties":{"$ref":"#/components/schemas/Reference"},"type":"object","title":"References"},"selector":{"anyOf":[{"additionalProperties":{"type":"string"},"type":"object"},{"type":"null"}],"title":"Selector"}},"type":"object","title":"RetrievalInfo","description":"References actually used to retrieve a revision.\n\nFor direct retrievals, `references` carries the artifact / variant / revision\nthat was fetched. For environment-backed retrievals, it additionally carries\nthe environment + environment_variant + environment_revision used to look\nthe target up, and `selector` is {the key: path} map inside the environment's\nreferences map that selected the target."},"SSOProviderDTO":{"properties":{"provider":{"$ref":"#/components/schemas/SSOProviderSettingsDTO"}},"type":"object","required":["provider"],"title":"SSOProviderDTO"},"SSOProviderInfo":{"properties":{"id":{"type":"string","title":"Id"},"slug":{"type":"string","title":"Slug"},"third_party_id":{"type":"string","title":"Third Party Id"}},"type":"object","required":["id","slug","third_party_id"],"title":"SSOProviderInfo"},"SSOProviderSettingsDTO":{"properties":{"client_id":{"type":"string","title":"Client Id"},"client_secret":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Client Secret"},"issuer_url":{"type":"string","title":"Issuer Url"},"scopes":{"items":{"type":"string"},"type":"array","title":"Scopes"},"extra":{"additionalProperties":true,"type":"object","title":"Extra"}},"type":"object","required":["client_id","issuer_url","scopes"],"title":"SSOProviderSettingsDTO"},"SSOProviders":{"properties":{"providers":{"items":{"$ref":"#/components/schemas/SSOProviderInfo"},"type":"array","title":"Providers"}},"type":"object","required":["providers"],"title":"SSOProviders"},"SecretDTO":{"properties":{"kind":{"$ref":"#/components/schemas/SecretKind"},"data":{"anyOf":[{"$ref":"#/components/schemas/StandardProviderDTO"},{"$ref":"#/components/schemas/CustomProviderDTO"},{"$ref":"#/components/schemas/SSOProviderDTO"},{"$ref":"#/components/schemas/WebhookProviderDTO"},{"$ref":"#/components/schemas/CustomSecretDTO"}],"title":"Data"}},"type":"object","required":["kind","data"],"title":"SecretDTO","description":"Create-time secret payload. Required credential fields must be present."},"SecretKind":{"type":"string","enum":["provider_key","custom_provider","sso_provider","webhook_provider","custom_secret"],"title":"SecretKind"},"SecretManagementPolicy":{"type":"string","enum":["manager_only"],"title":"SecretManagementPolicy"},"SecretValueStatus":{"properties":{"configured":{"type":"boolean","title":"Configured"},"preview":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Preview"}},"type":"object","required":["configured"],"title":"SecretValueStatus"},"Selector":{"properties":{"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key"},"path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path"}},"type":"object","title":"Selector","description":"Selector for extracting specific data from entities.\n\nPlaced alongside Reference for data extraction from referenced entities.\n\nFields:\n- **key**: For environment revisions only. Navigates to data.references.,\n follows the entity pointer found there (e.g. workflow_revision), fetches that\n entity, then applies path against its data.\n- **path**: Dot notation path into the resolved entity's data.\n If key is set, path applies to the secondary entity's data.\n If key is not set, path applies directly to the referenced entity's data."},"SessionAttachment":{"properties":{"attachment_id":{"type":"string","format":"uuid","title":"Attachment Id"},"filename":{"type":"string","title":"Filename"},"media_type":{"type":"string","title":"Media Type"},"size":{"type":"integer","title":"Size"},"created_at":{"type":"string","format":"date-time","title":"Created At"}},"type":"object","required":["attachment_id","filename","media_type","size","created_at"],"title":"SessionAttachment"},"SessionAttachmentReferenceRequest":{"properties":{"session_id":{"type":"string","title":"Session Id"},"attachment_ids":{"items":{"type":"string","format":"uuid"},"type":"array","maxItems":100,"title":"Attachment Ids"}},"type":"object","required":["session_id","attachment_ids"],"title":"SessionAttachmentReferenceRequest"},"SessionAttachmentResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"attachment":{"$ref":"#/components/schemas/SessionAttachment"}},"type":"object","required":["attachment"],"title":"SessionAttachmentResponse"},"SessionAttachmentsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"attachments":{"items":{"$ref":"#/components/schemas/SessionAttachment"},"type":"array","title":"Attachments"}},"type":"object","title":"SessionAttachmentsResponse"},"SessionDelivery":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"}},"type":"object","required":["id"],"title":"SessionDelivery"},"SessionDetachRequest":{"properties":{"session_id":{"type":"string","title":"Session Id"},"watcher_id":{"type":"string","title":"Watcher Id"}},"type":"object","required":["session_id","watcher_id"],"title":"SessionDetachRequest"},"SessionExcludeRequest":{"properties":{"origins":{"anyOf":[{"items":{"$ref":"#/components/schemas/SessionOrigin"},"type":"array"},{"type":"null"}],"title":"Origins"},"session_ids":{"anyOf":[{"items":{"type":"string","maxLength":128,"minLength":1,"pattern":"^[a-zA-Z0-9_\\-]+$"},"type":"array","maxItems":500},{"type":"null"}],"title":"Session Ids"}},"additionalProperties":false,"type":"object","title":"SessionExcludeRequest"},"SessionExpansion":{"type":"string","enum":["last_message","trigger"],"title":"SessionExpansion"},"SessionHeartbeatRequest":{"properties":{"session_id":{"type":"string","title":"Session Id"},"replica_id":{"type":"string","minLength":1,"title":"Replica Id"},"turn_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Turn Id"},"is_running":{"type":"boolean","title":"Is Running","default":true},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"references":{"anyOf":[{"items":{"$ref":"#/components/schemas/SessionReference"},"type":"array"},{"type":"null"}],"title":"References"}},"type":"object","required":["session_id","replica_id"],"title":"SessionHeartbeatRequest","description":"A beat, plus what this run knows about the session that nothing else records.\n\n``name`` and ``references`` are PROPOSALS, not edits: the service writes each only\nonto a NULL column (see `SessionStreamsService.heartbeat`). The runner is the only\ncomponent present on every execution path — browser, headless invoke, scheduled\ntrigger — so it is the only one that can title and attribute a session that no\nbrowser will ever render."},"SessionHeartbeatResult":{"properties":{"stream":{"anyOf":[{"$ref":"#/components/schemas/SessionStream"},{"type":"null"}]},"replica_id":{"type":"string","title":"Replica Id"},"is_current_turn":{"type":"boolean","title":"Is Current Turn","default":true}},"type":"object","required":["replica_id"],"title":"SessionHeartbeatResult","description":"A heartbeat's outcome: the reconciled stream plus the session's actual owner replica.\n\n`replica_id` is the replica that currently holds the affinity key after the claim\n(this caller if it won or already held it, another replica otherwise). The runner reads\nit to refuse serving a local sandbox session it does not own.\n\n`stream` is None when a losing replica heartbeats a session that has no row yet: it may\nnot create or stamp one, since that row belongs to the owner.\n\n`is_current_turn` (W7.4) is False when this turn_id's alive/running lock was gone or\nreassigned at the moment of this beat — i.e. a cancel/steer/kill interrupted this turn\nsince the last heartbeat. The runner's watchdog reads this to abort the in-flight run;\nwithout it a cancel that raced a heartbeat's nx=True re-acquire would silently re-arm the\nSAME lock under the SAME turn_id and the interruption would never surface."},"SessionIdentitiesUpdate":{"properties":{"session_identities":{"items":{"type":"string"},"type":"array","title":"Session Identities"}},"type":"object","required":["session_identities"],"title":"SessionIdentitiesUpdate"},"SessionIdsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of session IDs in this page.","default":0},"session_ids":{"items":{"type":"string"},"type":"array","title":"Session Ids","description":"Distinct values of `ag.session.id` in this page.","default":[]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor for the next page. Pass verbatim as `windowing.next`."}},"type":"object","title":"SessionIdsResponse"},"SessionInteraction":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"project_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Project Id"},"session_id":{"type":"string","title":"Session Id"},"turn_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Turn Id"},"token":{"type":"string","title":"Token"},"kind":{"$ref":"#/components/schemas/SessionInteractionKind"},"status":{"anyOf":[{"$ref":"#/components/schemas/SessionInteractionStatus"},{"type":"null"}]},"data":{"anyOf":[{"$ref":"#/components/schemas/SessionInteractionData"},{"type":"null"}]},"flags":{"$ref":"#/components/schemas/SessionInteractionFlags","default":{"delivered_in_band":false,"delivered_webhook":false}},"tags":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta"}},"type":"object","required":["session_id","token","kind"],"title":"SessionInteraction"},"SessionInteractionCancelStaleRequest":{"properties":{"session_id":{"type":"string","title":"Session Id"},"turn_id":{"type":"string","title":"Turn Id"},"tokens":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Tokens"}},"type":"object","required":["session_id","turn_id"],"title":"SessionInteractionCancelStaleRequest"},"SessionInteractionCreateRequest":{"properties":{"session_id":{"type":"string","title":"Session Id"},"turn_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Turn Id"},"token":{"type":"string","title":"Token"},"kind":{"$ref":"#/components/schemas/SessionInteractionKind"},"data":{"anyOf":[{"$ref":"#/components/schemas/SessionInteractionData"},{"type":"null"}]},"flags":{"$ref":"#/components/schemas/SessionInteractionFlags","default":{"delivered_in_band":false,"delivered_webhook":false}},"tags":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta"}},"type":"object","required":["session_id","token","kind"],"title":"SessionInteractionCreateRequest"},"SessionInteractionData":{"properties":{"request":{"anyOf":[{"$ref":"#/components/schemas/SessionInteractionRequest"},{"type":"null"}]},"references":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/Reference"},"type":"object"},{"type":"null"}],"title":"References"},"selector":{"anyOf":[{"$ref":"#/components/schemas/Selector"},{"type":"null"}]},"resolution":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Resolution"},"parameters":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Parameters"}},"type":"object","title":"SessionInteractionData"},"SessionInteractionFlags":{"properties":{"delivered_in_band":{"type":"boolean","title":"Delivered In Band","default":false},"delivered_webhook":{"type":"boolean","title":"Delivered Webhook","default":false}},"type":"object","title":"SessionInteractionFlags"},"SessionInteractionKind":{"type":"string","enum":["user_approval","user_input","client_tool"],"title":"SessionInteractionKind"},"SessionInteractionQuery":{"properties":{"session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Session Id"},"turn_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Turn Id"},"kind":{"anyOf":[{"$ref":"#/components/schemas/SessionInteractionKind"},{"type":"null"}]},"status":{"anyOf":[{"$ref":"#/components/schemas/SessionInteractionStatus"},{"type":"null"}]},"flags":{"anyOf":[{"$ref":"#/components/schemas/SessionInteractionQueryFlags"},{"type":"null"}]},"actionable_only":{"type":"boolean","title":"Actionable Only","default":false}},"type":"object","title":"SessionInteractionQuery"},"SessionInteractionQueryFlags":{"properties":{"delivered_in_band":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Delivered In Band"},"delivered_webhook":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Delivered Webhook"}},"type":"object","title":"SessionInteractionQueryFlags"},"SessionInteractionQueryRequest":{"properties":{"query":{"anyOf":[{"$ref":"#/components/schemas/SessionInteractionQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"SessionInteractionQueryRequest"},"SessionInteractionRequest":{"properties":{"tool":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tool"},"args":{"anyOf":[{},{"type":"null"}],"title":"Args"},"tool_call_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tool Call Id"}},"additionalProperties":true,"type":"object","title":"SessionInteractionRequest"},"SessionInteractionRespondRequest":{"properties":{"answer":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Answer"}},"type":"object","title":"SessionInteractionRespondRequest"},"SessionInteractionResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"interaction":{"anyOf":[{"$ref":"#/components/schemas/SessionInteraction"},{"type":"null"}]}},"type":"object","title":"SessionInteractionResponse"},"SessionInteractionStatus":{"type":"string","enum":["pending","responded","resolved","cancelled"],"title":"SessionInteractionStatus"},"SessionInteractionTransitionRequest":{"properties":{"session_id":{"type":"string","title":"Session Id"},"token":{"type":"string","title":"Token"},"status":{"$ref":"#/components/schemas/SessionInteractionStatus"},"resolution":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Resolution"}},"type":"object","required":["session_id","token","status"],"title":"SessionInteractionTransitionRequest"},"SessionInteractionsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"interactions":{"items":{"$ref":"#/components/schemas/SessionInteraction"},"type":"array","title":"Interactions"}},"type":"object","title":"SessionInteractionsResponse"},"SessionListItem":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"project_id":{"type":"string","format":"uuid","title":"Project Id"},"session_id":{"type":"string","title":"Session Id"},"flags":{"$ref":"#/components/schemas/SessionStreamFlags","default":{"is_alive":false,"is_running":false,"is_attached":false}},"tags":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta"},"turn_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Turn Id"},"references":{"anyOf":[{"items":{"$ref":"#/components/schemas/SessionReference"},"type":"array"},{"type":"null"}],"title":"References"},"archived_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Archived At"},"origin":{"anyOf":[{"$ref":"#/components/schemas/SessionOrigin"},{"type":"null"}]},"trigger":{"anyOf":[{"$ref":"#/components/schemas/SessionTrigger"},{"type":"null"}]},"delivery":{"anyOf":[{"$ref":"#/components/schemas/SessionDelivery"},{"type":"null"}]},"last_message":{"anyOf":[{"$ref":"#/components/schemas/SessionMessagePreview"},{"type":"null"}]}},"type":"object","required":["project_id","session_id"],"title":"SessionListItem","description":"A `/sessions/query` row, enriched at READ time with the session's last message.\n\n`references` prefers the stream row's own (filled once at run time) and falls back to\nthe HIGHEST `turn_index` turn's — the agent/workflow that produced the latest turn.\nThe fallback is what keeps rows written before the stream column existed openable.\nBoth enrichments are batch lookups keyed on the whole page; never one call per row.\n\nHydrated by `SessionsService.query_sessions`."},"SessionMessagePreview":{"properties":{"text":{"type":"string","maxLength":240,"title":"Text"},"source":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Source"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Timestamp"}},"type":"object","required":["text"],"title":"SessionMessagePreview","description":"The last thing said in a session, for a list row.\n\nA session row carried a title and a timestamp, so deciding whether a session was worth\nreopening meant opening it. Only `message` records are considered: `done`/`usage` are\nbookkeeping, `thought` is not addressed to anyone, and a `tool_call` says what the agent\nreached for rather than what it concluded."},"SessionMount":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"project_id":{"type":"string","format":"uuid","title":"Project Id"},"session_id":{"type":"string","title":"Session Id"},"agent_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Id"},"purpose":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Purpose"},"data":{"$ref":"#/components/schemas/MountData"},"flags":{"$ref":"#/components/schemas/MountFlags"},"tags":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta"}},"type":"object","required":["project_id","session_id"],"title":"SessionMount"},"SessionMountQuery":{"properties":{"session_id":{"type":"string","title":"Session Id"},"agent_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Id"},"include_archived":{"type":"boolean","title":"Include Archived","default":false}},"type":"object","required":["session_id"],"title":"SessionMountQuery"},"SessionMountQueryRequest":{"properties":{"mount":{"anyOf":[{"$ref":"#/components/schemas/SessionMountQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"SessionMountQueryRequest"},"SessionMountsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"mounts":{"items":{"$ref":"#/components/schemas/SessionMount"},"type":"array","title":"Mounts"}},"type":"object","title":"SessionMountsResponse"},"SessionOrigin":{"type":"string","enum":["manual","trigger"],"title":"SessionOrigin"},"SessionPredicatesRequest":{"properties":{"search":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Search"},"liveness":{"anyOf":[{"$ref":"#/components/schemas/SessionStreamQueryFlags"},{"type":"null"}]},"origins":{"anyOf":[{"items":{"$ref":"#/components/schemas/SessionOrigin"},"type":"array"},{"type":"null"}],"title":"Origins"}},"additionalProperties":false,"type":"object","title":"SessionPredicatesRequest"},"SessionQueryRequest":{"properties":{"session":{"anyOf":[{"$ref":"#/components/schemas/SessionPredicatesRequest"},{"type":"null"}]},"session_ids":{"anyOf":[{"items":{"type":"string","maxLength":128,"minLength":1,"pattern":"^[a-zA-Z0-9_\\-]+$"},"type":"array","maxItems":500},{"type":"null"}],"title":"Session Ids"},"exclude":{"anyOf":[{"$ref":"#/components/schemas/SessionExcludeRequest"},{"type":"null"}]},"turn_references":{"anyOf":[{"items":{"$ref":"#/components/schemas/SessionReference"},"type":"array"},{"type":"null"}],"title":"Turn References"},"include_ended":{"type":"boolean","title":"Include Ended","default":false},"include_archived":{"type":"boolean","title":"Include Archived","default":false},"archived_only":{"type":"boolean","title":"Archived Only","default":false},"include_total":{"type":"boolean","title":"Include Total","default":false},"expand":{"items":{"$ref":"#/components/schemas/SessionExpansion"},"type":"array","title":"Expand"},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]},"references":{"anyOf":[{"items":{"$ref":"#/components/schemas/SessionReference"},"type":"array"},{"type":"null"}],"title":"References"},"search":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Search"},"flags":{"anyOf":[{"$ref":"#/components/schemas/SessionStreamQueryFlags"},{"type":"null"}]},"exclude_session_ids":{"anyOf":[{"items":{"type":"string","maxLength":128,"minLength":1,"pattern":"^[a-zA-Z0-9_\\-]+$"},"type":"array","maxItems":500},{"type":"null"}],"title":"Exclude Session Ids"},"origin":{"anyOf":[{"$ref":"#/components/schemas/SessionOrigin"},{"type":"null"}]},"exclude_origin":{"anyOf":[{"$ref":"#/components/schemas/SessionOrigin"},{"type":"null"}]}},"additionalProperties":false,"type":"object","title":"SessionQueryRequest"},"SessionRecord":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"record_id":{"type":"string","format":"uuid","title":"Record Id"},"session_id":{"type":"string","title":"Session Id"},"project_id":{"type":"string","format":"uuid","title":"Project Id"},"record_index":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Record Index"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Timestamp"},"record_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Record Type"},"record_source":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Record Source"},"attributes":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Attributes"},"turn_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Turn Id"},"span_id":{"anyOf":[{"type":"string","pattern":"^[0-9a-fA-F]{16}$"},{"type":"null"}],"title":"Span Id"}},"type":"object","required":["record_id","session_id","project_id"],"title":"SessionRecord"},"SessionRecordIngestRequest":{"properties":{"session_id":{"type":"string","title":"Session Id"},"record_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Record Id"},"record_index":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Record Index"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Timestamp"},"record_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Record Type"},"record_source":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Record Source"},"attributes":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Attributes"},"turn_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Turn Id"},"span_id":{"anyOf":[{"type":"string","pattern":"^[0-9a-fA-F]{16}$"},{"type":"null"}],"title":"Span Id"}},"type":"object","required":["session_id"],"title":"SessionRecordIngestRequest"},"SessionRecordQueryRequest":{"properties":{"session_id":{"type":"string","title":"Session Id"}},"type":"object","required":["session_id"],"title":"SessionRecordQueryRequest"},"SessionRecordResponse":{"properties":{"record":{"anyOf":[{"$ref":"#/components/schemas/SessionRecord"},{"type":"null"}]}},"type":"object","title":"SessionRecordResponse"},"SessionRecordsQueryResponse":{"properties":{"count":{"type":"integer","title":"Count"},"records":{"items":{"$ref":"#/components/schemas/SessionRecord"},"type":"array","title":"Records"}},"type":"object","required":["count","records"],"title":"SessionRecordsQueryResponse"},"SessionReference":{"properties":{"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key"}},"type":"object","title":"SessionReference","description":"A reference element that carries its own family.\n\nSessions persist references as a flat list (`session_turns.references`,\n`session_streams.references`), so the map key that named the family upstream is\ngone by the time a reader sees the row — leaving \"first UUID in the list\" as the\nonly way to guess which element is the workflow.\n\n``key`` is the name `evaluation_runs.references` already uses for the same flat-list\ndiscriminator (`dbs/postgres/evaluations/utils.py`), and the same one tracing carries\nin `OTelReference.attributes[\"key\"]`.\n\nIt is a plain string rather than ``ReferenceKey`` on purpose: a turn append is\nfire-and-forget, so rejecting an unrecognized family would drop the whole turn, which\nis the failure this field exists to prevent. Producers inside the API use\n``ReferenceKey``; readers treat anything else as untyped."},"SessionResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"session":{"anyOf":[{"$ref":"#/components/schemas/SessionStream"},{"type":"null"}]}},"type":"object","title":"SessionResponse"},"SessionStream":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"project_id":{"type":"string","format":"uuid","title":"Project Id"},"session_id":{"type":"string","title":"Session Id"},"flags":{"$ref":"#/components/schemas/SessionStreamFlags","default":{"is_alive":false,"is_running":false,"is_attached":false}},"tags":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta"},"turn_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Turn Id"},"references":{"anyOf":[{"items":{"$ref":"#/components/schemas/SessionReference"},"type":"array"},{"type":"null"}],"title":"References"},"archived_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Archived At"},"origin":{"anyOf":[{"$ref":"#/components/schemas/SessionOrigin"},{"type":"null"}]},"trigger":{"anyOf":[{"$ref":"#/components/schemas/SessionTrigger"},{"type":"null"}]},"delivery":{"anyOf":[{"$ref":"#/components/schemas/SessionDelivery"},{"type":"null"}]}},"type":"object","required":["project_id","session_id"],"title":"SessionStream"},"SessionStreamCommandRequest":{"properties":{"session_id":{"type":"string","title":"Session Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRequestData"},{"type":"null"}]},"force":{"type":"boolean","title":"Force","default":false},"detached":{"type":"boolean","title":"Detached","default":false}},"type":"object","required":["session_id"],"title":"SessionStreamCommandRequest","description":"The set_session_stream edit: a state mutation over the lock/row nest.\n\nRuns nothing itself — the runner (execution plane) is the only thing that runs.\n`data` mirrors the workflow-invoke shape (`WorkflowServiceRequestData`, keyed on\n`.inputs`) so the discriminator aligns with `WorkflowInvokeRequest.data.inputs`\nrather than a bespoke `prompt` string."},"SessionStreamCommandResponse":{"properties":{"mode":{"$ref":"#/components/schemas/CommandMode"},"session_id":{"type":"string","title":"Session Id"},"turn_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Turn Id"},"watcher_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Watcher Id"},"detached":{"type":"boolean","title":"Detached","default":false}},"type":"object","required":["mode","session_id"],"title":"SessionStreamCommandResponse"},"SessionStreamFlags":{"properties":{"is_alive":{"type":"boolean","title":"Is Alive","default":false},"is_running":{"type":"boolean","title":"Is Running","default":false},"is_attached":{"type":"boolean","title":"Is Attached","default":false}},"type":"object","title":"SessionStreamFlags","description":"The nest as primitive bools (alive ⊇ running ⊇ attached).\n\nresumable (alive & !running) and reattachable (running & !attached) are\nderived client-side, never stored."},"SessionStreamHeaderEdit":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},"type":"object","title":"SessionStreamHeaderEdit","description":"The rename edit: a full-PUT of the header fields only.\n\nDistinct from SessionStreamEdit (used by the flag-mirror/heartbeat paths) so the\nliveness-only writes can never carry name/description, and vice versa. The one\nother header writer is the heartbeat's fill-once proposal, which goes through the\nDAO's NULL-guarded `fill_missing` and so cannot overwrite this edit.\n\n``name`` may be omitted/``None`` (no change) or an empty string (the explicit\nclear-title action the chat rail's rename path uses), but a NON-empty name must\ncontain a non-whitespace character: storing ``\" \"`` clears the visible title\nwhile the row still holds a value, a state no caller ever means. The LLM-facing\n``rename_session`` schema already rejects both; this closes the direct-API hole."},"SessionStreamQueryFlags":{"properties":{"is_alive":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Alive"},"is_running":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Running"},"is_attached":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Attached"}},"type":"object","title":"SessionStreamQueryFlags"},"SessionStreamQueryRequest":{"properties":{"session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Session Id"},"is_alive":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Alive"},"is_running":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Running"}},"type":"object","title":"SessionStreamQueryRequest"},"SessionStreamResponse":{"properties":{"stream":{"anyOf":[{"$ref":"#/components/schemas/SessionStream"},{"type":"null"}]}},"type":"object","title":"SessionStreamResponse"},"SessionStreamsResponse":{"properties":{"count":{"type":"integer","title":"Count"},"streams":{"items":{"$ref":"#/components/schemas/SessionStream"},"type":"array","title":"Streams"}},"type":"object","required":["count","streams"],"title":"SessionStreamsResponse"},"SessionTrigger":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"kind":{"$ref":"#/components/schemas/SessionTriggerKind"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"}},"type":"object","required":["id","kind"],"title":"SessionTrigger"},"SessionTriggerKind":{"type":"string","enum":["schedule","subscription"],"title":"SessionTriggerKind"},"SessionTurn":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"project_id":{"type":"string","format":"uuid","title":"Project Id"},"session_id":{"type":"string","title":"Session Id"},"turn_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Turn Id"},"stream_id":{"type":"string","format":"uuid","title":"Stream Id"},"turn_index":{"type":"integer","title":"Turn Index"},"harness_kind":{"$ref":"#/components/schemas/HarnessKind"},"agent_session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Session Id"},"sandbox_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sandbox Id"},"references":{"anyOf":[{"items":{"$ref":"#/components/schemas/SessionReference"},"type":"array"},{"type":"null"}],"title":"References"},"trace_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Trace Id"},"span_id":{"anyOf":[{"type":"string","pattern":"^[0-9a-fA-F]{16}$"},{"type":"null"}],"title":"Span Id"},"start_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Start Time"},"end_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"End Time"}},"type":"object","required":["project_id","session_id","stream_id","turn_index","harness_kind"],"title":"SessionTurn"},"SessionTurnAppendRequest":{"properties":{"session_id":{"type":"string","title":"Session Id"},"turn_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Turn Id"},"stream_id":{"type":"string","format":"uuid","title":"Stream Id"},"turn_index":{"type":"integer","title":"Turn Index"},"harness_kind":{"$ref":"#/components/schemas/HarnessKind"},"agent_session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Session Id"},"sandbox_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sandbox Id"},"references":{"anyOf":[{"items":{"$ref":"#/components/schemas/SessionReference"},"type":"array"},{"type":"null"}],"title":"References"},"trace_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Trace Id"},"span_id":{"anyOf":[{"type":"string","pattern":"^[0-9a-fA-F]{16}$"},{"type":"null"}],"title":"Span Id"},"start_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Start Time"},"end_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"End Time"}},"type":"object","required":["session_id","stream_id","turn_index","harness_kind"],"title":"SessionTurnAppendRequest"},"SessionTurnCompleteRequest":{"properties":{"session_id":{"type":"string","title":"Session Id"},"turn_index":{"type":"integer","title":"Turn Index"},"agent_session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Session Id"},"end_time":{"type":"string","format":"date-time","title":"End Time"}},"type":"object","required":["session_id","turn_index","end_time"],"title":"SessionTurnCompleteRequest"},"SessionTurnQuery":{"properties":{"session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Session Id"},"stream_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Stream Id"},"harness_kind":{"anyOf":[{"$ref":"#/components/schemas/HarnessKind"},{"type":"null"}]},"references":{"anyOf":[{"items":{"$ref":"#/components/schemas/SessionReference"},"type":"array"},{"type":"null"}],"title":"References"}},"type":"object","title":"SessionTurnQuery"},"SessionTurnQueryRequest":{"properties":{"query":{"anyOf":[{"$ref":"#/components/schemas/SessionTurnQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"SessionTurnQueryRequest"},"SessionTurnResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"turn":{"anyOf":[{"$ref":"#/components/schemas/SessionTurn"},{"type":"null"}]}},"type":"object","title":"SessionTurnResponse"},"SessionTurnsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"turns":{"items":{"$ref":"#/components/schemas/SessionTurn"},"type":"array","title":"Turns"}},"type":"object","title":"SessionTurnsResponse"},"SessionsQueryRequest":{"properties":{"realtime":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Realtime","description":"When `true`, paginate by `last_active` (reflects ongoing activity but can shift between pages). When `false` or unset, paginate by the stable `first_active` cursor."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor pagination and time range. Pass the returned `windowing.next` on subsequent calls to continue iteration."}},"type":"object","title":"SessionsQueryRequest","description":"Request body for `POST /tracing/sessions/query`."},"SessionsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"total":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Total"},"sessions":{"items":{"$ref":"#/components/schemas/SessionListItem"},"type":"array","title":"Sessions"},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"SessionsResponse"},"SetRepeatsRequest":{"properties":{"repeats":{"type":"integer","title":"Repeats"}},"type":"object","required":["repeats"],"title":"SetRepeatsRequest"},"SimpleApplication":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/SimpleApplicationFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/SimpleApplicationData-Output"},{"type":"null"}]},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"}},"type":"object","title":"SimpleApplication"},"SimpleApplicationAdditionalContext":{"properties":{"playground_build_kit":{"anyOf":[{"$ref":"#/components/schemas/PlaygroundBuildKitContext"},{"type":"null"}],"description":"Playground-only build kit data that is never persisted on the app."}},"type":"object","title":"SimpleApplicationAdditionalContext","description":"Platform-supplied read-only context for a simple-application response."},"SimpleApplicationCreate":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/SimpleApplicationFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"data":{"anyOf":[{"$ref":"#/components/schemas/SimpleApplicationData-Input"},{"type":"null"}]}},"type":"object","title":"SimpleApplicationCreate"},"SimpleApplicationCreateRequest":{"properties":{"application":{"$ref":"#/components/schemas/SimpleApplicationCreate","description":"Application fields plus `data` for the first revision. `data.uri` selects the template (for example `agenta:builtin:completion:v0`); `data.parameters` carries the prompt and model config."}},"type":"object","required":["application"],"title":"SimpleApplicationCreateRequest","description":"Request body for `POST /simple/applications/`.\n\nCreates the application artifact, a default variant, and a first committed\nrevision whose `data` comes from the request. Use this for the common case\nof \"spin up a new application from a template\".\nSee [Simple Endpoints](/reference/api-guide/simple-endpoints)."},"SimpleApplicationData-Input":{"properties":{"uri":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Uri"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"headers":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/Reference"}]},"type":"object"},{"type":"null"}],"title":"Headers"},"runtime":{"anyOf":[{"type":"string","enum":["python","typescript","javascript"]},{"type":"null"}],"title":"Runtime"},"script":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Script"},"schemas":{"anyOf":[{"$ref":"#/components/schemas/JsonSchemas-Input"},{"type":"null"}]},"parameters":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Parameters"}},"additionalProperties":false,"type":"object","title":"SimpleApplicationData"},"SimpleApplicationData-Output":{"properties":{"uri":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Uri"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"headers":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/Reference"}]},"type":"object"},{"type":"null"}],"title":"Headers"},"runtime":{"anyOf":[{"type":"string","enum":["python","typescript","javascript"]},{"type":"null"}],"title":"Runtime"},"script":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Script"},"schemas":{"anyOf":[{"$ref":"#/components/schemas/JsonSchemas-Output"},{"type":"null"}]},"parameters":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Parameters"}},"additionalProperties":false,"type":"object","title":"SimpleApplicationData"},"SimpleApplicationEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/SimpleApplicationFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/SimpleApplicationData-Input"},{"type":"null"}]}},"type":"object","title":"SimpleApplicationEdit"},"SimpleApplicationEditRequest":{"properties":{"application":{"$ref":"#/components/schemas/SimpleApplicationEdit","description":"Fields to change. `id` must match the path. Supplying `data` commits a new revision with that configuration; supplying `flags`/`tags`/`meta` commits a revision with the updated header but the existing `data`."}},"type":"object","required":["application"],"title":"SimpleApplicationEditRequest","description":"Request body for `PUT /simple/applications/{application_id}`.\n\nCommits a new revision on the application's variant whenever fields other\nthan `id` are present. If only `id` is sent, the current state is returned\nwithout committing."},"SimpleApplicationFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false},"is_managed":{"type":"boolean","title":"Is Managed","default":false},"is_custom":{"type":"boolean","title":"Is Custom","default":false},"is_llm":{"type":"boolean","title":"Is Llm","default":false},"is_hook":{"type":"boolean","title":"Is Hook","default":false},"is_code":{"type":"boolean","title":"Is Code","default":false},"is_match":{"type":"boolean","title":"Is Match","default":false},"is_feedback":{"type":"boolean","title":"Is Feedback","default":false},"is_agent":{"type":"boolean","title":"Is Agent","default":false},"is_skill":{"type":"boolean","title":"Is Skill","default":false},"is_chat":{"type":"boolean","title":"Is Chat","default":false},"has_url":{"type":"boolean","title":"Has Url","default":false},"has_script":{"type":"boolean","title":"Has Script","default":false},"has_handler":{"type":"boolean","title":"Has Handler","default":false},"is_static":{"type":"boolean","title":"Is Static","default":false}},"type":"object","title":"SimpleApplicationFlags"},"SimpleApplicationQuery":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/SimpleApplicationQueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"slugs":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Slugs"}},"type":"object","title":"SimpleApplicationQuery"},"SimpleApplicationQueryFlags":{"properties":{"is_application":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Application"},"is_evaluator":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Evaluator"},"is_snippet":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Snippet"},"is_managed":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Managed"},"is_custom":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Custom"},"is_llm":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Llm"},"is_hook":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Hook"},"is_code":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Code"},"is_match":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Match"},"is_feedback":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Feedback"},"is_agent":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Agent"},"is_skill":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Skill"},"is_chat":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Chat"},"has_url":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Url"},"has_script":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Script"},"has_handler":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Handler"},"is_static":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Static"}},"type":"object","title":"SimpleApplicationQueryFlags"},"SimpleApplicationQueryRequest":{"properties":{"application":{"anyOf":[{"$ref":"#/components/schemas/SimpleApplicationQuery"},{"type":"null"}],"description":"Attribute filter. Supports `slug`, `slugs`, `flags`, and `meta`. `flags` filter both artifact flags (`is_application`, etc.) and revision flags (`is_chat`, `has_url`, etc.)."},"application_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Application Refs","description":"Restrict to specific applications by `id` or `slug`."},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived","description":"When `true`, include archived applications. Defaults to `false`.","default":false},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor pagination and time-range controls."}},"type":"object","title":"SimpleApplicationQueryRequest","description":"Request body for `POST /simple/applications/query`.\n\nReturns one row per application with the currently resolved variant,\nrevision, and `data` merged in — the shape most clients want when listing\napplications for a dashboard or invocation picker."},"SimpleApplicationResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when the application was found, `0` otherwise.","default":0},"application":{"anyOf":[{"$ref":"#/components/schemas/SimpleApplication"},{"type":"null"}],"description":"The application with `variant_id`, `revision_id`, and the revision's `data` merged. `data.url` is the invocation URL."},"additional_context":{"anyOf":[{"$ref":"#/components/schemas/SimpleApplicationAdditionalContext"},{"type":"null"}],"description":"Read-only platform context derived for this response."}},"type":"object","title":"SimpleApplicationResponse","description":"Simple-application single-row response envelope."},"SimpleApplicationsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of applications in this page.","default":0},"applications":{"items":{"$ref":"#/components/schemas/SimpleApplication"},"type":"array","title":"Applications","description":"Applications with their current variant, revision, and `data` merged in."}},"type":"object","title":"SimpleApplicationsResponse","description":"Paginated list of simple-application rows."},"SimpleEnvironment":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentRevisionData"},{"type":"null"}]},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"}},"type":"object","title":"SimpleEnvironment"},"SimpleEnvironmentCreate":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"data":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentRevisionData"},{"type":"null"}]}},"type":"object","title":"SimpleEnvironmentCreate"},"SimpleEnvironmentCreateRequest":{"properties":{"environment":{"$ref":"#/components/schemas/SimpleEnvironmentCreate"}},"type":"object","required":["environment"],"title":"SimpleEnvironmentCreateRequest"},"SimpleEnvironmentEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentRevisionData"},{"type":"null"}]}},"type":"object","title":"SimpleEnvironmentEdit"},"SimpleEnvironmentEditRequest":{"properties":{"environment":{"$ref":"#/components/schemas/SimpleEnvironmentEdit"}},"type":"object","required":["environment"],"title":"SimpleEnvironmentEditRequest"},"SimpleEnvironmentQuery":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentQueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"slugs":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Slugs"}},"type":"object","title":"SimpleEnvironmentQuery"},"SimpleEnvironmentQueryRequest":{"properties":{"environment":{"anyOf":[{"$ref":"#/components/schemas/SimpleEnvironmentQuery"},{"type":"null"}]},"environment_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Environment Refs"},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived","default":false},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"SimpleEnvironmentQueryRequest"},"SimpleEnvironmentResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"environment":{"anyOf":[{"$ref":"#/components/schemas/SimpleEnvironment"},{"type":"null"}]}},"type":"object","title":"SimpleEnvironmentResponse"},"SimpleEnvironmentsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"environments":{"items":{"$ref":"#/components/schemas/SimpleEnvironment"},"type":"array","title":"Environments","default":[]}},"type":"object","title":"SimpleEnvironmentsResponse"},"SimpleEvaluation":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"data":{"anyOf":[{"$ref":"#/components/schemas/SimpleEvaluationData"},{"type":"null"}]}},"type":"object","title":"SimpleEvaluation"},"SimpleEvaluationCreate":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"version":{"type":"string","title":"Version","default":"2025-07-14"},"data":{"anyOf":[{"$ref":"#/components/schemas/SimpleEvaluationData"},{"type":"null"}]}},"type":"object","title":"SimpleEvaluationCreate"},"SimpleEvaluationCreateRequest":{"properties":{"evaluation":{"$ref":"#/components/schemas/SimpleEvaluationCreate"}},"type":"object","required":["evaluation"],"title":"SimpleEvaluationCreateRequest"},"SimpleEvaluationData":{"properties":{"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}]},"query_steps":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"additionalProperties":{"type":"string","enum":["custom","human","auto"]},"propertyNames":{"format":"uuid"},"type":"object"},{"type":"null"}],"title":"Query Steps"},"testset_steps":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"additionalProperties":{"type":"string","enum":["custom","human","auto"]},"propertyNames":{"format":"uuid"},"type":"object"},{"type":"null"}],"title":"Testset Steps"},"application_steps":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"additionalProperties":{"type":"string","enum":["custom","human","auto"]},"propertyNames":{"format":"uuid"},"type":"object"},{"type":"null"}],"title":"Application Steps"},"evaluator_steps":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"additionalProperties":{"type":"string","enum":["custom","human","auto"]},"propertyNames":{"format":"uuid"},"type":"object"},{"type":"null"}],"title":"Evaluator Steps"},"repeats":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Repeats"},"concurrency":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunDataConcurrency"},{"type":"null"}]}},"type":"object","title":"SimpleEvaluationData"},"SimpleEvaluationEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"version":{"type":"string","title":"Version","default":"2025-07-14"},"data":{"anyOf":[{"$ref":"#/components/schemas/SimpleEvaluationData"},{"type":"null"}]}},"type":"object","title":"SimpleEvaluationEdit"},"SimpleEvaluationEditRequest":{"properties":{"evaluation":{"$ref":"#/components/schemas/SimpleEvaluationEdit"}},"type":"object","required":["evaluation"],"title":"SimpleEvaluationEditRequest"},"SimpleEvaluationIdResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"evaluation_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluation Id"}},"type":"object","title":"SimpleEvaluationIdResponse"},"SimpleEvaluationQuery":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunQueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Ids"}},"type":"object","title":"SimpleEvaluationQuery"},"SimpleEvaluationQueryRequest":{"properties":{"evaluation":{"anyOf":[{"$ref":"#/components/schemas/SimpleEvaluationQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"SimpleEvaluationQueryRequest"},"SimpleEvaluationResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"evaluation":{"anyOf":[{"$ref":"#/components/schemas/SimpleEvaluation"},{"type":"null"}]}},"type":"object","title":"SimpleEvaluationResponse"},"SimpleEvaluationsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"evaluations":{"items":{"$ref":"#/components/schemas/SimpleEvaluation"},"type":"array","title":"Evaluations","default":[]}},"type":"object","title":"SimpleEvaluationsResponse"},"SimpleEvaluator":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/SimpleEvaluatorFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/SimpleEvaluatorData-Output"},{"type":"null"}]},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"}},"type":"object","title":"SimpleEvaluator"},"SimpleEvaluatorCreate":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/SimpleEvaluatorFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"data":{"anyOf":[{"$ref":"#/components/schemas/SimpleEvaluatorData-Input"},{"type":"null"}]}},"type":"object","title":"SimpleEvaluatorCreate"},"SimpleEvaluatorCreateRequest":{"properties":{"evaluator":{"$ref":"#/components/schemas/SimpleEvaluatorCreate","description":"Simple evaluator payload (slug, name, flags, and `data` with `uri` + `parameters`)."}},"type":"object","required":["evaluator"],"title":"SimpleEvaluatorCreateRequest","description":"Body for creating an evaluator via the simple surface.\n\nCollapses artifact, variant, and first revision into one call. The\nresponse returns the same flat shape that `/simple/evaluators/query`\nexposes."},"SimpleEvaluatorData-Input":{"properties":{"uri":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Uri"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"headers":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/Reference"}]},"type":"object"},{"type":"null"}],"title":"Headers"},"runtime":{"anyOf":[{"type":"string","enum":["python","typescript","javascript"]},{"type":"null"}],"title":"Runtime"},"script":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Script"},"schemas":{"anyOf":[{"$ref":"#/components/schemas/JsonSchemas-Input"},{"type":"null"}]},"parameters":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Parameters"}},"additionalProperties":false,"type":"object","title":"SimpleEvaluatorData"},"SimpleEvaluatorData-Output":{"properties":{"uri":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Uri"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"headers":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/Reference"}]},"type":"object"},{"type":"null"}],"title":"Headers"},"runtime":{"anyOf":[{"type":"string","enum":["python","typescript","javascript"]},{"type":"null"}],"title":"Runtime"},"script":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Script"},"schemas":{"anyOf":[{"$ref":"#/components/schemas/JsonSchemas-Output"},{"type":"null"}]},"parameters":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Parameters"}},"additionalProperties":false,"type":"object","title":"SimpleEvaluatorData"},"SimpleEvaluatorEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/SimpleEvaluatorFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/SimpleEvaluatorData-Input"},{"type":"null"}]}},"type":"object","title":"SimpleEvaluatorEdit"},"SimpleEvaluatorEditRequest":{"properties":{"evaluator":{"$ref":"#/components/schemas/SimpleEvaluatorEdit","description":"Simple evaluator edit payload. Requires the evaluator `id`. Renaming is temporarily disabled."}},"type":"object","required":["evaluator"],"title":"SimpleEvaluatorEditRequest","description":"Body for editing an evaluator via the simple surface."},"SimpleEvaluatorFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false},"is_managed":{"type":"boolean","title":"Is Managed","default":false},"is_custom":{"type":"boolean","title":"Is Custom","default":false},"is_llm":{"type":"boolean","title":"Is Llm","default":false},"is_hook":{"type":"boolean","title":"Is Hook","default":false},"is_code":{"type":"boolean","title":"Is Code","default":false},"is_match":{"type":"boolean","title":"Is Match","default":false},"is_feedback":{"type":"boolean","title":"Is Feedback","default":false},"is_agent":{"type":"boolean","title":"Is Agent","default":false},"is_skill":{"type":"boolean","title":"Is Skill","default":false},"is_chat":{"type":"boolean","title":"Is Chat","default":false},"has_url":{"type":"boolean","title":"Has Url","default":false},"has_script":{"type":"boolean","title":"Has Script","default":false},"has_handler":{"type":"boolean","title":"Has Handler","default":false},"is_static":{"type":"boolean","title":"Is Static","default":false}},"type":"object","title":"SimpleEvaluatorFlags"},"SimpleEvaluatorQuery":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/SimpleEvaluatorQueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"slugs":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Slugs"}},"type":"object","title":"SimpleEvaluatorQuery"},"SimpleEvaluatorQueryFlags":{"properties":{"is_application":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Application"},"is_evaluator":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Evaluator"},"is_snippet":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Snippet"},"is_managed":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Managed"},"is_custom":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Custom"},"is_llm":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Llm"},"is_hook":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Hook"},"is_code":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Code"},"is_match":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Match"},"is_feedback":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Feedback"},"is_agent":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Agent"},"is_skill":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Skill"},"is_chat":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Chat"},"has_url":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Url"},"has_script":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Script"},"has_handler":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Handler"},"is_static":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Static"}},"type":"object","title":"SimpleEvaluatorQueryFlags"},"SimpleEvaluatorQueryRequest":{"properties":{"evaluator":{"anyOf":[{"$ref":"#/components/schemas/SimpleEvaluatorQuery"},{"type":"null"}],"description":"Filter on evaluator attributes (slug, slugs, flags, meta)."},"evaluator_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Evaluator Refs","description":"Restrict to these evaluators."},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived","description":"When true, include soft-deleted evaluators.","default":false},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor-based pagination controls."}},"type":"object","title":"SimpleEvaluatorQueryRequest","description":"Body for filtering evaluators via the simple surface."},"SimpleEvaluatorResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"1 when an evaluator is returned, 0 otherwise.","default":0},"evaluator":{"anyOf":[{"$ref":"#/components/schemas/SimpleEvaluator"},{"type":"null"}],"description":"The flat evaluator record with latest variant and revision merged into `data`."}},"type":"object","title":"SimpleEvaluatorResponse","description":"Envelope for a single simple evaluator."},"SimpleEvaluatorsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of evaluators in `evaluators`.","default":0},"evaluators":{"items":{"$ref":"#/components/schemas/SimpleEvaluator"},"type":"array","title":"Evaluators","description":"Matching flat evaluator records."}},"type":"object","title":"SimpleEvaluatorsResponse","description":"Envelope for a list of simple evaluators."},"SimpleQueriesResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"queries":{"items":{"$ref":"#/components/schemas/SimpleQuery"},"type":"array","title":"Queries","default":[]}},"type":"object","title":"SimpleQueriesResponse"},"SimpleQuery":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/QueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"data":{"anyOf":[{"$ref":"#/components/schemas/QueryRevisionData-Output"},{"type":"null"}]},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"}},"type":"object","title":"SimpleQuery"},"SimpleQueryCreate":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"flags":{"anyOf":[{"$ref":"#/components/schemas/QueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"data":{"anyOf":[{"$ref":"#/components/schemas/QueryRevisionData-Input"},{"type":"null"}]}},"type":"object","title":"SimpleQueryCreate"},"SimpleQueryCreateRequest":{"properties":{"query":{"$ref":"#/components/schemas/SimpleQueryCreate"}},"type":"object","required":["query"],"title":"SimpleQueryCreateRequest"},"SimpleQueryEdit":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/QueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"data":{"anyOf":[{"$ref":"#/components/schemas/QueryRevisionData-Input"},{"type":"null"}]}},"type":"object","title":"SimpleQueryEdit"},"SimpleQueryEditRequest":{"properties":{"query":{"$ref":"#/components/schemas/SimpleQueryEdit"}},"type":"object","required":["query"],"title":"SimpleQueryEditRequest"},"SimpleQueryQuery":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/QueryQueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"}},"type":"object","title":"SimpleQueryQuery"},"SimpleQueryQueryRequest":{"properties":{"query":{"anyOf":[{"$ref":"#/components/schemas/SimpleQueryQuery"},{"type":"null"}]},"query_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Query Refs"},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived","default":false},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"SimpleQueryQueryRequest"},"SimpleQueryResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"query":{"anyOf":[{"$ref":"#/components/schemas/SimpleQuery"},{"type":"null"}]}},"type":"object","title":"SimpleQueryResponse"},"SimpleQueue":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}],"default":"pending"},"data":{"anyOf":[{"$ref":"#/components/schemas/SimpleQueueData"},{"type":"null"}]},"run_id":{"type":"string","format":"uuid","title":"Run Id"}},"type":"object","required":["run_id"],"title":"SimpleQueue"},"SimpleQueueCreate":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}]},"data":{"anyOf":[{"$ref":"#/components/schemas/SimpleQueueData"},{"type":"null"}]}},"type":"object","title":"SimpleQueueCreate"},"SimpleQueueCreateRequest":{"properties":{"queue":{"$ref":"#/components/schemas/SimpleQueueCreate"}},"type":"object","required":["queue"],"title":"SimpleQueueCreateRequest"},"SimpleQueueData":{"properties":{"kind":{"anyOf":[{"$ref":"#/components/schemas/SimpleQueueKind"},{"type":"null"}]},"queries":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Queries"},"testsets":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Testsets"},"evaluators":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"additionalProperties":{"type":"string","enum":["custom","human","auto"]},"propertyNames":{"format":"uuid"},"type":"object"},{"type":"null"}],"title":"Evaluators"},"repeats":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Repeats"},"assignments":{"anyOf":[{"items":{"items":{"type":"string","format":"uuid"},"type":"array"},"type":"array"},{"type":"null"}],"title":"Assignments"},"step_keys":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Step Keys"},"settings":{"anyOf":[{"$ref":"#/components/schemas/SimpleQueueSettings"},{"type":"null"}]}},"type":"object","title":"SimpleQueueData"},"SimpleQueueIdResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"queue_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Queue Id"}},"type":"object","title":"SimpleQueueIdResponse"},"SimpleQueueIdsRequest":{"properties":{"queue_ids":{"items":{"type":"string","format":"uuid"},"type":"array","title":"Queue Ids"}},"type":"object","required":["queue_ids"],"title":"SimpleQueueIdsRequest"},"SimpleQueueIdsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"queue_ids":{"items":{"type":"string","format":"uuid"},"type":"array","title":"Queue Ids","default":[]}},"type":"object","title":"SimpleQueueIdsResponse"},"SimpleQueueKind":{"type":"string","enum":["queries","testsets","traces","testcases"],"title":"SimpleQueueKind"},"SimpleQueueQuery":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"kind":{"anyOf":[{"$ref":"#/components/schemas/SimpleQueueKind"},{"type":"null"}]},"user_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"User Id"},"user_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"User Ids"},"run_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Run Id"},"run_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Run Ids"},"queue_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Queue Ids"}},"type":"object","title":"SimpleQueueQuery"},"SimpleQueueQueryRequest":{"properties":{"queue":{"anyOf":[{"$ref":"#/components/schemas/SimpleQueueQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"SimpleQueueQueryRequest"},"SimpleQueueResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"queue":{"anyOf":[{"$ref":"#/components/schemas/SimpleQueue"},{"type":"null"}]}},"type":"object","title":"SimpleQueueResponse"},"SimpleQueueScenariosQuery":{"properties":{"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"user_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"User Id"},"user_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"User Ids"}},"type":"object","title":"SimpleQueueScenariosQuery"},"SimpleQueueScenariosQueryRequest":{"properties":{"queue":{"anyOf":[{"$ref":"#/components/schemas/SimpleQueueScenariosQuery"},{"type":"null"}]},"scenario":{"anyOf":[{"$ref":"#/components/schemas/EvaluationScenarioQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"SimpleQueueScenariosQueryRequest"},"SimpleQueueScenariosResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"scenarios":{"items":{"$ref":"#/components/schemas/EvaluationScenario"},"type":"array","title":"Scenarios","default":[]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"SimpleQueueScenariosResponse"},"SimpleQueueSettings":{"properties":{"batch_size":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Batch Size"},"batch_offset":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Batch Offset"}},"type":"object","title":"SimpleQueueSettings"},"SimpleQueueTestcasesCreateRequest":{"properties":{"testcase_ids":{"items":{"type":"string","format":"uuid"},"type":"array","title":"Testcase Ids"}},"type":"object","required":["testcase_ids"],"title":"SimpleQueueTestcasesCreateRequest"},"SimpleQueueTracesCreateRequest":{"properties":{"trace_ids":{"items":{"type":"string"},"type":"array","title":"Trace Ids"}},"type":"object","required":["trace_ids"],"title":"SimpleQueueTracesCreateRequest"},"SimpleQueuesResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"queues":{"items":{"$ref":"#/components/schemas/SimpleQueue"},"type":"array","title":"Queues","default":[]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"SimpleQueuesResponse"},"SimpleTestset":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/TestsetRevisionData-Output"},{"type":"null"}]},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"}},"type":"object","title":"SimpleTestset"},"SimpleTestsetCreate":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"data":{"anyOf":[{"$ref":"#/components/schemas/TestsetRevisionData-Input"},{"type":"null"}]}},"type":"object","title":"SimpleTestsetCreate"},"SimpleTestsetCreateRequest":{"properties":{"testset":{"$ref":"#/components/schemas/SimpleTestsetCreate","description":"Simple testset to create. `data.testcases` is committed as the first revision on a single variant in one call."}},"type":"object","required":["testset"],"title":"SimpleTestsetCreateRequest"},"SimpleTestsetEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/TestsetRevisionData-Input"},{"type":"null"}]}},"type":"object","title":"SimpleTestsetEdit"},"SimpleTestsetEditRequest":{"properties":{"testset":{"$ref":"#/components/schemas/SimpleTestsetEdit","description":"Simple testset fields to update. If `data.testcases` is provided, a new revision is committed with those testcases."}},"type":"object","required":["testset"],"title":"SimpleTestsetEditRequest"},"SimpleTestsetQuery":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},"type":"object","title":"SimpleTestsetQuery"},"SimpleTestsetQueryRequest":{"properties":{"testset":{"anyOf":[{"$ref":"#/components/schemas/SimpleTestsetQuery"},{"type":"null"}],"description":"Attribute filter on the testset (flags, tags, meta)."},"testset_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Testset Refs","description":"Restrict the query to specific testsets."},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived","description":"Include soft-deleted testsets."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor-based pagination. See the Query Pattern guide."}},"type":"object","title":"SimpleTestsetQueryRequest"},"SimpleTestsetResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"1 if a testset was returned, 0 otherwise.","default":0},"testset":{"anyOf":[{"$ref":"#/components/schemas/SimpleTestset"},{"type":"null"}],"description":"The testset with its latest revision testcases merged into `data.testcases`, and the revision ID on `revision_id`."}},"type":"object","title":"SimpleTestsetResponse"},"SimpleTestsetsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of simple testsets returned.","default":0},"testsets":{"items":{"$ref":"#/components/schemas/SimpleTestset"},"type":"array","title":"Testsets","description":"Simple testsets, each with its latest revision testcases merged in."}},"type":"object","title":"SimpleTestsetsResponse"},"SimpleTrace":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"span_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Id"},"trace_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Trace Id"},"attributes":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Attributes"},"origin":{"$ref":"#/components/schemas/SimpleTraceOrigin","default":"custom"},"kind":{"$ref":"#/components/schemas/SimpleTraceKind","default":"adhoc"},"channel":{"$ref":"#/components/schemas/SimpleTraceChannel","default":"api"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"data":{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object","title":"Data"},"references":{"$ref":"#/components/schemas/SimpleTraceReferences"},"links":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/OTelLink-Output"},"type":"object"},{"items":{"$ref":"#/components/schemas/OTelLink-Output"},"type":"array"}],"title":"Links"}},"type":"object","required":["data","references","links"],"title":"SimpleTrace"},"SimpleTraceChannel":{"type":"string","enum":["otlp","web","sdk","api"],"title":"SimpleTraceChannel"},"SimpleTraceCreate":{"properties":{"origin":{"$ref":"#/components/schemas/SimpleTraceOrigin","default":"custom"},"kind":{"$ref":"#/components/schemas/SimpleTraceKind","default":"adhoc"},"channel":{"$ref":"#/components/schemas/SimpleTraceChannel","default":"api"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"data":{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object","title":"Data"},"references":{"$ref":"#/components/schemas/SimpleTraceReferences"},"links":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"object"},{"items":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"array"}],"title":"Links"}},"type":"object","required":["data","references","links"],"title":"SimpleTraceCreate"},"SimpleTraceCreateRequest":{"properties":{"trace":{"$ref":"#/components/schemas/SimpleTraceCreate","description":"The trace to create. Must include `data` (the payload being recorded) and typically `origin`, `kind`, and `channel` to describe where it came from. Optional `references` link the trace to Agenta entities (app, variant, revision, evaluator, testset, etc.)."}},"type":"object","required":["trace"],"title":"SimpleTraceCreateRequest","description":"Request body for creating a single-span \"simple\" trace."},"SimpleTraceEdit":{"properties":{"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"data":{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object","title":"Data"},"references":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceReferences"},{"type":"null"}]},"links":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"object"},{"items":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"array"},{"type":"null"}],"title":"Links"}},"type":"object","required":["data"],"title":"SimpleTraceEdit"},"SimpleTraceEditRequest":{"properties":{"trace":{"$ref":"#/components/schemas/SimpleTraceEdit","description":"The fields to update. `data` is required. `tags`, `meta`, `references`, and `links` overwrite their current values when present."}},"type":"object","required":["trace"],"title":"SimpleTraceEditRequest","description":"Request body for editing an existing \"simple\" trace."},"SimpleTraceKind":{"type":"string","enum":["adhoc","eval","play"],"title":"SimpleTraceKind"},"SimpleTraceLinkResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` if a trace was removed, `0` otherwise.","default":0},"link":{"anyOf":[{"$ref":"#/components/schemas/OTelLink-Output"},{"type":"null"}],"description":"The `(trace_id, span_id)` pair that was removed."}},"type":"object","title":"SimpleTraceLinkResponse","description":"Response from `DELETE /simple/traces/{trace_id}`."},"SimpleTraceOrigin":{"type":"string","enum":["custom","human","auto"],"title":"SimpleTraceOrigin"},"SimpleTraceQuery":{"properties":{"origin":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceOrigin"},{"type":"null"}]},"kind":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceKind"},{"type":"null"}]},"channel":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceChannel"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"references":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceReferences"},{"type":"null"}]},"links":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"object"},{"items":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"array"},{"type":"null"}],"title":"Links"}},"type":"object","title":"SimpleTraceQuery"},"SimpleTraceQueryRequest":{"properties":{"trace":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceQuery"},{"type":"null"}],"description":"Filter fields on the trace itself — `origin`, `kind`, `channel`, `tags`, `meta`, `references`, and inbound `links`. Filtering by `trace.links.invocation` is the common pattern for finding annotations on a given span."},"links":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"array"},{"type":"null"}],"title":"Links","description":"Batch GET by the trace's own `(trace_id, span_id)`. Each entry matches the trace whose own identity equals the pair. Distinct from `trace.links`, which filters on inbound links."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor pagination and time range."}},"type":"object","title":"SimpleTraceQueryRequest","description":"Request body for `POST /simple/traces/query`."},"SimpleTraceReferences":{"properties":{"query":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"query_variant":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"query_revision":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"testset":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"testset_variant":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"testset_revision":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"application":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"application_variant":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"application_revision":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"evaluator":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"evaluator_variant":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"evaluator_revision":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"environment":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"environment_variant":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"environment_revision":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"testcase":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"selector":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Selector"}},"type":"object","title":"SimpleTraceReferences"},"SimpleTraceResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` if the trace was returned, `0` otherwise.","default":0},"trace":{"anyOf":[{"$ref":"#/components/schemas/SimpleTrace"},{"type":"null"}],"description":"The created or fetched trace, including server-assigned `trace_id` and `span_id`."}},"type":"object","title":"SimpleTraceResponse","description":"Response from a single-trace create/fetch/edit."},"SimpleTracesResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of matching traces in this page.","default":0},"traces":{"items":{"$ref":"#/components/schemas/SimpleTrace"},"type":"array","title":"Traces","description":"The matching traces in the high-level `SimpleTrace` shape.","default":[]}},"type":"object","title":"SimpleTracesResponse","description":"Response from `POST /simple/traces/query`."},"SimpleWorkflow":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/SimpleWorkflowFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/SimpleWorkflowData-Output"},{"type":"null"}]},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"}},"type":"object","title":"SimpleWorkflow"},"SimpleWorkflowCreate":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/SimpleWorkflowFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"data":{"anyOf":[{"$ref":"#/components/schemas/SimpleWorkflowData-Input"},{"type":"null"}]}},"type":"object","title":"SimpleWorkflowCreate"},"SimpleWorkflowCreateRequest":{"properties":{"workflow":{"$ref":"#/components/schemas/SimpleWorkflowCreate","description":"Simple-workflow create payload. Creates the artifact, a default variant, and an initial revision in one call."}},"type":"object","required":["workflow"],"title":"SimpleWorkflowCreateRequest"},"SimpleWorkflowData-Input":{"properties":{"uri":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Uri"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"headers":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/Reference"}]},"type":"object"},{"type":"null"}],"title":"Headers"},"runtime":{"anyOf":[{"type":"string","enum":["python","typescript","javascript"]},{"type":"null"}],"title":"Runtime"},"script":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Script"},"schemas":{"anyOf":[{"$ref":"#/components/schemas/JsonSchemas-Input"},{"type":"null"}]},"parameters":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Parameters"}},"additionalProperties":false,"type":"object","title":"SimpleWorkflowData"},"SimpleWorkflowData-Output":{"properties":{"uri":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Uri"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"headers":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/Reference"}]},"type":"object"},{"type":"null"}],"title":"Headers"},"runtime":{"anyOf":[{"type":"string","enum":["python","typescript","javascript"]},{"type":"null"}],"title":"Runtime"},"script":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Script"},"schemas":{"anyOf":[{"$ref":"#/components/schemas/JsonSchemas-Output"},{"type":"null"}]},"parameters":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Parameters"}},"additionalProperties":false,"type":"object","title":"SimpleWorkflowData"},"SimpleWorkflowEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/SimpleWorkflowFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/SimpleWorkflowData-Input"},{"type":"null"}]}},"type":"object","title":"SimpleWorkflowEdit"},"SimpleWorkflowEditRequest":{"properties":{"workflow":{"$ref":"#/components/schemas/SimpleWorkflowEdit","description":"Simple-workflow edit payload. Updates artifact-level fields and commits a new revision when `data` changes."}},"type":"object","required":["workflow"],"title":"SimpleWorkflowEditRequest"},"SimpleWorkflowFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false},"is_managed":{"type":"boolean","title":"Is Managed","default":false},"is_custom":{"type":"boolean","title":"Is Custom","default":false},"is_llm":{"type":"boolean","title":"Is Llm","default":false},"is_hook":{"type":"boolean","title":"Is Hook","default":false},"is_code":{"type":"boolean","title":"Is Code","default":false},"is_match":{"type":"boolean","title":"Is Match","default":false},"is_feedback":{"type":"boolean","title":"Is Feedback","default":false},"is_agent":{"type":"boolean","title":"Is Agent","default":false},"is_skill":{"type":"boolean","title":"Is Skill","default":false},"is_chat":{"type":"boolean","title":"Is Chat","default":false},"has_url":{"type":"boolean","title":"Has Url","default":false},"has_script":{"type":"boolean","title":"Has Script","default":false},"has_handler":{"type":"boolean","title":"Has Handler","default":false},"is_static":{"type":"boolean","title":"Is Static","default":false}},"type":"object","title":"SimpleWorkflowFlags"},"SimpleWorkflowQuery":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/SimpleWorkflowQueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"slugs":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Slugs"}},"type":"object","title":"SimpleWorkflowQuery"},"SimpleWorkflowQueryFlags":{"properties":{"is_application":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Application"},"is_evaluator":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Evaluator"},"is_snippet":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Snippet"},"is_managed":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Managed"},"is_custom":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Custom"},"is_llm":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Llm"},"is_hook":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Hook"},"is_code":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Code"},"is_match":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Match"},"is_feedback":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Feedback"},"is_agent":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Agent"},"is_skill":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Skill"},"is_chat":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Chat"},"has_url":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Url"},"has_script":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Script"},"has_handler":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Handler"},"is_static":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Static"}},"type":"object","title":"SimpleWorkflowQueryFlags"},"SimpleWorkflowQueryRequest":{"properties":{"workflow":{"anyOf":[{"$ref":"#/components/schemas/SimpleWorkflowQuery"},{"type":"null"}],"description":"Attribute filter on simple workflows (slug, slugs, flags, tags, meta)."},"workflow_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Workflow Refs","description":"Restrict results to workflows matching these references."},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived","description":"When true, include archived workflows."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor-based pagination controls."}},"type":"object","title":"SimpleWorkflowQueryRequest"},"SimpleWorkflowResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when a simple workflow is returned, `0` when none matched.","default":0},"workflow":{"anyOf":[{"$ref":"#/components/schemas/SimpleWorkflow"},{"type":"null"}],"description":"Workflow artifact with its resolved variant and revision merged."}},"type":"object","title":"SimpleWorkflowResponse"},"SimpleWorkflowsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of workflows in the response.","default":0},"workflows":{"items":{"$ref":"#/components/schemas/SimpleWorkflow"},"type":"array","title":"Workflows","description":"Workflow artifacts each merged with their resolved variant and revision."}},"type":"object","title":"SimpleWorkflowsResponse"},"Span-Input":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"trace_id":{"type":"string","title":"Trace Id"},"span_id":{"type":"string","title":"Span Id"},"parent_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Parent Id"},"trace_type":{"anyOf":[{"$ref":"#/components/schemas/TraceType"},{"type":"null"}]},"span_type":{"anyOf":[{"$ref":"#/components/schemas/SpanType"},{"type":"null"}]},"span_kind":{"anyOf":[{"$ref":"#/components/schemas/OTelSpanKind"},{"type":"null"}]},"span_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Name"},"start_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"integer"},{"type":"null"}],"title":"Start Time"},"end_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"integer"},{"type":"null"}],"title":"End Time"},"status_code":{"anyOf":[{"$ref":"#/components/schemas/OTelStatusCode"},{"type":"null"}]},"status_message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status Message"},"session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Session Id"},"user_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"User Id"},"agent_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Id"},"attributes":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Attributes"},"references":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelReference-Input"},"type":"array"},{"type":"null"}],"title":"References"},"links":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"array"},{"type":"null"}],"title":"Links"},"hashes":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelHash-Input"},"type":"array"},{"type":"null"}],"title":"Hashes"},"exception":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Exception"},"events":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelEvent-Input"},"type":"array"},{"type":"null"}],"title":"Events"}},"type":"object","required":["trace_id","span_id"],"title":"Span"},"Span-Output":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"trace_id":{"type":"string","title":"Trace Id"},"span_id":{"type":"string","title":"Span Id"},"parent_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Parent Id"},"trace_type":{"anyOf":[{"$ref":"#/components/schemas/TraceType"},{"type":"null"}]},"span_type":{"anyOf":[{"$ref":"#/components/schemas/SpanType"},{"type":"null"}]},"span_kind":{"anyOf":[{"$ref":"#/components/schemas/OTelSpanKind"},{"type":"null"}]},"span_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Name"},"start_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"integer"},{"type":"null"}],"title":"Start Time"},"end_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"integer"},{"type":"null"}],"title":"End Time"},"status_code":{"anyOf":[{"$ref":"#/components/schemas/OTelStatusCode"},{"type":"null"}]},"status_message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status Message"},"session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Session Id"},"user_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"User Id"},"agent_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Id"},"attributes":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Attributes"},"references":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelReference-Output"},"type":"array"},{"type":"null"}],"title":"References"},"links":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelLink-Output"},"type":"array"},{"type":"null"}],"title":"Links"},"hashes":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelHash-Output"},"type":"array"},{"type":"null"}],"title":"Hashes"},"exception":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Exception"},"events":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelEvent-Output"},"type":"array"},{"type":"null"}],"title":"Events"}},"type":"object","required":["trace_id","span_id"],"title":"Span"},"SpanResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` if a span was returned, `0` otherwise.","default":0},"span":{"anyOf":[{"$ref":"#/components/schemas/Span-Output"},{"type":"null"}],"description":"The matching span, or `null` if not found."}},"type":"object","title":"SpanResponse"},"SpanType":{"type":"string","enum":["agent","chain","workflow","task","tool","embedding","query","llm","completion","chat","rerank","unknown"],"title":"SpanType"},"SpansNode-Input":{"properties":{"spans":{"anyOf":[{"additionalProperties":{"anyOf":[{"$ref":"#/components/schemas/SpansNode-Input"},{"items":{"$ref":"#/components/schemas/SpansNode-Input"},"type":"array"}]},"type":"object"},{"type":"null"}],"title":"Spans"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"trace_id":{"type":"string","title":"Trace Id"},"span_id":{"type":"string","title":"Span Id"},"parent_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Parent Id"},"trace_type":{"anyOf":[{"$ref":"#/components/schemas/TraceType"},{"type":"null"}]},"span_type":{"anyOf":[{"$ref":"#/components/schemas/SpanType"},{"type":"null"}]},"span_kind":{"anyOf":[{"$ref":"#/components/schemas/OTelSpanKind"},{"type":"null"}]},"span_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Name"},"start_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"integer"},{"type":"null"}],"title":"Start Time"},"end_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"integer"},{"type":"null"}],"title":"End Time"},"status_code":{"anyOf":[{"$ref":"#/components/schemas/OTelStatusCode"},{"type":"null"}]},"status_message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status Message"},"session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Session Id"},"user_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"User Id"},"agent_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Id"},"attributes":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Attributes"},"references":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelReference-Input"},"type":"array"},{"type":"null"}],"title":"References"},"links":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"array"},{"type":"null"}],"title":"Links"},"hashes":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelHash-Input"},"type":"array"},{"type":"null"}],"title":"Hashes"},"exception":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Exception"},"events":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelEvent-Input"},"type":"array"},{"type":"null"}],"title":"Events"}},"type":"object","required":["trace_id","span_id"],"title":"SpansNode"},"SpansNode-Output":{"properties":{"spans":{"anyOf":[{"additionalProperties":{"anyOf":[{"$ref":"#/components/schemas/SpansNode-Output"},{"items":{"$ref":"#/components/schemas/SpansNode-Output"},"type":"array"}]},"type":"object"},{"type":"null"}],"title":"Spans"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"trace_id":{"type":"string","title":"Trace Id"},"span_id":{"type":"string","title":"Span Id"},"parent_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Parent Id"},"trace_type":{"anyOf":[{"$ref":"#/components/schemas/TraceType"},{"type":"null"}]},"span_type":{"anyOf":[{"$ref":"#/components/schemas/SpanType"},{"type":"null"}]},"span_kind":{"anyOf":[{"$ref":"#/components/schemas/OTelSpanKind"},{"type":"null"}]},"span_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Name"},"start_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"integer"},{"type":"null"}],"title":"Start Time"},"end_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"integer"},{"type":"null"}],"title":"End Time"},"status_code":{"anyOf":[{"$ref":"#/components/schemas/OTelStatusCode"},{"type":"null"}]},"status_message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status Message"},"session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Session Id"},"user_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"User Id"},"agent_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Id"},"attributes":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Attributes"},"references":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelReference-Output"},"type":"array"},{"type":"null"}],"title":"References"},"links":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelLink-Output"},"type":"array"},{"type":"null"}],"title":"Links"},"hashes":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelHash-Output"},"type":"array"},{"type":"null"}],"title":"Hashes"},"exception":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Exception"},"events":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelEvent-Output"},"type":"array"},{"type":"null"}],"title":"Events"}},"type":"object","required":["trace_id","span_id"],"title":"SpansNode"},"SpansQueryRequest":{"properties":{"filtering":{"anyOf":[{"$ref":"#/components/schemas/Filtering-Input"},{"type":"null"}],"description":"Span-level conditions."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor pagination and time range."},"query_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Resolve filtering/windowing from a saved query."},"query_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Resolve from the latest revision of a specific query variant."},"query_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Resolve from a specific query revision. Returns `409` when the revision's stored `formatting.focus` is `trace`."}},"type":"object","title":"SpansQueryRequest","description":"Request body for `POST /spans/query`."},"SpansResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Total number of matching spans in the window.","default":0},"spans":{"anyOf":[{"items":{"$ref":"#/components/schemas/Span-Output"},"type":"array"},{"type":"null"}],"title":"Spans","description":"Flat list of matching spans."}},"type":"object","title":"SpansResponse"},"SpansTree-Input":{"properties":{"spans":{"anyOf":[{"additionalProperties":{"anyOf":[{"$ref":"#/components/schemas/SpansNode-Input"},{"items":{"$ref":"#/components/schemas/SpansNode-Input"},"type":"array"}]},"type":"object"},{"type":"null"}],"title":"Spans"}},"type":"object","title":"SpansTree"},"SpansTree-Output":{"properties":{"spans":{"anyOf":[{"additionalProperties":{"anyOf":[{"$ref":"#/components/schemas/SpansNode-Output"},{"items":{"$ref":"#/components/schemas/SpansNode-Output"},"type":"array"}]},"type":"object"},{"type":"null"}],"title":"Spans"}},"type":"object","title":"SpansTree"},"StandardProviderDTO":{"properties":{"kind":{"$ref":"#/components/schemas/StandardProviderKind"},"provider":{"$ref":"#/components/schemas/StandardProviderSettingsDTO"},"models":{"anyOf":[{"items":{"$ref":"#/components/schemas/CustomModelSettingsDTO"},"type":"array"},{"type":"null"}],"title":"Models"},"harnesses":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Harnesses"}},"type":"object","required":["kind","provider"],"title":"StandardProviderDTO"},"StandardProviderKind":{"type":"string","enum":["openai","cohere","anyscale","deepinfra","alephalpha","groq","minimax","mistral","mistralai","anthropic","perplexityai","together_ai","openrouter","gemini"],"title":"StandardProviderKind"},"StandardProviderSettingsDTO":{"properties":{"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key"}},"type":"object","title":"StandardProviderSettingsDTO"},"Status":{"properties":{"timestamp":{"type":"string","format":"date-time","title":"Timestamp"},"type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Type"},"code":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Code"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"stacktrace":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Stacktrace"}},"type":"object","title":"Status"},"StringOperator":{"type":"string","enum":["startswith","endswith","contains","matches","like"],"title":"StringOperator"},"Testcase-Input":{"properties":{"testset_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Id"},"set_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Set Id"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"data":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Data"}},"type":"object","title":"Testcase"},"Testcase-Output":{"properties":{"testset_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Id"},"set_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Set Id"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"data":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Data"}},"type":"object","title":"Testcase"},"TestcaseResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"1 if a testcase was returned, 0 otherwise.","default":0},"testcase":{"anyOf":[{"$ref":"#/components/schemas/Testcase-Output"},{"type":"null"}],"description":"The testcase blob. `data` carries the user-defined columns; `testcase_dedup_id` (inside `data`) is the caller-supplied dedup key when present."}},"type":"object","title":"TestcaseResponse"},"TestcasesQueryRequest":{"properties":{"testcase_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Testcase Ids","description":"Explicit list of testcase IDs to fetch. Combine with `testset_id` or testset references to scope the lookup."},"testset_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Id","description":"Return all testcases stored in this testset. The testset owns its testcases as a content-addressed bag; a revision references a subset of these."},"testset_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Testset reference used to resolve the latest revision on the default variant. The revision's ordered testcase IDs are used for the lookup and pagination."},"testset_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Testset variant reference used to resolve the latest revision on that variant."},"testset_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Specific testset revision reference. The revision's ordered testcase IDs drive the lookup and cursor pagination."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor-based pagination. When a revision reference is used, the cursor walks the revision's deterministic testcase ID list."}},"type":"object","title":"TestcasesQueryRequest"},"TestcasesResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of testcases returned on this page.","default":0},"testcases":{"items":{"$ref":"#/components/schemas/Testcase-Output"},"type":"array","title":"Testcases","description":"Testcase blobs matching the query, in revision-order when scoped by a revision reference."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor for the next page, if more results exist."}},"type":"object","title":"TestcasesResponse"},"Testset":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"Testset"},"TestsetCreate":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"TestsetCreate"},"TestsetCreateRequest":{"properties":{"testset":{"$ref":"#/components/schemas/TestsetCreate","description":"Testset artifact to create. The call only creates the artifact row; testcases are added by committing a revision (see /testsets/revisions/commit) or by using the /simple/testsets/ surface."}},"type":"object","required":["testset"],"title":"TestsetCreateRequest"},"TestsetEdit":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"TestsetEdit"},"TestsetEditRequest":{"properties":{"testset":{"$ref":"#/components/schemas/TestsetEdit","description":"Testset artifact fields to update. The `id` in the body must match the `testset_id` in the path."}},"type":"object","required":["testset"],"title":"TestsetEditRequest"},"TestsetFlags":{"properties":{},"type":"object","title":"TestsetFlags","description":"Placeholder for testset-level flags.\n\nThis model is intentionally empty but kept as a dedicated type so that:\n- existing references to `flags: Optional[TestsetFlags]` remain valid, and\n- structured flags can be added here in the future without breaking the\n surrounding DTOs."},"TestsetQuery":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"slugs":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Slugs"}},"type":"object","title":"TestsetQuery"},"TestsetQueryRequest":{"properties":{"testset":{"anyOf":[{"$ref":"#/components/schemas/TestsetQuery"},{"type":"null"}],"description":"Attribute filter (name, description, slug, flags, tags, meta, folder)."},"testset_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Testset Refs","description":"Restrict the query to specific testsets by reference (id or slug)."},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived","description":"Include soft-deleted testsets."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor-based pagination. See the Query Pattern guide."}},"type":"object","title":"TestsetQueryRequest"},"TestsetResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"1 if a testset was returned, 0 otherwise.","default":0},"testset":{"anyOf":[{"$ref":"#/components/schemas/Testset"},{"type":"null"}],"description":"The testset artifact. Does not include testcases."}},"type":"object","title":"TestsetResponse"},"TestsetRevision":{"properties":{"testset_variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Testset Variant Slug"},"variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Variant Slug"},"testset_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Testset Slug"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"testset_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"testset_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"author":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Author"},"date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Date"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/TestsetRevisionData-Output"},{"type":"null"}]}},"type":"object","title":"TestsetRevision"},"TestsetRevisionCommit":{"properties":{"testset_revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Revision Id"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"},"testset_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"testset_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"data":{"anyOf":[{"$ref":"#/components/schemas/TestsetRevisionData-Input"},{"type":"null"}]},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"delta":{"anyOf":[{"$ref":"#/components/schemas/TestsetRevisionDelta"},{"type":"null"}]}},"type":"object","title":"TestsetRevisionCommit"},"TestsetRevisionCommitRequest":{"properties":{"testset_revision":{"$ref":"#/components/schemas/TestsetRevisionCommit","description":"New revision to commit. Pass either `data` (full replacement of the testcase list) or `delta` (add/remove/replace operations against the base revision) — not both."},"include_testcases":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Testcases","description":"Include full testcase objects in the response."}},"type":"object","required":["testset_revision"],"title":"TestsetRevisionCommitRequest"},"TestsetRevisionCreate":{"properties":{"testset_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"testset_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"TestsetRevisionCreate"},"TestsetRevisionCreateRequest":{"properties":{"testset_revision":{"$ref":"#/components/schemas/TestsetRevisionCreate","description":"Revision to create on an existing variant. Typically used to seed an empty revision; use /testsets/revisions/commit to set testcases."},"include_testcases":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Testcases","description":"Include full testcase objects in the response. Defaults to true when the response would carry revision data."}},"type":"object","required":["testset_revision"],"title":"TestsetRevisionCreateRequest"},"TestsetRevisionData-Input":{"properties":{"testcase_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Testcase Ids"},"testcases":{"anyOf":[{"items":{"$ref":"#/components/schemas/Testcase-Input"},"type":"array"},{"type":"null"}],"title":"Testcases"}},"additionalProperties":false,"type":"object","title":"TestsetRevisionData"},"TestsetRevisionData-Output":{"properties":{"testcase_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Testcase Ids"},"testcases":{"anyOf":[{"items":{"$ref":"#/components/schemas/Testcase-Output"},"type":"array"},{"type":"null"}],"title":"Testcases"}},"additionalProperties":false,"type":"object","title":"TestsetRevisionData"},"TestsetRevisionDelta":{"properties":{"rows":{"anyOf":[{"$ref":"#/components/schemas/TestsetRevisionDeltaRows"},{"type":"null"}]},"columns":{"anyOf":[{"$ref":"#/components/schemas/TestsetRevisionDeltaColumns"},{"type":"null"}]}},"type":"object","title":"TestsetRevisionDelta","description":"Operations to apply to a testset revision."},"TestsetRevisionDeltaColumns":{"properties":{"add":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Add"},"remove":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Remove"},"replace":{"anyOf":[{"items":{"prefixItems":[{"type":"string"},{"type":"string"}],"type":"array","maxItems":2,"minItems":2},"type":"array"},{"type":"null"}],"title":"Replace"}},"type":"object","title":"TestsetRevisionDeltaColumns","description":"Column-level operations applied to ALL testcases in the revision."},"TestsetRevisionDeltaRows":{"properties":{"add":{"anyOf":[{"items":{"$ref":"#/components/schemas/Testcase-Input"},"type":"array"},{"type":"null"}],"title":"Add"},"remove":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Remove"},"replace":{"anyOf":[{"items":{"$ref":"#/components/schemas/Testcase-Input"},"type":"array"},{"type":"null"}],"title":"Replace"}},"type":"object","title":"TestsetRevisionDeltaRows","description":"Row-level operations applied to testcases in the revision."},"TestsetRevisionEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"TestsetRevisionEdit"},"TestsetRevisionEditRequest":{"properties":{"testset_revision":{"$ref":"#/components/schemas/TestsetRevisionEdit","description":"Revision fields to update. The `id` in the body must match the `testset_revision_id` in the path. Only metadata fields are editable; content is committed as a new revision."},"include_testcases":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Testcases","description":"Include full testcase objects in the response."}},"type":"object","required":["testset_revision"],"title":"TestsetRevisionEditRequest"},"TestsetRevisionQuery":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"slugs":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Slugs"},"author":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Author"},"authors":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Authors"},"date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Date"},"dates":{"anyOf":[{"items":{"type":"string","format":"date-time"},"type":"array"},{"type":"null"}],"title":"Dates"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"}},"type":"object","title":"TestsetRevisionQuery"},"TestsetRevisionQueryRequest":{"properties":{"testset_revision":{"anyOf":[{"$ref":"#/components/schemas/TestsetRevisionQuery"},{"type":"null"}],"description":"Attribute filter on the revision (name, description, slug, author, date, message)."},"testset_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Testset Refs","description":"Scope revisions to these testsets."},"testset_variant_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Testset Variant Refs","description":"Scope revisions to these variants."},"testset_revision_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Testset Revision Refs","description":"Restrict to specific revisions by reference (id, slug, or version)."},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived","description":"Include soft-deleted revisions."},"include_testcases":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Testcases","description":"Include full testcase objects for each returned revision. Defaults to true."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor-based pagination. See the Query Pattern guide."}},"type":"object","title":"TestsetRevisionQueryRequest"},"TestsetRevisionResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"1 if a revision was returned, 0 otherwise.","default":0},"testset_revision":{"anyOf":[{"$ref":"#/components/schemas/TestsetRevision"},{"type":"null"}],"description":"The testset revision. `data.testcase_ids` is the ordered list of testcase IDs; `data.testcases` is populated when `include_testcases` is true."},"retrieval_info":{"anyOf":[{"$ref":"#/components/schemas/RetrievalInfo"},{"type":"null"}],"description":"References used to retrieve the top-level revision."}},"type":"object","title":"TestsetRevisionResponse"},"TestsetRevisionRetrieveRequest":{"properties":{"testset_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Testset artifact to look up. Identifies the artifact by `id` or `slug` (both project-unique). When no variant_ref or revision_ref is provided, returns the latest revision of an arbitrary variant of this testset."},"testset_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Testset variant to look up. Identifies the variant by `id` or `slug` (both project-unique). When no revision_ref is provided, returns the latest revision of this variant."},"testset_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Testset revision to look up. `id` alone identifies a revision (project-unique). `slug` alone identifies a revision (project-unique). `version` alone is a per-variant sequence number and is **not** sufficient on its own; it must be combined with a `testset_variant_ref`. Sending only `version` without a variant ref returns HTTP 400."},"include_testcase_ids":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Testcase Ids","description":"Include the ordered list of testcase IDs. Defaults to true (opt-out)."},"include_testcases":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Testcases","description":"Include full testcase objects. Defaults to true (opt-out). Note: this opt-out default is the opposite of `/queries/revisions/retrieve`, where trace materialization is opt-in."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Windowing applied to the testcases list when materialized."}},"type":"object","title":"TestsetRevisionRetrieveRequest"},"TestsetRevisionsLog":{"properties":{"testset_revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Revision Id"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"},"testset_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"testset_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"depth":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Depth"}},"type":"object","title":"TestsetRevisionsLog"},"TestsetRevisionsLogRequest":{"properties":{"testset_revisions":{"$ref":"#/components/schemas/TestsetRevisionsLog","description":"Scope for the log: one of `testset_id`, `testset_variant_id`, or `testset_revision_id`. Optional `depth` limits how far back to walk."},"include_testcases":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Testcases","description":"Include full testcase objects for each returned revision."}},"type":"object","required":["testset_revisions"],"title":"TestsetRevisionsLogRequest"},"TestsetRevisionsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of revisions returned.","default":0},"testset_revisions":{"items":{"$ref":"#/components/schemas/TestsetRevision"},"type":"array","title":"Testset Revisions","description":"Testset revisions matching the query, in the requested order."}},"type":"object","title":"TestsetRevisionsResponse"},"TestsetVariant":{"properties":{"testset_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Testset Slug"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"testset_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"TestsetVariant"},"TestsetVariantCreate":{"properties":{"testset_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"TestsetVariantCreate"},"TestsetVariantCreateRequest":{"properties":{"testset_variant":{"$ref":"#/components/schemas/TestsetVariantCreate","description":"Variant to create on an existing testset. Pass `testset_id` to identify the parent artifact."}},"type":"object","required":["testset_variant"],"title":"TestsetVariantCreateRequest"},"TestsetVariantEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"TestsetVariantEdit"},"TestsetVariantEditRequest":{"properties":{"testset_variant":{"$ref":"#/components/schemas/TestsetVariantEdit","description":"Variant fields to update. The `id` in the body must match the `testset_variant_id` in the path."}},"type":"object","required":["testset_variant"],"title":"TestsetVariantEditRequest"},"TestsetVariantFork":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"TestsetVariantFork"},"TestsetVariantForkRequest":{"properties":{"testset_variant":{"$ref":"#/components/schemas/TestsetVariantFork","description":"Config for the new variant (slug, name, description, flags)."},"testset_variant_ref":{"$ref":"#/components/schemas/Reference","description":"Source variant to fork from."},"testset_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Pin the fork to this revision; defaults to the source variant's head."}},"type":"object","required":["testset_variant","testset_variant_ref"],"title":"TestsetVariantForkRequest"},"TestsetVariantQuery":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"slugs":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Slugs"}},"type":"object","title":"TestsetVariantQuery"},"TestsetVariantQueryRequest":{"properties":{"testset_variant":{"anyOf":[{"$ref":"#/components/schemas/TestsetVariantQuery"},{"type":"null"}],"description":"Attribute filter on the variant (name, description, slug, flags, tags, meta)."},"testset_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Testset Refs","description":"Scope to variants whose parent testset matches one of these references."},"testset_variant_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Testset Variant Refs","description":"Restrict the query to specific variants by reference (id or slug)."},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived","description":"Include soft-deleted variants."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor-based pagination. See the Query Pattern guide."}},"type":"object","title":"TestsetVariantQueryRequest"},"TestsetVariantResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"1 if a variant was returned, 0 otherwise.","default":0},"testset_variant":{"anyOf":[{"$ref":"#/components/schemas/TestsetVariant"},{"type":"null"}],"description":"The testset variant (branch)."}},"type":"object","title":"TestsetVariantResponse"},"TestsetVariantsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of variants returned.","default":0},"testset_variants":{"items":{"$ref":"#/components/schemas/TestsetVariant"},"type":"array","title":"Testset Variants","description":"Testset variants matching the query."}},"type":"object","title":"TestsetVariantsResponse"},"TestsetsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of testsets returned on this page.","default":0},"testsets":{"items":{"$ref":"#/components/schemas/Testset"},"type":"array","title":"Testsets","description":"Testset artifacts matching the query, without testcases."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor for the next page, if more results exist."}},"type":"object","title":"TestsetsResponse"},"TextOptions":{"properties":{"case_sensitive":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Case Sensitive","default":false},"exact_match":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Exact Match","default":false}},"type":"object","title":"TextOptions"},"ToolAuthScheme":{"type":"string","enum":["oauth","api_key"],"title":"ToolAuthScheme"},"ToolCall":{"properties":{"data":{"$ref":"#/components/schemas/ToolCallData"}},"type":"object","required":["data"],"title":"ToolCall","description":"Request envelope — wraps the raw OpenAI tool call."},"ToolCallData":{"properties":{"id":{"type":"string","title":"Id"},"type":{"type":"string","title":"Type","default":"function"},"function":{"$ref":"#/components/schemas/ToolCallFunction"}},"type":"object","required":["id","function"],"title":"ToolCallData","description":"OpenAI tool_calls array item — passed verbatim from the LLM."},"ToolCallFunction":{"properties":{"name":{"type":"string","title":"Name"},"arguments":{"title":"Arguments"}},"type":"object","required":["name","arguments"],"title":"ToolCallFunction","description":"Mirrors OpenAI function call: {name, arguments}."},"ToolCallResponse":{"properties":{"call":{"$ref":"#/components/schemas/ToolResult"}},"type":"object","required":["call"],"title":"ToolCallResponse"},"ToolCatalogAction":{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories","default":[]},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"read_only":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Read Only"}},"type":"object","required":["key","name"],"title":"ToolCatalogAction"},"ToolCatalogActionDetails":{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories","default":[]},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"read_only":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Read Only"},"schemas":{"anyOf":[{"$ref":"#/components/schemas/JsonSchemas-Output"},{"type":"null"}]},"scopes":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Scopes"}},"type":"object","required":["key","name"],"title":"ToolCatalogActionDetails"},"ToolCatalogActionResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"action":{"anyOf":[{"$ref":"#/components/schemas/ToolCatalogAction"},{"$ref":"#/components/schemas/ToolCatalogActionDetails"},{"type":"null"}],"title":"Action"}},"type":"object","title":"ToolCatalogActionResponse"},"ToolCatalogActionsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"total":{"type":"integer","title":"Total","default":0},"cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"},"actions":{"items":{"anyOf":[{"$ref":"#/components/schemas/ToolCatalogAction"},{"$ref":"#/components/schemas/ToolCatalogActionDetails"}]},"type":"array","title":"Actions","default":[]}},"type":"object","title":"ToolCatalogActionsResponse"},"ToolCatalogCategoriesResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"categories":{"items":{"$ref":"#/components/schemas/ToolCatalogCategory"},"type":"array","title":"Categories","default":[]}},"type":"object","title":"ToolCatalogCategoriesResponse"},"ToolCatalogCategory":{"properties":{"id":{"type":"string","title":"Id"},"name":{"type":"string","title":"Name"}},"type":"object","required":["id","name"],"title":"ToolCatalogCategory"},"ToolCatalogIntegration":{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories","default":[]},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"actions_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Actions Count"},"auth_schemes":{"anyOf":[{"items":{"$ref":"#/components/schemas/ToolAuthScheme"},"type":"array"},{"type":"null"}],"title":"Auth Schemes"}},"type":"object","required":["key","name"],"title":"ToolCatalogIntegration"},"ToolCatalogIntegrationDetails":{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories","default":[]},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"actions_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Actions Count"},"auth_schemes":{"anyOf":[{"items":{"$ref":"#/components/schemas/ToolAuthScheme"},"type":"array"},{"type":"null"}],"title":"Auth Schemes"},"actions":{"anyOf":[{"items":{"$ref":"#/components/schemas/ToolCatalogAction"},"type":"array"},{"type":"null"}],"title":"Actions"}},"type":"object","required":["key","name"],"title":"ToolCatalogIntegrationDetails"},"ToolCatalogIntegrationResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"integration":{"anyOf":[{"$ref":"#/components/schemas/ToolCatalogIntegration"},{"$ref":"#/components/schemas/ToolCatalogIntegrationDetails"},{"type":"null"}],"title":"Integration"}},"type":"object","title":"ToolCatalogIntegrationResponse"},"ToolCatalogIntegrationsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"total":{"type":"integer","title":"Total","default":0},"cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"},"integrations":{"items":{"anyOf":[{"$ref":"#/components/schemas/ToolCatalogIntegration"},{"$ref":"#/components/schemas/ToolCatalogIntegrationDetails"}]},"type":"array","title":"Integrations","default":[]}},"type":"object","title":"ToolCatalogIntegrationsResponse"},"ToolCatalogProvider":{"properties":{"key":{"$ref":"#/components/schemas/ToolProviderKind"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"integrations_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Integrations Count"}},"type":"object","required":["key","name"],"title":"ToolCatalogProvider"},"ToolCatalogProviderDetails":{"properties":{"key":{"$ref":"#/components/schemas/ToolProviderKind"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"integrations_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Integrations Count"},"integrations":{"anyOf":[{"items":{"$ref":"#/components/schemas/ToolCatalogIntegration"},"type":"array"},{"type":"null"}],"title":"Integrations"}},"type":"object","required":["key","name"],"title":"ToolCatalogProviderDetails"},"ToolCatalogProviderResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"provider":{"anyOf":[{"$ref":"#/components/schemas/ToolCatalogProvider"},{"$ref":"#/components/schemas/ToolCatalogProviderDetails"},{"type":"null"}],"title":"Provider"}},"type":"object","title":"ToolCatalogProviderResponse"},"ToolCatalogProvidersResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"providers":{"items":{"anyOf":[{"$ref":"#/components/schemas/ToolCatalogProvider"},{"$ref":"#/components/schemas/ToolCatalogProviderDetails"}]},"type":"array","title":"Providers","default":[]}},"type":"object","title":"ToolCatalogProvidersResponse"},"ToolConnection":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"provider_key":{"$ref":"#/components/schemas/ToolProviderKind"},"integration_key":{"type":"string","title":"Integration Key"},"data":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Data"},"status":{"anyOf":[{"$ref":"#/components/schemas/ToolConnectionStatus"},{"type":"null"}]}},"type":"object","required":["provider_key","integration_key"],"title":"ToolConnection"},"ToolConnectionCreate":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"provider_key":{"$ref":"#/components/schemas/ToolProviderKind"},"integration_key":{"type":"string","title":"Integration Key"},"data":{"anyOf":[{"$ref":"#/components/schemas/ToolConnectionCreateData"},{"type":"null"}]}},"type":"object","required":["provider_key","integration_key"],"title":"ToolConnectionCreate"},"ToolConnectionCreateData":{"properties":{"callback_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Callback Url"},"auth_scheme":{"anyOf":[{"$ref":"#/components/schemas/ToolAuthScheme"},{"type":"null"}]},"connected_account_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Connected Account Id"},"auth_config_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Auth Config Id"},"redirect_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Redirect Url"},"project_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Id"},"no_auth":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"No Auth"}},"type":"object","title":"ToolConnectionCreateData"},"ToolConnectionCreateRequest":{"properties":{"connection":{"$ref":"#/components/schemas/ToolConnectionCreate"}},"type":"object","required":["connection"],"title":"ToolConnectionCreateRequest"},"ToolConnectionResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"connection":{"anyOf":[{"$ref":"#/components/schemas/ToolConnection"},{"type":"null"}]}},"type":"object","title":"ToolConnectionResponse"},"ToolConnectionState":{"type":"string","enum":["ready","needs_auth","needs_input"],"title":"ToolConnectionState","description":"The connection state of one integration, derived per the design's state\nmachine. ``ready`` reuses an existing connection; the other two need a human."},"ToolConnectionStatus":{"properties":{"redirect_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Redirect Url"}},"type":"object","title":"ToolConnectionStatus"},"ToolConnectionsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"connections":{"items":{"$ref":"#/components/schemas/ToolConnection"},"type":"array","title":"Connections","default":[]}},"type":"object","title":"ToolConnectionsResponse"},"ToolProviderKind":{"type":"string","enum":["composio","agenta"],"title":"ToolProviderKind"},"ToolResolveRequest":{"properties":{"tools":{"items":{"anyOf":[{"$ref":"#/components/schemas/BuiltinToolConfig"},{"$ref":"#/components/schemas/GatewayToolConfig"}]},"type":"array","title":"Tools"}},"type":"object","title":"ToolResolveRequest"},"ToolResolveResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"builtins":{"items":{"type":"string"},"type":"array","title":"Builtins"},"custom":{"items":{"$ref":"#/components/schemas/ResolvedTool"},"type":"array","title":"Custom"}},"type":"object","title":"ToolResolveResponse"},"ToolResult":{"properties":{"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"status":{"anyOf":[{"$ref":"#/components/schemas/Status"},{"type":"null"}]},"data":{"anyOf":[{"$ref":"#/components/schemas/ToolResultData"},{"type":"null"}]}},"type":"object","title":"ToolResult","description":"Response envelope with Agenta identity, status, and the OpenAI tool message."},"ToolResultData":{"properties":{"role":{"type":"string","title":"Role","default":"tool"},"tool_call_id":{"type":"string","title":"Tool Call Id"},"content":{"type":"string","title":"Content"}},"type":"object","required":["tool_call_id","content"],"title":"ToolResultData","description":"OpenAI tool message — passed verbatim back to the LLM."},"Trace-Input":{"properties":{"spans":{"anyOf":[{"additionalProperties":{"anyOf":[{"$ref":"#/components/schemas/SpansNode-Input"},{"items":{"$ref":"#/components/schemas/SpansNode-Input"},"type":"array"}]},"type":"object"},{"type":"null"}],"title":"Spans"},"trace_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Trace Id"}},"type":"object","title":"Trace"},"Trace-Output":{"properties":{"spans":{"anyOf":[{"additionalProperties":{"anyOf":[{"$ref":"#/components/schemas/SpansNode-Output"},{"items":{"$ref":"#/components/schemas/SpansNode-Output"},"type":"array"}]},"type":"object"},{"type":"null"}],"title":"Spans"},"trace_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Trace Id"}},"type":"object","title":"Trace"},"TraceIdResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` if a `trace_id` was returned, `0` otherwise.","default":0},"trace_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Trace Id","description":"32-char hex UUID identifying the trace that was created or edited."}},"type":"object","title":"TraceIdResponse"},"TraceIdsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of distinct trace IDs in this response.","default":0},"trace_ids":{"items":{"type":"string"},"type":"array","title":"Trace Ids","description":"32-char hex UUIDs of the traces that were ingested. Compare against the number you submitted to detect partial failures.","default":[]}},"type":"object","title":"TraceIdsResponse"},"TraceRequest":{"properties":{"trace":{"anyOf":[{"$ref":"#/components/schemas/Trace-Input"},{"type":"null"}],"description":"A single trace record (trace_id plus nested spans). The `trace_id` must match the path parameter on edit endpoints."}},"type":"object","title":"TraceRequest","description":"Ingest or edit payload for a single canonical `Trace`."},"TraceResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` if a trace was returned, `0` otherwise.","default":0},"trace":{"anyOf":[{"$ref":"#/components/schemas/Trace-Output"},{"type":"null"}],"description":"The trace in the canonical `Trace` shape (`trace_id` + nested `spans` tree)."}},"type":"object","title":"TraceResponse"},"TraceType":{"type":"string","enum":["invocation","annotation","unknown"],"title":"TraceType"},"TracesQueryRequest":{"properties":{"filtering":{"anyOf":[{"$ref":"#/components/schemas/Filtering-Input"},{"type":"null"}],"description":"Span-level conditions. A trace matches when any of its spans matches."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor pagination and time range (see [Query Pattern](/reference/api-guide/query-pattern#windowing))."},"query_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Resolve filtering/windowing from a saved query by `id`/`slug`. Only one of the three `query_*_ref` fields is needed."},"query_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Resolve from the latest revision of a specific query variant."},"query_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Resolve from a specific query revision. Returns `409` when the revision's stored `formatting.focus` is `span`."}},"type":"object","title":"TracesQueryRequest","description":"Request body for `POST /traces/query`."},"TracesRequest":{"properties":{"traces":{"anyOf":[{"items":{"$ref":"#/components/schemas/Trace-Input"},"type":"array"},{"type":"null"}],"title":"Traces","description":"List of trace records. Each record is a `trace_id` plus the nested `spans` tree. Equivalent to the map-shaped payload accepted by `POST /tracing/spans/ingest`."}},"type":"object","title":"TracesRequest","description":"Ingest payload in the canonical `Traces` list shape.\n\nUsed by `POST /traces/ingest`. Each entry is one trace with its\n`trace_id` and a nested `spans` tree."},"TracesResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Total number of matching traces in the window.","default":0},"traces":{"anyOf":[{"items":{"$ref":"#/components/schemas/Trace-Output"},"type":"array"},{"type":"null"}],"title":"Traces","description":"List of traces in the canonical `Traces` shape. For the map-shaped payload keyed by `trace_id`, call `POST /tracing/spans/query` with `focus=\"trace\"`."}},"type":"object","title":"TracesResponse"},"TracingQuery":{"properties":{"formatting":{"anyOf":[{"$ref":"#/components/schemas/Formatting"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]},"filtering":{"anyOf":[{"$ref":"#/components/schemas/Filtering-Output"},{"type":"null"}]}},"type":"object","title":"TracingQuery"},"TriggerAuthScheme":{"type":"string","enum":["oauth","api_key"],"title":"TriggerAuthScheme"},"TriggerCapabilitiesResult":{"properties":{"capabilities":{"items":{"$ref":"#/components/schemas/TriggerCapability"},"type":"array","title":"Capabilities"},"connections":{"items":{"$ref":"#/components/schemas/TriggerConnectionRequirement"},"type":"array","title":"Connections"},"guidance":{"$ref":"#/components/schemas/TriggerDiscoveryGuidance"},"ready":{"type":"boolean","title":"Ready","default":false},"notes":{"items":{"type":"string"},"type":"array","title":"Notes"}},"type":"object","title":"TriggerCapabilitiesResult"},"TriggerCapability":{"properties":{"use_case":{"type":"string","title":"Use Case"},"integration":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Integration"},"event":{"anyOf":[{"$ref":"#/components/schemas/DiscoveredTriggerEvent"},{"type":"null"}]},"alternatives":{"items":{"$ref":"#/components/schemas/DiscoveredTriggerAlternative"},"type":"array","title":"Alternatives"},"connection":{"anyOf":[{"$ref":"#/components/schemas/TriggerCapabilityConnection"},{"type":"null"}]},"note":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Note"}},"type":"object","required":["use_case"],"title":"TriggerCapability"},"TriggerCapabilityConnection":{"properties":{"state":{"$ref":"#/components/schemas/TriggerDiscoveryConnectionState"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","required":["state"],"title":"TriggerCapabilityConnection"},"TriggerCatalogEvent":{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"provider":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider"},"integration":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Integration"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories"},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"}},"type":"object","required":["key","name"],"title":"TriggerCatalogEvent"},"TriggerCatalogEventDetails":{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"provider":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider"},"integration":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Integration"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories"},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"trigger_config":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Trigger Config"},"payload":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Payload"}},"type":"object","required":["key","name"],"title":"TriggerCatalogEventDetails"},"TriggerCatalogEventResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"event":{"anyOf":[{"$ref":"#/components/schemas/TriggerCatalogEventDetails"},{"type":"null"}]}},"type":"object","title":"TriggerCatalogEventResponse"},"TriggerCatalogEventsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"total":{"type":"integer","title":"Total","default":0},"cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"},"events":{"items":{"$ref":"#/components/schemas/TriggerCatalogEvent"},"type":"array","title":"Events"}},"type":"object","title":"TriggerCatalogEventsResponse"},"TriggerCatalogIntegration":{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories","default":[]},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"actions_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Actions Count"},"auth_schemes":{"anyOf":[{"items":{"$ref":"#/components/schemas/TriggerAuthScheme"},"type":"array"},{"type":"null"}],"title":"Auth Schemes"}},"type":"object","required":["key","name"],"title":"TriggerCatalogIntegration"},"TriggerCatalogIntegrationResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"integration":{"anyOf":[{"$ref":"#/components/schemas/TriggerCatalogIntegration"},{"type":"null"}]}},"type":"object","title":"TriggerCatalogIntegrationResponse"},"TriggerCatalogIntegrationsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"total":{"type":"integer","title":"Total","default":0},"cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"},"integrations":{"items":{"$ref":"#/components/schemas/TriggerCatalogIntegration"},"type":"array","title":"Integrations"}},"type":"object","title":"TriggerCatalogIntegrationsResponse"},"TriggerCatalogProvider":{"properties":{"key":{"$ref":"#/components/schemas/TriggerProviderKind"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"integrations_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Integrations Count"}},"type":"object","required":["key","name"],"title":"TriggerCatalogProvider"},"TriggerCatalogProviderResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"provider":{"anyOf":[{"$ref":"#/components/schemas/TriggerCatalogProvider"},{"type":"null"}]}},"type":"object","title":"TriggerCatalogProviderResponse"},"TriggerCatalogProvidersResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"providers":{"items":{"$ref":"#/components/schemas/TriggerCatalogProvider"},"type":"array","title":"Providers"}},"type":"object","title":"TriggerCatalogProvidersResponse"},"TriggerConnectAffordance":{"properties":{"endpoint":{"type":"string","title":"Endpoint","default":"POST /triggers/connections/"},"body":{"additionalProperties":true,"type":"object","title":"Body"}},"type":"object","required":["body"],"title":"TriggerConnectAffordance"},"TriggerConnection":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"provider_key":{"$ref":"#/components/schemas/TriggerProviderKind"},"integration_key":{"type":"string","title":"Integration Key"},"data":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Data"},"status":{"anyOf":[{"$ref":"#/components/schemas/TriggerConnectionStatus"},{"type":"null"}]}},"type":"object","required":["provider_key","integration_key"],"title":"TriggerConnection"},"TriggerConnectionCreate":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"provider_key":{"$ref":"#/components/schemas/TriggerProviderKind"},"integration_key":{"type":"string","title":"Integration Key"},"data":{"anyOf":[{"$ref":"#/components/schemas/TriggerConnectionCreateData"},{"type":"null"}]}},"type":"object","required":["provider_key","integration_key"],"title":"TriggerConnectionCreate"},"TriggerConnectionCreateData":{"properties":{"callback_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Callback Url"},"auth_scheme":{"anyOf":[{"$ref":"#/components/schemas/TriggerAuthScheme"},{"type":"null"}]},"connected_account_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Connected Account Id"},"auth_config_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Auth Config Id"},"redirect_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Redirect Url"},"project_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Id"},"no_auth":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"No Auth"}},"type":"object","title":"TriggerConnectionCreateData"},"TriggerConnectionCreateRequest":{"properties":{"connection":{"$ref":"#/components/schemas/TriggerConnectionCreate"}},"type":"object","required":["connection"],"title":"TriggerConnectionCreateRequest"},"TriggerConnectionRequirement":{"properties":{"integration":{"type":"string","title":"Integration"},"state":{"$ref":"#/components/schemas/TriggerDiscoveryConnectionState"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"connect":{"anyOf":[{"$ref":"#/components/schemas/TriggerConnectAffordance"},{"type":"null"}]}},"type":"object","required":["integration","state"],"title":"TriggerConnectionRequirement"},"TriggerConnectionResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"connection":{"anyOf":[{"$ref":"#/components/schemas/TriggerConnection"},{"type":"null"}]}},"type":"object","title":"TriggerConnectionResponse"},"TriggerConnectionStatus":{"properties":{"redirect_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Redirect Url"}},"type":"object","title":"TriggerConnectionStatus"},"TriggerConnectionsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"connections":{"items":{"$ref":"#/components/schemas/TriggerConnection"},"type":"array","title":"Connections"}},"type":"object","title":"TriggerConnectionsResponse"},"TriggerDeliveriesResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"deliveries":{"items":{"$ref":"#/components/schemas/TriggerDelivery"},"type":"array","title":"Deliveries"}},"type":"object","title":"TriggerDeliveriesResponse"},"TriggerDelivery":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"status":{"$ref":"#/components/schemas/Status"},"data":{"anyOf":[{"$ref":"#/components/schemas/TriggerDeliveryData"},{"type":"null"}]},"subscription_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Subscription Id"},"schedule_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Schedule Id"},"event_id":{"type":"string","title":"Event Id"}},"type":"object","required":["status","event_id"],"title":"TriggerDelivery"},"TriggerDeliveryData":{"properties":{"event_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Event Key"},"session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Session Id"},"references":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/Reference"},"type":"object"},{"type":"null"}],"title":"References"},"inputs":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Inputs"},"result":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Result"},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"},"is_test":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Test"}},"type":"object","title":"TriggerDeliveryData"},"TriggerDeliveryQuery":{"properties":{"status":{"anyOf":[{"$ref":"#/components/schemas/Status"},{"type":"null"}]},"subscription_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Subscription Id"},"schedule_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Schedule Id"},"event_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Event Id"}},"type":"object","title":"TriggerDeliveryQuery"},"TriggerDeliveryQueryRequest":{"properties":{"delivery":{"anyOf":[{"$ref":"#/components/schemas/TriggerDeliveryQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"TriggerDeliveryQueryRequest"},"TriggerDeliveryResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"delivery":{"anyOf":[{"$ref":"#/components/schemas/TriggerDelivery"},{"type":"null"}]}},"type":"object","title":"TriggerDeliveryResponse"},"TriggerDiscoveryConnectionState":{"type":"string","enum":["ready","needs_auth","needs_input"],"title":"TriggerDiscoveryConnectionState"},"TriggerDiscoveryGuidance":{"properties":{"plan_steps":{"items":{"type":"string"},"type":"array","title":"Plan Steps"},"pitfalls":{"items":{"type":"string"},"type":"array","title":"Pitfalls"}},"type":"object","title":"TriggerDiscoveryGuidance"},"TriggerDiscoveryQuery":{"properties":{"use_cases":{"items":{"type":"string"},"type":"array","title":"Use Cases"},"provider":{"type":"string","title":"Provider","default":"composio"},"limit_alternatives":{"type":"integer","minimum":0.0,"title":"Limit Alternatives","default":3}},"type":"object","required":["use_cases"],"title":"TriggerDiscoveryQuery","description":"Request body for ``POST /triggers/discover``."},"TriggerEventAck":{"properties":{"status":{"type":"string","title":"Status","default":"accepted"},"detail":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Detail"}},"type":"object","title":"TriggerEventAck"},"TriggerProviderKind":{"type":"string","enum":["composio"],"title":"TriggerProviderKind"},"TriggerSchedule":{"properties":{"flags":{"$ref":"#/components/schemas/TriggerScheduleFlags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"$ref":"#/components/schemas/TriggerScheduleData"}},"type":"object","required":["data"],"title":"TriggerSchedule"},"TriggerScheduleCreate":{"properties":{"flags":{"$ref":"#/components/schemas/TriggerScheduleFlags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"data":{"$ref":"#/components/schemas/TriggerScheduleData"}},"type":"object","required":["data"],"title":"TriggerScheduleCreate"},"TriggerScheduleCreateRequest":{"properties":{"schedule":{"$ref":"#/components/schemas/TriggerScheduleCreate"}},"type":"object","required":["schedule"],"title":"TriggerScheduleCreateRequest"},"TriggerScheduleData":{"properties":{"event_key":{"type":"string","title":"Event Key"},"schedule":{"type":"string","title":"Schedule"},"start_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Start Time"},"end_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"End Time"},"inputs_fields":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"string"},{"type":"null"}],"title":"Inputs Fields"},"references":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/Reference"},"type":"object"},{"type":"null"}],"title":"References"},"selector":{"anyOf":[{"$ref":"#/components/schemas/Selector"},{"type":"null"}]}},"type":"object","required":["event_key","schedule"],"title":"TriggerScheduleData"},"TriggerScheduleEdit":{"properties":{"flags":{"$ref":"#/components/schemas/TriggerScheduleFlags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"$ref":"#/components/schemas/TriggerScheduleData"}},"type":"object","required":["data"],"title":"TriggerScheduleEdit"},"TriggerScheduleEditRequest":{"properties":{"schedule":{"$ref":"#/components/schemas/TriggerScheduleEdit"}},"type":"object","required":["schedule"],"title":"TriggerScheduleEditRequest"},"TriggerScheduleFlags":{"properties":{"is_active":{"type":"boolean","title":"Is Active","default":true}},"type":"object","title":"TriggerScheduleFlags"},"TriggerScheduleQuery":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"event_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Event Key"}},"type":"object","title":"TriggerScheduleQuery"},"TriggerScheduleQueryRequest":{"properties":{"schedule":{"anyOf":[{"$ref":"#/components/schemas/TriggerScheduleQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"TriggerScheduleQueryRequest"},"TriggerScheduleResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"schedule":{"anyOf":[{"$ref":"#/components/schemas/TriggerSchedule"},{"type":"null"}]}},"type":"object","title":"TriggerScheduleResponse"},"TriggerSchedulesResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"schedules":{"items":{"$ref":"#/components/schemas/TriggerSchedule"},"type":"array","title":"Schedules"}},"type":"object","title":"TriggerSchedulesResponse"},"TriggerSubscription":{"properties":{"flags":{"$ref":"#/components/schemas/TriggerSubscriptionFlags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"connection_id":{"type":"string","format":"uuid","title":"Connection Id"},"trigger_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Trigger Id"},"data":{"$ref":"#/components/schemas/TriggerSubscriptionData"}},"type":"object","required":["connection_id","data"],"title":"TriggerSubscription"},"TriggerSubscriptionCreate":{"properties":{"flags":{"$ref":"#/components/schemas/TriggerSubscriptionFlags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"connection_id":{"type":"string","format":"uuid","title":"Connection Id"},"data":{"$ref":"#/components/schemas/TriggerSubscriptionData"}},"type":"object","required":["connection_id","data"],"title":"TriggerSubscriptionCreate"},"TriggerSubscriptionCreateRequest":{"properties":{"subscription":{"$ref":"#/components/schemas/TriggerSubscriptionCreate"}},"type":"object","required":["subscription"],"title":"TriggerSubscriptionCreateRequest"},"TriggerSubscriptionData":{"properties":{"event_key":{"type":"string","title":"Event Key"},"trigger_config":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Trigger Config"},"inputs_fields":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"string"},{"type":"null"}],"title":"Inputs Fields"},"references":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/Reference"},"type":"object"},{"type":"null"}],"title":"References"},"selector":{"anyOf":[{"$ref":"#/components/schemas/Selector"},{"type":"null"}]}},"type":"object","required":["event_key"],"title":"TriggerSubscriptionData"},"TriggerSubscriptionEdit":{"properties":{"flags":{"$ref":"#/components/schemas/TriggerSubscriptionFlags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"connection_id":{"type":"string","format":"uuid","title":"Connection Id"},"data":{"$ref":"#/components/schemas/TriggerSubscriptionData"}},"type":"object","required":["connection_id","data"],"title":"TriggerSubscriptionEdit"},"TriggerSubscriptionEditRequest":{"properties":{"subscription":{"$ref":"#/components/schemas/TriggerSubscriptionEdit"}},"type":"object","required":["subscription"],"title":"TriggerSubscriptionEditRequest"},"TriggerSubscriptionFlags":{"properties":{"is_active":{"type":"boolean","title":"Is Active","default":true},"is_valid":{"type":"boolean","title":"Is Valid","default":true},"is_test":{"type":"boolean","title":"Is Test","default":false}},"type":"object","title":"TriggerSubscriptionFlags"},"TriggerSubscriptionQuery":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"connection_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Connection Id"},"event_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Event Key"}},"type":"object","title":"TriggerSubscriptionQuery"},"TriggerSubscriptionQueryRequest":{"properties":{"subscription":{"anyOf":[{"$ref":"#/components/schemas/TriggerSubscriptionQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"TriggerSubscriptionQueryRequest"},"TriggerSubscriptionResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"subscription":{"anyOf":[{"$ref":"#/components/schemas/TriggerSubscription"},{"type":"null"}]}},"type":"object","title":"TriggerSubscriptionResponse"},"TriggerSubscriptionsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"subscriptions":{"items":{"$ref":"#/components/schemas/TriggerSubscription"},"type":"array","title":"Subscriptions"}},"type":"object","title":"TriggerSubscriptionsResponse"},"UpdateProjectRequest":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"make_default":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Make Default"}},"type":"object","title":"UpdateProjectRequest"},"UpdateSecretDTO":{"properties":{"header":{"anyOf":[{"$ref":"#/components/schemas/Header"},{"type":"null"}]},"secret":{"anyOf":[{"$ref":"#/components/schemas/UpdateSecretPayloadDTO"},{"type":"null"}]}},"additionalProperties":false,"type":"object","title":"UpdateSecretDTO"},"UpdateSecretPayloadDTO":{"properties":{"kind":{"$ref":"#/components/schemas/SecretKind"},"data":{"anyOf":[{"$ref":"#/components/schemas/StandardProviderDTO"},{"$ref":"#/components/schemas/CustomProviderDTO"},{"$ref":"#/components/schemas/SSOProviderDTO"},{"$ref":"#/components/schemas/WebhookProviderDTO"},{"$ref":"#/components/schemas/CustomSecretDTO"}],"title":"Data"}},"type":"object","required":["kind","data"],"title":"UpdateSecretPayloadDTO","description":"Update-time payload. Omitted credential fields keep their stored values."},"UpdateWorkspace":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"}},"type":"object","title":"UpdateWorkspace"},"UserIdsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of user IDs in this page.","default":0},"user_ids":{"items":{"type":"string"},"type":"array","title":"User Ids","description":"Distinct values of `ag.user.id` in this page.","default":[]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor for the next page. Pass verbatim as `windowing.next`."}},"type":"object","title":"UserIdsResponse"},"UserRole":{"properties":{"email":{"type":"string","title":"Email"},"role":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Role"},"organization_id":{"type":"string","title":"Organization Id"}},"type":"object","required":["email","organization_id"],"title":"UserRole"},"UserUpdate":{"properties":{"username":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Username"},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email"},"updated_at":{"type":"string","title":"Updated At"}},"type":"object","title":"UserUpdate"},"UsersQueryRequest":{"properties":{"realtime":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Realtime","description":"When `true`, paginate by `last_active`. When `false` or unset, paginate by the stable `first_active` cursor."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor pagination and time range."}},"type":"object","title":"UsersQueryRequest","description":"Request body for `POST /tracing/users/query`."},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"WebhookDeliveriesResponse":{"properties":{"count":{"type":"integer","title":"Count"},"deliveries":{"items":{"$ref":"#/components/schemas/WebhookDelivery"},"type":"array","title":"Deliveries","default":[]}},"type":"object","required":["count"],"title":"WebhookDeliveriesResponse"},"WebhookDelivery":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"status":{"$ref":"#/components/schemas/Status"},"data":{"anyOf":[{"$ref":"#/components/schemas/WebhookDeliveryData"},{"type":"null"}]},"subscription_id":{"type":"string","format":"uuid","title":"Subscription Id"},"event_id":{"type":"string","format":"uuid","title":"Event Id"}},"type":"object","required":["status","subscription_id","event_id"],"title":"WebhookDelivery"},"WebhookDeliveryCreate":{"properties":{"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"status":{"$ref":"#/components/schemas/Status"},"data":{"anyOf":[{"$ref":"#/components/schemas/WebhookDeliveryData"},{"type":"null"}]},"subscription_id":{"type":"string","format":"uuid","title":"Subscription Id"},"event_id":{"type":"string","format":"uuid","title":"Event Id"}},"type":"object","required":["status","subscription_id","event_id"],"title":"WebhookDeliveryCreate"},"WebhookDeliveryCreateRequest":{"properties":{"delivery":{"$ref":"#/components/schemas/WebhookDeliveryCreate"}},"type":"object","required":["delivery"],"title":"WebhookDeliveryCreateRequest"},"WebhookDeliveryData":{"properties":{"event_type":{"anyOf":[{"$ref":"#/components/schemas/WebhookEventType"},{"type":"null"}]},"url":{"type":"string","maxLength":2083,"minLength":1,"format":"uri","title":"Url"},"headers":{"anyOf":[{"additionalProperties":{"type":"string"},"type":"object"},{"type":"null"}],"title":"Headers"},"payload":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Payload"},"response":{"anyOf":[{"$ref":"#/components/schemas/WebhookDeliveryResponseInfo"},{"type":"null"}]},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"}},"type":"object","required":["url"],"title":"WebhookDeliveryData"},"WebhookDeliveryQuery":{"properties":{"status":{"anyOf":[{"$ref":"#/components/schemas/Status"},{"type":"null"}]},"subscription_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Subscription Id"},"event_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Event Id"}},"type":"object","title":"WebhookDeliveryQuery"},"WebhookDeliveryQueryRequest":{"properties":{"delivery":{"anyOf":[{"$ref":"#/components/schemas/WebhookDeliveryQuery"},{"type":"null"}]},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"WebhookDeliveryQueryRequest"},"WebhookDeliveryResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"delivery":{"anyOf":[{"$ref":"#/components/schemas/WebhookDelivery"},{"type":"null"}]}},"type":"object","title":"WebhookDeliveryResponse"},"WebhookDeliveryResponseInfo":{"properties":{"status_code":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Status Code"},"body":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Body"}},"type":"object","title":"WebhookDeliveryResponseInfo"},"WebhookEventType":{"type":"string","enum":["webhooks.subscriptions.tested","traces.fetched","traces.queried","queries.revisions.retrieved","queries.revisions.fetched","queries.revisions.queried","queries.revisions.logged","queries.revisions.committed","testcases.fetched","testcases.queried","testsets.revisions.retrieved","testsets.revisions.fetched","testsets.revisions.queried","testsets.revisions.logged","testsets.revisions.committed","workflows.revisions.retrieved","workflows.revisions.fetched","workflows.revisions.queried","workflows.revisions.logged","workflows.revisions.committed","environments.revisions.retrieved","environments.revisions.fetched","environments.revisions.queried","environments.revisions.logged","environments.revisions.committed"],"title":"WebhookEventType","description":"Subscribable event types — a strict subset of EventType.\n\nValues are derived from EventType so the strings stay in sync.\nTo add a new subscribable event type, it must first exist in EventType.\nWhen extending this enum, regenerate Fern clients and update the\n\"Available event types\" section in `04-webhooks.mdx`."},"WebhookProviderDTO":{"properties":{"provider":{"$ref":"#/components/schemas/WebhookProviderSettingsDTO"}},"type":"object","required":["provider"],"title":"WebhookProviderDTO"},"WebhookProviderSettingsDTO":{"properties":{"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key"}},"type":"object","title":"WebhookProviderSettingsDTO"},"WebhookSubscription":{"properties":{"flags":{"$ref":"#/components/schemas/WebhookSubscriptionFlags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"$ref":"#/components/schemas/WebhookSubscriptionData"},"secret_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Secret Id"},"secret":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Secret"}},"type":"object","required":["data"],"title":"WebhookSubscription"},"WebhookSubscriptionCreate":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"data":{"$ref":"#/components/schemas/WebhookSubscriptionData"},"secret":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Secret"}},"type":"object","required":["data"],"title":"WebhookSubscriptionCreate"},"WebhookSubscriptionCreateRequest":{"properties":{"subscription":{"$ref":"#/components/schemas/WebhookSubscriptionCreate"}},"type":"object","required":["subscription"],"title":"WebhookSubscriptionCreateRequest"},"WebhookSubscriptionData":{"properties":{"url":{"type":"string","maxLength":2083,"minLength":1,"format":"uri","title":"Url"},"headers":{"anyOf":[{"additionalProperties":{"type":"string"},"type":"object"},{"type":"null"}],"title":"Headers"},"payload_fields":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Payload Fields"},"auth_mode":{"anyOf":[{"type":"string","enum":["signature","authorization"]},{"type":"null"}],"title":"Auth Mode"},"event_types":{"anyOf":[{"items":{"$ref":"#/components/schemas/WebhookEventType"},"type":"array"},{"type":"null"}],"title":"Event Types"}},"type":"object","required":["url"],"title":"WebhookSubscriptionData"},"WebhookSubscriptionEdit":{"properties":{"flags":{"$ref":"#/components/schemas/WebhookSubscriptionFlags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"$ref":"#/components/schemas/WebhookSubscriptionData"},"secret":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Secret"}},"type":"object","required":["data"],"title":"WebhookSubscriptionEdit"},"WebhookSubscriptionEditRequest":{"properties":{"subscription":{"$ref":"#/components/schemas/WebhookSubscriptionEdit"}},"type":"object","required":["subscription"],"title":"WebhookSubscriptionEditRequest"},"WebhookSubscriptionFlags":{"properties":{"is_active":{"type":"boolean","title":"Is Active","default":true}},"type":"object","title":"WebhookSubscriptionFlags"},"WebhookSubscriptionQuery":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},"type":"object","title":"WebhookSubscriptionQuery"},"WebhookSubscriptionQueryRequest":{"properties":{"subscription":{"anyOf":[{"$ref":"#/components/schemas/WebhookSubscriptionQuery"},{"type":"null"}]},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"WebhookSubscriptionQueryRequest"},"WebhookSubscriptionResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"subscription":{"anyOf":[{"$ref":"#/components/schemas/WebhookSubscription"},{"type":"null"}]}},"type":"object","title":"WebhookSubscriptionResponse"},"WebhookSubscriptionTestRequest":{"properties":{"subscription_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Subscription Id"},"subscription":{"anyOf":[{"$ref":"#/components/schemas/WebhookSubscriptionEdit"},{"$ref":"#/components/schemas/WebhookSubscriptionCreate"},{"type":"null"}],"title":"Subscription"}},"type":"object","title":"WebhookSubscriptionTestRequest"},"WebhookSubscriptionsResponse":{"properties":{"count":{"type":"integer","title":"Count"},"subscriptions":{"items":{"$ref":"#/components/schemas/WebhookSubscription"},"type":"array","title":"Subscriptions","default":[]}},"type":"object","required":["count"],"title":"WebhookSubscriptionsResponse"},"Windowing":{"properties":{"newest":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Newest"},"oldest":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Oldest"},"next":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Next"},"limit":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"},"order":{"anyOf":[{"type":"string","enum":["ascending","descending"]},{"type":"null"}],"title":"Order"},"interval":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"},"rate":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Rate"}},"type":"object","title":"Windowing"},"Workflow":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowArtifactFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"Workflow"},"WorkflowArtifactFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false}},"type":"object","title":"WorkflowArtifactFlags"},"WorkflowCatalogFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false},"is_archived":{"type":"boolean","title":"Is Archived","default":false},"is_recommended":{"type":"boolean","title":"Is Recommended","default":false}},"type":"object","title":"WorkflowCatalogFlags"},"WorkflowCatalogHarness":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"key":{"type":"string","title":"Key"},"capabilities":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Capabilities"}},"type":"object","required":["key"],"title":"WorkflowCatalogHarness"},"WorkflowCatalogHarnessResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when a harness record is returned, `0` when not found.","default":0},"harness":{"anyOf":[{"$ref":"#/components/schemas/WorkflowCatalogHarness"},{"type":"null"}],"description":"A harness record referenced by a template's harness field via `x-ag-harness-ref`."}},"type":"object","title":"WorkflowCatalogHarnessResponse"},"WorkflowCatalogHarnessesResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of harness records available.","default":0},"harnesses":{"items":{"$ref":"#/components/schemas/WorkflowCatalogHarness"},"type":"array","title":"Harnesses","description":"Harness records shipped with the product (each carries its `capabilities`)."}},"type":"object","title":"WorkflowCatalogHarnessesResponse"},"WorkflowCatalogPreset":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"key":{"type":"string","title":"Key"},"categories":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Categories"},"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowCatalogFlags"},{"type":"null"}]},"data":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevisionData-Output"},{"type":"null"}]}},"type":"object","required":["key"],"title":"WorkflowCatalogPreset"},"WorkflowCatalogPresetResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when the preset is returned, `0` when not found.","default":0},"preset":{"anyOf":[{"$ref":"#/components/schemas/WorkflowCatalogPreset"},{"type":"null"}],"description":"Named parameter set defined against a template."}},"type":"object","title":"WorkflowCatalogPresetResponse"},"WorkflowCatalogPresetsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of presets returned.","default":0},"presets":{"items":{"$ref":"#/components/schemas/WorkflowCatalogPreset"},"type":"array","title":"Presets","description":"Named parameter sets defined against a template."}},"type":"object","title":"WorkflowCatalogPresetsResponse"},"WorkflowCatalogTemplate":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"key":{"type":"string","title":"Key"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"categories":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Categories"},"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowCatalogFlags"},{"type":"null"}]},"data":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevisionData-Output"},{"type":"null"}]}},"type":"object","required":["key"],"title":"WorkflowCatalogTemplate"},"WorkflowCatalogTemplateResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when the template is returned, `0` when not found.","default":0},"template":{"anyOf":[{"$ref":"#/components/schemas/WorkflowCatalogTemplate"},{"type":"null"}],"description":"Workflow blueprint (key, name, description, flags, default data)."}},"type":"object","title":"WorkflowCatalogTemplateResponse"},"WorkflowCatalogTemplatesResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of templates returned.","default":0},"templates":{"items":{"$ref":"#/components/schemas/WorkflowCatalogTemplate"},"type":"array","title":"Templates","description":"Workflow blueprints shipped with the product."}},"type":"object","title":"WorkflowCatalogTemplatesResponse"},"WorkflowCatalogType":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"key":{"type":"string","title":"Key"},"json_schema":{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object","title":"Json Schema"}},"type":"object","required":["key","json_schema"],"title":"WorkflowCatalogType"},"WorkflowCatalogTypeResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when a type definition is returned, `0` when not found.","default":0},"type":{"anyOf":[{"$ref":"#/components/schemas/WorkflowCatalogType"},{"type":"null"}],"description":"JSON Schema fragment referenced by workflow input/output schemas via `x-ag-type-ref`."}},"type":"object","title":"WorkflowCatalogTypeResponse"},"WorkflowCatalogTypesResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of type definitions available.","default":0},"types":{"items":{"$ref":"#/components/schemas/WorkflowCatalogType"},"type":"array","title":"Types","description":"Shared JSON Schema fragments shipped with the product."}},"type":"object","title":"WorkflowCatalogTypesResponse"},"WorkflowCreate":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"WorkflowCreate"},"WorkflowCreateRequest":{"properties":{"workflow":{"$ref":"#/components/schemas/WorkflowCreate","description":"Workflow artifact to create. Must include a project-unique `slug`; `name`, `description`, `flags`, `tags`, and `meta` are optional."}},"type":"object","required":["workflow"],"title":"WorkflowCreateRequest"},"WorkflowEdit":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"WorkflowEdit"},"WorkflowEditRequest":{"properties":{"workflow":{"$ref":"#/components/schemas/WorkflowEdit","description":"Workflow fields to update. `id` is required and must match the path parameter; only supplied fields are modified."}},"type":"object","required":["workflow"],"title":"WorkflowEditRequest"},"WorkflowFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false},"is_managed":{"type":"boolean","title":"Is Managed","default":false},"is_custom":{"type":"boolean","title":"Is Custom","default":false},"is_llm":{"type":"boolean","title":"Is Llm","default":false},"is_hook":{"type":"boolean","title":"Is Hook","default":false},"is_code":{"type":"boolean","title":"Is Code","default":false},"is_match":{"type":"boolean","title":"Is Match","default":false},"is_feedback":{"type":"boolean","title":"Is Feedback","default":false},"is_agent":{"type":"boolean","title":"Is Agent","default":false},"is_skill":{"type":"boolean","title":"Is Skill","default":false},"is_chat":{"type":"boolean","title":"Is Chat","default":false},"has_url":{"type":"boolean","title":"Has Url","default":false},"has_script":{"type":"boolean","title":"Has Script","default":false},"has_handler":{"type":"boolean","title":"Has Handler","default":false},"is_static":{"type":"boolean","title":"Is Static","default":false}},"type":"object","title":"WorkflowFlags","description":"Legacy full workflow flag set."},"WorkflowRequestData":{"properties":{"revision":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Revision"},"parameters":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Parameters"},"testcase":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Testcase"},"inputs":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Inputs"},"trace":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Trace"},"outputs":{"anyOf":[{},{"type":"null"}],"title":"Outputs"}},"type":"object","title":"WorkflowRequestData"},"WorkflowResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when a workflow is returned, `0` when none matched.","default":0},"workflow":{"anyOf":[{"$ref":"#/components/schemas/Workflow"},{"type":"null"}],"description":"The workflow artifact."}},"type":"object","title":"WorkflowResponse"},"WorkflowRevision-Input":{"properties":{"workflow_variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Variant Slug"},"variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Variant Slug"},"workflow_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Slug"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"workflow_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"author":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Author"},"date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Date"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevisionFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevisionData-Input"},{"type":"null"}]}},"type":"object","title":"WorkflowRevision"},"WorkflowRevision-Output":{"properties":{"workflow_variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Variant Slug"},"variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Variant Slug"},"workflow_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Slug"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"workflow_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"author":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Author"},"date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Date"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevisionFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevisionData-Output"},{"type":"null"}]}},"type":"object","title":"WorkflowRevision"},"WorkflowRevisionCommit":{"properties":{"workflow_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"data":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevisionData-Input"},{"type":"null"}]},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"},"delta":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevisionDelta"},{"type":"null"}]},"base_revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Base Revision Id","description":"The revision this change was built on. Omit it to keep today's last-write-wins behavior. Send it on a legacy delta and the commit is refused with `409` when the variant's head has moved since: sending the field is how a caller asks for that check. An ordered delta requires it."}},"type":"object","title":"WorkflowRevisionCommit"},"WorkflowRevisionCommitRequest":{"properties":{"workflow_revision":{"$ref":"#/components/schemas/WorkflowRevisionCommit","description":"Revision to append to a variant's history. Requires `workflow_variant_id` and optional `message`; `data` carries the new configuration."}},"type":"object","required":["workflow_revision"],"title":"WorkflowRevisionCommitRequest"},"WorkflowRevisionCreate":{"properties":{"workflow_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"WorkflowRevisionCreate"},"WorkflowRevisionCreateRequest":{"properties":{"workflow_revision":{"$ref":"#/components/schemas/WorkflowRevisionCreate","description":"Revision to create on an existing variant. The revision is immutable once persisted; to change the payload, commit a new revision."}},"type":"object","required":["workflow_revision"],"title":"WorkflowRevisionCreateRequest"},"WorkflowRevisionData-Input":{"properties":{"uri":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Uri"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"headers":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/Reference"}]},"type":"object"},{"type":"null"}],"title":"Headers"},"runtime":{"anyOf":[{"type":"string","enum":["python","typescript","javascript"]},{"type":"null"}],"title":"Runtime"},"script":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Script"},"schemas":{"anyOf":[{"$ref":"#/components/schemas/JsonSchemas-Input"},{"type":"null"}]},"parameters":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Parameters"}},"additionalProperties":false,"type":"object","title":"WorkflowRevisionData"},"WorkflowRevisionData-Output":{"properties":{"uri":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Uri"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"headers":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/Reference"}]},"type":"object"},{"type":"null"}],"title":"Headers"},"runtime":{"anyOf":[{"type":"string","enum":["python","typescript","javascript"]},{"type":"null"}],"title":"Runtime"},"script":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Script"},"schemas":{"anyOf":[{"$ref":"#/components/schemas/JsonSchemas-Output"},{"type":"null"}]},"parameters":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Parameters"}},"additionalProperties":false,"type":"object","title":"WorkflowRevisionData"},"WorkflowRevisionDelta":{"properties":{"set":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Set"},"remove":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Remove"},"operations":{"anyOf":[{"items":{"$ref":"#/components/schemas/WorkflowRevisionOperation"},"type":"array"},{"type":"null"}],"title":"Operations"}},"type":"object","title":"WorkflowRevisionDelta","description":"Delta operations on a workflow revision's data tree.\n\nTwo forms, never mixed (contract 3):\n\n- **legacy** — ``set``: a partial data tree deep-merged onto the base revision's data\n (nested dicts merge; scalars and lists replace); ``remove``: dotted key paths to\n delete (e.g. ``parameters.agent.tools``).\n- **ordered** — ``operations``: the seven verbs, applied in array order, all or\n nothing.\n\nThe engine enforces the exclusivity and every operation rule; this model only carries\nthe shapes.\n\nUnknown keys beside ``set``/``remove``/``operations`` are refused on the ORDERED arm\nonly, so a caller cannot believe it sent an operation modifier the server never saw.\nA pure-legacy envelope keeps its shipped tolerance: the server has always ignored\nstray keys there, and playbooks in the field send them. Neither rule reaches the tree\ninside ``set``, which stays free-form."},"WorkflowRevisionDeployRequest":{"properties":{"workflow_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Workflow artifact to deploy. One of the workflow refs is required."},"workflow_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Workflow variant to deploy. Resolves to the latest revision of this variant."},"workflow_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Specific workflow revision to deploy."},"environment_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Target environment artifact. One of the environment refs is required."},"environment_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Target environment variant."},"environment_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Target environment revision."},"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key","description":"Reference key to set on the environment revision. Defaults to `.revision` when omitted."},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message","description":"Commit message recorded on the resulting environment revision."}},"type":"object","title":"WorkflowRevisionDeployRequest"},"WorkflowRevisionEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"WorkflowRevisionEdit"},"WorkflowRevisionEditRequest":{"properties":{"workflow_revision":{"$ref":"#/components/schemas/WorkflowRevisionEdit","description":"Revision fields to update (lifecycle metadata only). Data and configuration are immutable — commit a new revision to change them."}},"type":"object","required":["workflow_revision"],"title":"WorkflowRevisionEditRequest"},"WorkflowRevisionFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false},"is_managed":{"type":"boolean","title":"Is Managed","default":false},"is_custom":{"type":"boolean","title":"Is Custom","default":false},"is_llm":{"type":"boolean","title":"Is Llm","default":false},"is_hook":{"type":"boolean","title":"Is Hook","default":false},"is_code":{"type":"boolean","title":"Is Code","default":false},"is_match":{"type":"boolean","title":"Is Match","default":false},"is_feedback":{"type":"boolean","title":"Is Feedback","default":false},"is_agent":{"type":"boolean","title":"Is Agent","default":false},"is_skill":{"type":"boolean","title":"Is Skill","default":false},"is_chat":{"type":"boolean","title":"Is Chat","default":false},"has_url":{"type":"boolean","title":"Has Url","default":false},"has_script":{"type":"boolean","title":"Has Script","default":false},"has_handler":{"type":"boolean","title":"Has Handler","default":false},"is_static":{"type":"boolean","title":"Is Static","default":false}},"type":"object","title":"WorkflowRevisionFlags"},"WorkflowRevisionOperation":{"properties":{"operation":{"type":"string","enum":["set","merge","remove","edit_text","add_item","replace_item","remove_item"],"title":"Operation"},"target":{"items":{},"type":"array","title":"Target"},"value":{"anyOf":[{},{"type":"null"}],"title":"Value"},"edits":{"anyOf":[{"items":{"additionalProperties":{"type":"string"},"type":"object"},"type":"array"},{"type":"null"}],"title":"Edits"},"match_mode":{"anyOf":[{"type":"string","enum":["auto","exact"]},{"type":"null"}],"title":"Match Mode"}},"additionalProperties":false,"type":"object","required":["operation","target"],"title":"WorkflowRevisionOperation","description":"One ordered operation. The new delta arm (agent-config-editing, contract 3.2).\n\n``extra=\"forbid\"`` applies to this NEW model only. The legacy ``set``/``remove`` arm\nstays permissive on purpose: tightening it would reject payloads that shipped\nplaybooks send today and that the server has always ignored."},"WorkflowRevisionResolveRequest":{"properties":{"workflow_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Workflow artifact; resolves against its latest revision."},"workflow_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Workflow variant; resolves against its latest revision."},"workflow_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Specific workflow revision to resolve."},"workflow_revision":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevision-Input"},{"type":"null"}],"description":"Resolve the references embedded in this revision payload directly, without fetching it first."},"max_depth":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Depth","description":"Maximum recursive depth for nested `@ag.references`.","default":10},"max_embeds":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Embeds","description":"Maximum number of embeds to resolve in one call.","default":100},"error_policy":{"anyOf":[{"$ref":"#/components/schemas/ErrorPolicy"},{"type":"null"}],"description":"How to handle unresolved references: `EXCEPTION` or `IGNORE`.","default":"exception"}},"type":"object","title":"WorkflowRevisionResolveRequest"},"WorkflowRevisionResolveResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when a revision is returned, `0` when none matched.","default":0},"workflow_revision":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevision-Output"},{"type":"null"}],"description":"The workflow revision with `@ag.references` replaced by their resolved payloads."},"resolution_info":{"anyOf":[{"$ref":"#/components/schemas/ResolutionInfo"},{"type":"null"}],"description":"Metadata describing which references were resolved, depth reached, and errors."},"retrieval_info":{"anyOf":[{"$ref":"#/components/schemas/RetrievalInfo"},{"type":"null"}],"description":"References (artifact / variant / revision) actually used to retrieve this revision."}},"type":"object","title":"WorkflowRevisionResolveResponse"},"WorkflowRevisionResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when a revision is returned, `0` when none matched.","default":0},"workflow_revision":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevision-Output"},{"type":"null"}],"description":"The workflow revision."},"status":{"anyOf":[{"type":"string","enum":["committed","no_change"]},{"type":"null"}],"title":"Status","description":"Commit outcome. `no_change` means the change produced the stored configuration, so no revision was created and `workflow_revision` is the current head. Absent on paths that do not run the checked commit; a reader must treat absent as `committed`."},"warnings":{"anyOf":[{"items":{"$ref":"#/components/schemas/CommitWarning"},"type":"array"},{"type":"null"}],"title":"Warnings","description":"Structured advisories about the commit; never an error."},"resolution_info":{"anyOf":[{"$ref":"#/components/schemas/ResolutionInfo"},{"type":"null"}],"description":"Reference-resolution metadata; populated when `resolve=true` on retrieve."},"retrieval_info":{"anyOf":[{"$ref":"#/components/schemas/RetrievalInfo"},{"type":"null"}],"description":"References used to retrieve the top-level revision."}},"type":"object","title":"WorkflowRevisionResponse"},"WorkflowRevisionRetrieveRequest":{"properties":{"workflow_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Workflow artifact to look up. Identifies the artifact by `id` or `slug` (both project-unique). When no variant_ref or revision_ref is provided, returns the latest revision of the workflow's default variant."},"workflow_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Workflow variant to look up. Identifies the variant by `id` or `slug` (both project-unique). When no revision_ref is provided, returns the latest revision of this variant."},"workflow_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Workflow revision to look up. `id` alone identifies a revision (project-unique). `slug` alone identifies a revision (project-unique). `version` alone is a per-variant sequence number and is **not** sufficient on its own; it must be combined with a `workflow_variant_ref`. Sending only `version` without a variant ref returns HTTP 400."},"environment_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Environment artifact backing the deployment to resolve from."},"environment_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Environment variant backing the deployment to resolve from."},"environment_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Specific environment revision to resolve from."},"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key","description":"Key into the environment revision's reference map. Required when retrieving via environment refs."},"resolve":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Resolve","description":"When true, resolve `@ag.references` tokens embedded in the revision configuration before returning it."}},"type":"object","title":"WorkflowRevisionRetrieveRequest","description":"Request body for `POST /workflows/revisions/retrieve`.\n\nResolves to a single revision by one or more reference types. Every\nreference supplied must agree with the resolved revision; contradictions\nreturn HTTP 400. For environment-backed lookup, `key` may be omitted when\n`workflow_ref` is provided, in which case it defaults to\n`.revision`."},"WorkflowRevisionsLog":{"properties":{"workflow_revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Revision Id"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"},"workflow_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"depth":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Depth"}},"type":"object","title":"WorkflowRevisionsLog"},"WorkflowRevisionsLogRequest":{"properties":{"workflow_revisions":{"$ref":"#/components/schemas/WorkflowRevisionsLog","description":"Log query. Supply `workflow_id`, `workflow_variant_id`, or `workflow_revision_id` to scope the log, and an optional `depth`."}},"type":"object","required":["workflow_revisions"],"title":"WorkflowRevisionsLogRequest"},"WorkflowRevisionsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of revisions in this page.","default":0},"workflow_revisions":{"items":{"$ref":"#/components/schemas/WorkflowRevision-Output"},"type":"array","title":"Workflow Revisions","description":"Workflow revisions matching the query, ordered by commit time."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Pagination cursor."}},"type":"object","title":"WorkflowRevisionsResponse"},"WorkflowVariant":{"properties":{"workflow_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Slug"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowVariantFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"WorkflowVariant"},"WorkflowVariantCreate":{"properties":{"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"WorkflowVariantCreate"},"WorkflowVariantCreateRequest":{"properties":{"workflow_variant":{"$ref":"#/components/schemas/WorkflowVariantCreate","description":"Variant to create under an existing workflow. Requires `workflow_id` (the artifact) and a project-unique `slug`."}},"type":"object","required":["workflow_variant"],"title":"WorkflowVariantCreateRequest"},"WorkflowVariantEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"WorkflowVariantEdit"},"WorkflowVariantEditRequest":{"properties":{"workflow_variant":{"$ref":"#/components/schemas/WorkflowVariantEdit","description":"Variant fields to update. `id` is required and must match the path parameter."}},"type":"object","required":["workflow_variant"],"title":"WorkflowVariantEditRequest"},"WorkflowVariantFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false}},"type":"object","title":"WorkflowVariantFlags"},"WorkflowVariantFork":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowVariantFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"WorkflowVariantFork"},"WorkflowVariantForkRequest":{"properties":{"workflow_variant":{"$ref":"#/components/schemas/WorkflowVariantFork","description":"Config for the new variant (slug, name, description, flags)."},"workflow_variant_ref":{"$ref":"#/components/schemas/Reference","description":"Source variant to fork from."},"workflow_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Pin the fork to this revision; defaults to the source variant's head."}},"type":"object","required":["workflow_variant","workflow_variant_ref"],"title":"WorkflowVariantForkRequest"},"WorkflowVariantResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when a variant is returned, `0` when none matched.","default":0},"workflow_variant":{"anyOf":[{"$ref":"#/components/schemas/WorkflowVariant"},{"type":"null"}],"description":"The workflow variant."}},"type":"object","title":"WorkflowVariantResponse"},"WorkflowVariantsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of variants in this page.","default":0},"workflow_variants":{"items":{"$ref":"#/components/schemas/WorkflowVariant"},"type":"array","title":"Workflow Variants","description":"Workflow variants matching the query."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Pagination cursor."}},"type":"object","title":"WorkflowVariantsResponse"},"WorkflowsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of workflows in this page.","default":0},"workflows":{"items":{"$ref":"#/components/schemas/Workflow"},"type":"array","title":"Workflows","description":"Workflow artifacts matching the query."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Pagination cursor; pass `windowing.next` back to fetch the following page."}},"type":"object","title":"WorkflowsResponse"},"Workspace":{"properties":{"id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Id"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Type"}},"type":"object","required":["name","type"],"title":"Workspace"},"WorkspaceMemberResponse":{"properties":{"user":{"additionalProperties":true,"type":"object","title":"User"},"roles":{"items":{"$ref":"#/components/schemas/WorkspacePermission"},"type":"array","title":"Roles"}},"type":"object","required":["user","roles"],"title":"WorkspaceMemberResponse"},"WorkspacePermission":{"properties":{"role_name":{"type":"string","title":"Role Name"},"role_description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Role Description"},"permissions":{"anyOf":[{"items":{"$ref":"#/components/schemas/Permission"},"type":"array"},{"type":"null"}],"title":"Permissions"}},"type":"object","required":["role_name"],"title":"WorkspacePermission"},"WorkspaceResponse":{"properties":{"created_at":{"type":"string","title":"Created At"},"updated_at":{"type":"string","title":"Updated At"},"id":{"type":"string","title":"Id"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Type"},"organization":{"type":"string","title":"Organization"},"members":{"anyOf":[{"items":{"$ref":"#/components/schemas/WorkspaceMemberResponse"},"type":"array"},{"type":"null"}],"title":"Members"}},"type":"object","required":["id","name","type","organization"],"title":"WorkspaceResponse"}},"securitySchemes":{"APIKeyHeader":{"type":"apiKey","name":"Authorization","in":"header"}}},"tags":[{"name":"Status","description":"API server liveness and readiness status."},{"name":"Organizations","description":"Manage organizations, workspaces, SSO domains, and identity providers."},{"name":"Workspaces","description":"Manage workspaces within an organization and their members."},{"name":"Projects","description":"Manage projects within a workspace."},{"name":"Users","description":"User profile and account management — view profile, update username, reset password."},{"name":"Keys","description":"Create and revoke API keys used to authenticate programmatic requests."},{"name":"Workflows","description":"Workflow definitions — the runnable pipelines that back an application."},{"name":"Applications","description":"LLM applications — create, update, list, and delete apps."},{"name":"Evaluators","description":"Evaluator definitions — the metrics and judges used in evaluation runs."},{"name":"Testsets","description":"Test datasets — collections of input/output pairs used in evaluations."},{"name":"Testcases","description":"Individual test cases within a testset."},{"name":"Queries","description":"Saved query definitions used to filter and retrieve trace data."},{"name":"Traces","description":"Ingest and query traces, spans, and metrics from running applications."},{"name":"Invocations","description":"Run an application against a payload and capture the resulting trace."},{"name":"Annotations","description":"Attach evaluator-style feedback to existing traces and spans."},{"name":"Evaluations","description":"Evaluation runs — execute evaluators against variants and testsets."},{"name":"Environments","description":"Deployment environments (e.g. production, staging) and their active variants."},{"name":"Secrets","description":"Manage provider credentials and secret values stored in the vault."},{"name":"Tools","description":"External tool connections and OAuth integrations available to applications."},{"name":"Triggers","description":"Inbound provider event triggers and their watchable event catalog."},{"name":"Sessions","description":"Agent sessions — runner coordination (invoke/cancel/steer/attach/detach/heartbeat/liveness), state persistence (durable SDK state and sandbox resume pointer), records, and streams."},{"name":"Interactions","description":"Human-in-the-loop interaction requests raised by running agents — approvals, inputs, and tool confirmations."},{"name":"Folders","description":"Organize applications and other resources into folder hierarchies."},{"name":"Mounts","description":"Durable object-store mounts for agent working directories."},{"name":"Webhooks","description":"Register and manage webhooks that fire on platform events."},{"name":"OpenTelemetry","description":"OTLP-compatible endpoints for ingesting traces directly from OpenTelemetry-instrumented services."},{"name":"Access","description":"Authentication discovery, organization access checks, and SSO callback endpoints."},{"name":"Billing","description":"Subscription, plan, and usage endpoints for workspace billing."},{"name":"Admin","description":"Internal administration endpoints — restricted to platform operators."},{"name":"Legacy","description":"Stable legacy endpoints retained for existing integrations — not deprecated, but new integrations should prefer the canonical surface."},{"name":"Deprecated","description":"Deprecated endpoints kept for backwards compatibility — avoid in new integrations."}],"security":[{"APIKeyHeader":[]}],"servers":[{"url":"/api"},{"url":"https://eu.cloud.agenta.ai/api"}]} \ No newline at end of file +{"openapi":"3.1.0","info":{"title":"Agenta API","description":"Agenta API","contact":{"name":"Agenta","url":"https://agenta.ai/","email":"team@agenta.ai"},"version":"0.1.0"},"paths":{"/access/plans":{"get":{"tags":["Access"],"summary":"Fetch Plans","description":"Return the effective plan catalog: slug -> entitlement controls.\n\nThe shape mirrors what `AGENTA_ACCESS_PLANS` accepts, but fully parsed\nand validated. The frontend reads `flags`, `counters`, `gauges`, and\n`throttles` from here rather than slug-matching against constants.","operationId":"fetch_access_plans","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":{"additionalProperties":true,"type":"object"},"type":"object","title":"Response Fetch Access Plans"}}}}}}},"/billing/stripe/events/":{"post":{"tags":["Billing"],"summary":"Handle Events","operationId":"handle_events","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/billing/stripe/portals/":{"post":{"tags":["Billing"],"summary":"Create Portal User Route","operationId":"create_portal","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/billing/stripe/checkouts/":{"post":{"tags":["Billing"],"summary":"Create Checkout User Route","operationId":"create_checkout","parameters":[{"name":"plan","in":"query","required":true,"schema":{"type":"string","title":"Plan"}},{"name":"success_url","in":"query","required":true,"schema":{"type":"string","title":"Success Url"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/billing/plans":{"get":{"tags":["Billing"],"summary":"Fetch Plan User Route","operationId":"fetch_plans","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/billing/plans/switch":{"post":{"tags":["Billing"],"summary":"Switch Plans User Route","operationId":"switch_plans","parameters":[{"name":"plan","in":"query","required":true,"schema":{"type":"string","title":"Plan"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/billing/subscription":{"get":{"tags":["Billing"],"summary":"Fetch Subscription User Route","operationId":"fetch_subscription","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/billing/subscription/cancel":{"post":{"tags":["Billing"],"summary":"Cancel Subscription User Route","operationId":"cancel_plan","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/billing/usage":{"get":{"tags":["Billing"],"summary":"Fetch Usage User Route","operationId":"fetch_usage","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/billing/catalog":{"get":{"tags":["Billing"],"summary":"Fetch Catalog","description":"Return the effective billing catalog with pricing merged in.\n\nEach entry carries `title`, `description`, `plan`, `type`, `features`,\nand (when configured) a `price` block sourced from the matching\n`AGENTA_BILLING_PRICING` entry. Pre-joining avoids a client-side\ncatalog × pricing merge by slug.","operationId":"fetch_billing_catalog","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Response Fetch Billing Catalog"}}}}}}},"/billing/pricing":{"get":{"tags":["Billing"],"summary":"Fetch Pricing","description":"Return the effective pricing map: plan slug -> normalized pricing.\n\nIncludes backend-resolved free/trial fallback markers so clients do\nnot need to duplicate billing default rules.","operationId":"fetch_billing_pricing","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":{"additionalProperties":true,"type":"object"},"type":"object","title":"Response Fetch Billing Pricing"}}}}}}},"/events/query":{"post":{"tags":["Events"],"summary":"Query Events","operationId":"query_events_rpc","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventsQueryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations/domains/":{"get":{"tags":["Organizations"],"summary":"List Domains","description":"List all domains for the organization.","operationId":"list_organization_domains","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/OrganizationDomainResponse"},"type":"array","title":"Response List Organization Domains"}}}}}},"post":{"tags":["Organizations"],"summary":"Create Domain","description":"Create a new domain for verification.\n\nThis endpoint initiates the domain verification process by:\n1. Creating a domain record\n2. Generating a unique verification token\n3. Returning DNS configuration instructions\n\nThe user must add a DNS TXT record to verify ownership.","operationId":"create_organization_domain","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationDomainCreate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationDomainResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations/domains/verify":{"post":{"tags":["Organizations"],"summary":"Verify Domain","description":"Verify domain ownership via DNS TXT record.\n\nThis endpoint checks for the presence of the verification TXT record\nand marks the domain as verified if found.","operationId":"verify_organization_domain","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationDomainVerify"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationDomainResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations/domains/{domain_id}/refresh":{"post":{"tags":["Organizations"],"summary":"Refresh Domain Token","description":"Refresh the verification token for an unverified domain.\n\nGenerates a new token and resets the 48-hour expiry window.\nThis is useful when the original token has expired.","operationId":"refresh_organization_domain_token","parameters":[{"name":"domain_id","in":"path","required":true,"schema":{"type":"string","title":"Domain Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationDomainResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations/domains/{domain_id}/reset":{"post":{"tags":["Organizations"],"summary":"Reset Domain","description":"Reset a verified domain to unverified state for re-verification.\n\nGenerates a new token and marks the domain as unverified.\nThis allows re-verification of already verified domains.","operationId":"reset_organization_domain","parameters":[{"name":"domain_id","in":"path","required":true,"schema":{"type":"string","title":"Domain Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationDomainResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations/domains/{domain_id}":{"delete":{"tags":["Organizations"],"summary":"Delete Domain","description":"Delete a domain.","operationId":"delete_organization_domain","parameters":[{"name":"domain_id","in":"path","required":true,"schema":{"type":"string","title":"Domain Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations/providers/":{"get":{"tags":["Organizations"],"summary":"List Providers","description":"List all SSO providers for the organization.","operationId":"list_organization_providers","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/OrganizationProviderResponse"},"type":"array","title":"Response List Organization Providers"}}}}}},"post":{"tags":["Organizations"],"summary":"Create Provider","description":"Create a new SSO provider configuration.\n\nSupported provider types:\n- oidc: OpenID Connect\n- saml: SAML 2.0 (coming soon)","operationId":"create_organization_provider","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationProviderCreate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationProviderResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations/providers/{provider_id}":{"patch":{"tags":["Organizations"],"summary":"Update Provider","description":"Update an SSO provider configuration.","operationId":"update_organization_provider","parameters":[{"name":"provider_id","in":"path","required":true,"schema":{"type":"string","title":"Provider Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationProviderUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationProviderResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Organizations"],"summary":"Delete Provider","description":"Delete an SSO provider configuration.","operationId":"delete_organization_provider","parameters":[{"name":"provider_id","in":"path","required":true,"schema":{"type":"string","title":"Provider Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations/providers/{provider_id}/test":{"post":{"tags":["Organizations"],"summary":"Test Provider","description":"Test SSO provider connection.\n\nThis endpoint tests the OIDC provider configuration by fetching the\ndiscovery document and validating required endpoints exist.\nIf successful, marks the provider as valid (is_valid=true).\nIf failed, marks as invalid and deactivates (is_valid=false, is_active=false).","operationId":"test_organization_provider","parameters":[{"name":"provider_id","in":"path","required":true,"schema":{"type":"string","title":"Provider Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationProviderResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/secrets/":{"get":{"tags":["Secrets"],"summary":"List Secrets","operationId":"list_secrets","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/PublicSecretResponseDTO"},"type":"array","title":"Response List Secrets"}}}}}},"post":{"tags":["Secrets"],"summary":"Create Secret","operationId":"create_secret","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSecretDTO"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicSecretResponseDTO"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/secrets/{secret_id_or_slug}":{"get":{"tags":["Secrets"],"summary":"Read Secret","operationId":"read_secret","parameters":[{"name":"secret_id_or_slug","in":"path","required":true,"schema":{"type":"string","title":"Secret Id Or Slug"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicSecretResponseDTO"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/secrets/{secret_id}":{"put":{"tags":["Secrets"],"summary":"Update Secret","operationId":"update_secret","parameters":[{"name":"secret_id","in":"path","required":true,"schema":{"type":"string","title":"Secret Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSecretDTO"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicSecretResponseDTO"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Secrets"],"summary":"Delete Secret","operationId":"delete_secret","parameters":[{"name":"secret_id","in":"path","required":true,"schema":{"type":"string","title":"Secret Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/providers/probe":{"post":{"tags":["Secrets"],"summary":"Probe Provider","operationId":"probe_provider","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProbeProviderRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProbeProviderResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/webhooks/subscriptions/":{"post":{"tags":["Webhooks"],"summary":"Create Subscription","operationId":"create_webhook_subscription","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookSubscriptionCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookSubscriptionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/webhooks/subscriptions/test":{"post":{"tags":["Webhooks"],"summary":"Test Subscription","operationId":"test_webhook_subscription","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookSubscriptionTestRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDeliveryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/webhooks/subscriptions/{subscription_id}":{"get":{"tags":["Webhooks"],"summary":"Fetch Subscription","operationId":"fetch_webhook_subscription","parameters":[{"name":"subscription_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Subscription Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookSubscriptionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Webhooks"],"summary":"Edit Subscription","operationId":"edit_webhook_subscription","parameters":[{"name":"subscription_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Subscription Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookSubscriptionEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookSubscriptionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Webhooks"],"summary":"Delete Subscription","operationId":"delete_webhook_subscription","parameters":[{"name":"subscription_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Subscription Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/webhooks/subscriptions/query":{"post":{"tags":["Webhooks"],"summary":"Query Subscriptions","operationId":"query_webhook_subscriptions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookSubscriptionQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookSubscriptionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/webhooks/subscriptions/{subscription_id}/start":{"post":{"tags":["Webhooks"],"summary":"Start Subscription","operationId":"start_webhook_subscription","parameters":[{"name":"subscription_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Subscription Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookSubscriptionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/webhooks/subscriptions/{subscription_id}/stop":{"post":{"tags":["Webhooks"],"summary":"Stop Subscription","operationId":"stop_webhook_subscription","parameters":[{"name":"subscription_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Subscription Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookSubscriptionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/webhooks/deliveries":{"post":{"tags":["Webhooks"],"summary":"Create Delivery","operationId":"create_webhook_delivery","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDeliveryCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDeliveryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/webhooks/deliveries/{delivery_id}":{"get":{"tags":["Webhooks"],"summary":"Fetch Delivery","operationId":"fetch_webhook_delivery","parameters":[{"name":"delivery_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Delivery Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDeliveryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/webhooks/deliveries/query":{"post":{"tags":["Webhooks"],"summary":"Query Deliveries","operationId":"query_webhook_deliveries","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDeliveryQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDeliveriesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/otlp/v1/traces":{"get":{"tags":["OpenTelemetry"],"summary":"Status check for OTLP","description":"Return the OTLP endpoint liveness status.\n\nLightweight readiness probe. Returns `{\"status\": \"ready\"}` when\nthe router is mounted. Intended for health checks from OTel\ncollectors before they start exporting traces.","operationId":"otlp_status","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CollectStatusResponse"}}}}}},"post":{"tags":["OpenTelemetry"],"summary":"Ingest traces via OTLP","description":"Ingest traces via the OTLP/HTTP protobuf protocol.\n\nThis endpoint accepts a serialized\n`ExportTraceServiceRequest` protobuf. Point any OTLP/HTTP\ncollector or SDK at `POST /otlp/v1/traces` and spans will flow\ninto the same ingest stream as the Agenta-native endpoints.\n\nUse this when you already have OTel instrumentation emitting\nOTLP. For new integrations that don't need raw OTLP, prefer\n`POST /tracing/spans/ingest` — it takes JSON, accepts Agenta's\nnested shape directly, and surfaces parse failures immediately.\n\n## Content-Type and size limit\n\nBinary protobuf only (`Content-Type: application/x-protobuf`).\nJSON OTLP is not accepted. Requests larger than the configured\nbatch limit (default 10 MB, see `AGENTA_OTLP_MAX_BATCH_BYTES`) return\n`413 Request Entity Too Large`.\n\n## Response\n\nSuccessful ingest returns `200 OK` with a serialized\n`ExportTraceServiceResponse` protobuf. Parse failures on the\nrequest body return `400`; malformed spans return `500`; quota\nexhaustion returns `403`. Like the native ingest paths, spans\nare queued on a Redis stream and persisted asynchronously — see\n[Tracing — Async write\ncontract](/reference/api-guide/tracing#async-write-contract-202).","operationId":"otlp_ingest","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CollectStatusResponse"}}}}}}},"/auth/discover":{"post":{"tags":["Access"],"summary":"Discover","description":"Discover authentication methods available for a given email.\n\nThis endpoint does NOT reveal:\n- Organization names\n- User existence (optionally - currently does for UX)\n- Detailed policy information\n\nReturns minimal information needed for authentication flow.","operationId":"discover_access","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DiscoverRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DiscoverResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/auth/access":{"get":{"tags":["Access"],"summary":"Check Organization Access","description":"Check if the current session satisfies the organization's auth policy.\n\nReturns 200 when access is allowed, 403 with AUTH_UPGRADE_REQUIRED when not.","operationId":"check_organization_access","parameters":[{"name":"organization_id","in":"query","required":true,"schema":{"type":"string","title":"Organization Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/auth/session/identities":{"patch":{"tags":["Access"],"summary":"Update Session Identities","operationId":"update_session_identities","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionIdentitiesUpdate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/auth/sso/callback/{organization_slug}/{provider_slug}":{"get":{"tags":["Access"],"summary":"Sso Callback Redirect","description":"Custom SSO callback endpoint that redirects to SuperTokens.\n\nThis endpoint:\n1. Accepts clean URL path: /auth/sso/callback/{organization_slug}/{provider_slug}\n2. Validates the organization and provider exist\n3. Builds SuperTokens thirdPartyId: sso:{organization_slug}:{provider_slug}\n4. Redirects to SuperTokens callback: /auth/callback/{thirdPartyId}\n\nSuperTokens then handles:\n1. Exchange code for tokens (using our dynamic provider config)\n2. Get user info\n3. Call our sign_in_up override (creates user_identity, adds user_identities to session)\n4. Redirect to frontend with session cookie","operationId":"sso_callback_redirect","parameters":[{"name":"organization_slug","in":"path","required":true,"schema":{"type":"string","title":"Organization Slug"}},{"name":"provider_slug","in":"path","required":true,"schema":{"type":"string","title":"Provider Slug"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tracing/spans/ingest":{"post":{"tags":["Deprecated"],"summary":"Ingest Spans","description":"Ingest spans into the tracing backend.\n\nUse this endpoint to write full OpenTelemetry-style spans — including\nmulti-span hierarchies (parent → child → grandchild), attributes,\nreferences, events and links. For simple single-span annotations or\nevaluator outputs, prefer `POST /preview/tracing/traces/`\n(`create_simple_trace`) — it's a higher-level helper on top of this\nendpoint.\n\n## Request body\n\nProvide exactly one of:\n\n- `spans`: a flat list of spans. Parent/child relationships are\n expressed via `parent_id` on each span.\n- `traces`: a nested tree keyed by `trace_id` then by span name,\n where each node may contain a `spans` dict of its children. The\n query endpoint (`POST /tracing/spans/query`) returns this shape.\n\nEach span requires `trace_id`, `span_id`, `start_time`, `end_time`.\n`trace_id` must be a 32-char hex UUID, `span_id` a 16-char hex.\nAttributes follow the Agenta convention under the `ag` namespace\n(`ag.type`, `ag.data`, `ag.metrics`, `ag.references`) and may be\nsubmitted either as a flat dotted map (OTel wire format) or as a\nnested object — both are accepted.\n\n## Response\n\nReturns `202 Accepted` with the links (`trace_id` + `span_id`) for\nthe spans that were parsed into the ingest stream. See\n[Tracing — Async write\ncontract](/reference/api-guide/tracing#async-write-contract-202)\nfor what `count < N submitted` means.\n\n## Example\n\n```json\n{\n \"spans\": [\n {\n \"trace_id\": \"f5a2efb40895881e938e2ebc070beca8\",\n \"span_id\": \"15f3df0731995245\",\n \"span_name\": \"completion_v0\",\n \"span_type\": \"workflow\",\n \"span_kind\": \"SPAN_KIND_SERVER\",\n \"start_time\": \"2026-04-16T18:18:18.491929Z\",\n \"end_time\": \"2026-04-16T18:18:20.415372Z\",\n \"attributes\": {\n \"ag.type.trace\": \"invocation\",\n \"ag.type.span\": \"workflow\",\n \"ag.data.inputs.country\": \"France\",\n \"ag.data.outputs\": \"Paris\"\n }\n }\n ]\n}\n```","operationId":"ingest_spans","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OTelTracingRequest"}}},"required":true},"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OTelLinksResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"deprecated":true}},"/tracing/spans/query":{"post":{"tags":["Deprecated"],"summary":"Query Spans","description":"Query spans and traces in the tracing backend.\n\nUse `focus` in the request body to control the response shape:\n\n- `\"trace\"` (default): returns a nested `traces` tree keyed by\n `trace_id` then by span name. Children hang off their parent's\n `spans` field. Best for rendering a trace waterfall.\n- `\"span\"`: returns a flat `spans` list. Best for paginating or\n filtering across all spans regardless of hierarchy.\n\nUse `oldest` / `newest` (unix seconds) to window the query and\n`limit` to cap the number of traces/spans returned.\n\nThe response preserves the Agenta `ag.*` attribute namespace and\nincludes computed metrics (`ag.metrics.duration`, `ag.metrics.tokens`,\n`ag.metrics.costs`) on each span. The `traces` tree returned here is\nthe same shape that `POST /tracing/spans/ingest` accepts as its\n`traces` field.","operationId":"query_spans_rpc","deprecated":true,"parameters":[{"name":"focus","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/Focus"},{"type":"null"}],"title":"Focus"}},{"name":"format","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/Format"},{"type":"null"}],"title":"Format"}},{"name":"oldest","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"null"}],"title":"Oldest"}},{"name":"newest","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"null"}],"title":"Newest"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}},{"name":"interval","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"}},{"name":"rate","in":"query","required":false,"schema":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Rate"}},{"name":"filter","in":"query","required":false,"schema":{"title":"Filter"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OTelTracingResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tracing/analytics/query":{"post":{"tags":["Deprecated"],"summary":"Fetch Analytics","description":"Aggregate span metrics into time buckets.\n\nRuns filtering and windowing identical to `POST /tracing/spans/query`,\nthen bucketizes the matched spans by time and computes one or more\nmetric summaries per bucket. Use this to build charts of latency,\ncost, token usage, or custom numeric and categorical attributes.\n\n## Request body\n\n- `filtering` — same shape as the query endpoint, scoped to the spans\n that contribute to the analytics.\n- `windowing` — `oldest`/`newest` for the time range and `interval`\n for bucket width (in seconds).\n- `specs` — a list of `MetricSpec` entries describing which\n attributes to summarize and how. Each spec declares a `type`\n (`numeric/continuous`, `numeric/discrete`, `binary`,\n `categorical/single`, `categorical/multiple`, `string`, `json`,\n or `*` for auto) and a dotted `path` into the span (for example\n `attributes.ag.metrics.costs.cumulative.total`).\n\n## Response\n\nBuckets are returned in chronological order. Each bucket carries a\n`metrics` dict keyed by spec path. See [Tracing — the ag.*\nnamespace](/reference/api-guide/tracing#the-ag-attribute-namespace)\nfor the cumulative/incremental metric layout on each span.","operationId":"query_analytics","deprecated":true,"parameters":[{"name":"focus","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/Focus"},{"type":"null"}],"title":"Focus"}},{"name":"format","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/Format"},{"type":"null"}],"title":"Format"}},{"name":"oldest","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"null"}],"title":"Oldest"}},{"name":"newest","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"null"}],"title":"Newest"}},{"name":"interval","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"}},{"name":"rate","in":"query","required":false,"schema":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Rate"}},{"name":"filter","in":"query","required":false,"schema":{"title":"Filter"}},{"name":"specs","in":"query","required":false,"schema":{"title":"Specs"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyticsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tracing/traces/":{"post":{"tags":["Deprecated"],"summary":"Create Trace","description":"Create a trace from one or more spans.\n\nThis is the single-trace counterpart to `POST /tracing/spans/ingest`.\nAccepts the same `OTelTracingRequest` body (either `spans` flat list\nor `traces` nested tree) but requires all spans to share a single\n`trace_id`.\n\nReturns `202 Accepted` with the links for the spans that entered\nthe ingest stream. See [Tracing — Async write\ncontract](/reference/api-guide/tracing#async-write-contract-202).\n\nMost callers should prefer `POST /tracing/spans/ingest` (no\nsingle-trace restriction) or `POST /simple/traces/` (helper for a\none-span payload).","operationId":"create_trace_tracing","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OTelTracingRequest"}}},"required":true},"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OTelLinksResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"deprecated":true}},"/tracing/traces/{trace_id}":{"get":{"tags":["Deprecated"],"summary":"Fetch Trace","description":"Fetch a single trace by `trace_id`.\n\nReturns the trace as a `traces` map keyed by `trace_id` → span\nname. The response is empty when the trace is not in the current\nproject. `trace_id` must be a 32-char hex UUID; any other format\nreturns `400`.\n\nFor flat-list retrieval across many traces, use\n`POST /tracing/spans/query` with `focus=\"span\"`.","operationId":"fetch_trace_tracing","deprecated":true,"parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OTelTracingResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Deprecated"],"summary":"Edit Trace","description":"Replace the spans of an existing trace.\n\nThe path `trace_id` must match the `trace_id` in the payload.\nMismatches return `400`. The payload must contain exactly one\ntrace; submitting spans from more than one trace returns `400`.\n\nEdit is implemented as a re-ingest: the new spans are written\nthrough the same stream as `POST /tracing/spans/ingest`, and the\n`202 Accepted` response reports how many spans entered the stream.\nThe worker reconciles the trace asynchronously.","operationId":"edit_trace_tracing","deprecated":true,"parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OTelTracingRequest"}}}},"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OTelLinksResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Deprecated"],"summary":"Delete Trace","description":"Delete a trace and all its spans.\n\nRemoves every span that shares this `trace_id` within the project.\nReturns `202 Accepted` with the links for the spans that were\nmarked for deletion. `trace_id` must be a 32-char hex UUID.\n\nDeletion is not reversible. For soft-removal semantics on a\nsingle-trace simple annotation, prefer\n`DELETE /simple/traces/{trace_id}`.","operationId":"delete_trace_tracing","deprecated":true,"parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}}],"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OTelLinksResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tracing/sessions/query":{"post":{"tags":["Deprecated"],"summary":"List Sessions","description":"List distinct session IDs from span attributes.\n\nReturns the distinct values of `ag.session.id` across spans in the\ncurrent project, in a windowed, cursor-paginated form. Use this to\ndrive a session-picker UI before drilling into the spans of each\nsession.\n\nThe `realtime` flag controls the cursor field:\n\n- `false` or unset — paginate by a stable `first_active` cursor\n (safe to iterate under heavy write load).\n- `true` — paginate by `last_active`, reflecting ongoing activity\n but less stable between pages.\n\nThe response includes a `windowing` cursor; pass it as `windowing.next`\non the next call to continue.","operationId":"query_sessions_tracing","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionsQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionIdsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"deprecated":true}},"/tracing/users/query":{"post":{"tags":["Deprecated"],"summary":"List Users","description":"List distinct user IDs from span attributes.\n\nReturns the distinct values of `ag.user.id` across spans in the\ncurrent project. Same pagination and `realtime` semantics as\n`POST /tracing/sessions/query`; pass the returned `windowing.next`\ncursor on subsequent calls.","operationId":"query_users","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UsersQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserIdsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"deprecated":true}},"/tracing/spans/analytics":{"post":{"tags":["Legacy"],"summary":"Fetch Legacy Analytics","description":"Aggregate span metrics using the fixed legacy schema.\n\nReturns time-bucketed aggregates with a fixed set of fields\n(`count`, `duration`, `costs`, `tokens`) split into `total` and\n`errors`. The shape predates `specs`-driven analytics and is kept\nfor the existing observability dashboards that consume it.\n\nNew integrations should prefer `POST /tracing/analytics/query`,\nwhich accepts `specs` and can summarize arbitrary span attributes,\nnot just the four fixed metrics.","operationId":"fetch_legacy_analytics","parameters":[{"name":"focus","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/Focus"},{"type":"null"}],"title":"Focus"}},{"name":"format","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/Format"},{"type":"null"}],"title":"Format"}},{"name":"oldest","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"null"}],"title":"Oldest"}},{"name":"newest","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"null"}],"title":"Newest"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}},{"name":"interval","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"}},{"name":"rate","in":"query","required":false,"schema":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Rate"}},{"name":"filter","in":"query","required":false,"schema":{"title":"Filter"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OldAnalyticsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/traces/":{"get":{"tags":["Traces"],"summary":"Fetch Traces","description":"Fetch multiple traces by known IDs.\n\nPoint lookup endpoint. Accepts either repeated query params\n(`?trace_id=a&trace_id=b`) or a comma-separated single param\n(`?trace_ids=a,b`). Results are deduplicated. Returns `400` when\nno IDs are supplied. Use `POST /traces/query` for filter-based\nretrieval.","operationId":"fetch_traces","parameters":[{"name":"trace_id","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Trace Id"}},{"name":"trace_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Trace Ids"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TracesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["Traces"],"summary":"Create Trace","description":"Create a single trace from the canonical `Trace` shape.\n\nAccepts one trace (`trace_id` plus a nested `spans` tree) and\nreturns the resulting `trace_id`. The payload is internally\nnormalized into the same ingest pipeline as\n`POST /tracing/spans/ingest`.\n\nReturns `202 Accepted`. The async write contract applies — see\n[Tracing — Async write\ncontract](/reference/api-guide/tracing#async-write-contract-202).\n\nUse this when you want to operate on whole traces in the\nlist-shaped `Trace` payload. For flat-list ingestion or multiple\ntraces in one call, use `POST /traces/ingest` (plural).","operationId":"create_trace","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TraceRequest"}}}},"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TraceIdResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/traces/query":{"post":{"tags":["Traces"],"summary":"Query Traces","description":"Query traces as a list of canonical `Trace` records.\n\nThin wrapper over the shared span-query backend that forces\n`focus = \"trace\"` and returns the list-shaped `Traces` payload\n(one entry per trace, each with its nested `spans` tree). Use this\nto build a table of runs, where each row is a trace.\n\n## Request body\n\n- `filtering` — span-level conditions, same dialect as\n `POST /spans/query`. A trace matches when any of its spans\n matches.\n- `windowing` — cursor pagination and time range.\n- `query_ref`, `query_variant_ref`, `query_revision_ref` — resolve\n filters and windowing from a saved query revision. If the\n revision's stored `formatting.focus` is `span`, this endpoint\n returns `409` — call `POST /spans/query` instead.\n\n## Response\n\nReturns `{count, traces: [...]}`. For the per-trace map shape\nkeyed by `trace_id`, call `POST /tracing/spans/query` with\n`focus=\"trace\"`.","operationId":"query_traces","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TracesQueryRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TracesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/traces/{trace_id}":{"get":{"tags":["Traces"],"summary":"Fetch Trace","description":"Fetch a single trace by `trace_id` in the canonical `Trace` shape.\n\nReturns `{count: 1, trace}` when found and `{count: 0}` otherwise.\n`trace_id` must be a 32-char hex UUID; any other format returns\n`400`. The reserved path segments `query` and `ingest` return\n`405` to disambiguate from the sibling query/ingest endpoints.","operationId":"fetch_trace","parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TraceResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Traces"],"summary":"Edit Trace","description":"Replace a trace's spans using the canonical `Trace` shape.\n\nPath `trace_id` must match the `trace_id` inside the payload's\n`trace.trace_id`. Mismatches return `400`. The payload must\ndescribe exactly one trace.\n\nEdit re-ingests the spans through the same stream as\n`POST /tracing/spans/ingest`. Returns `202 Accepted` once the\nspans are queued. The worker reconciles the trace asynchronously.","operationId":"edit_trace","parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TraceRequest"}}}},"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TraceIdResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Traces"],"summary":"Delete Trace","operationId":"delete_trace","parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}}],"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TraceIdResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/traces/ingest":{"post":{"tags":["Deprecated"],"summary":"Ingest Traces","description":"Ingest a batch of traces in the canonical `Traces` list shape.\n\nAccepts a list of trace records (each `trace_id` plus nested\n`spans`). Internally normalized into the same pipeline as\n`POST /tracing/spans/ingest`. Use this when you already hold\ndata in the `Traces` list shape — for example, replaying traces\nfrom another environment.\n\nReturns `202 Accepted` with the list of accepted `trace_ids`. See\n[Tracing — Async write\ncontract](/reference/api-guide/tracing#async-write-contract-202)\nfor what `count` means here.","operationId":"ingest_traces","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TracesRequest"}}},"required":true},"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TraceIdsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"deprecated":true}},"/spans/":{"get":{"tags":["Traces"],"summary":"Fetch Spans","description":"Fetch spans by known IDs.\n\nPoint lookup endpoint. At least one of `trace_id` or `span_id`\nmust be present. Both accept either repeated query params\n(`?trace_id=a&trace_id=b`) or a comma-separated single param\n(`?trace_ids=a,b`); results are deduplicated.\n\nReturns `400` when neither IDs nor trace IDs are supplied.\nFor filter-based retrieval, use `POST /spans/query`.","operationId":"fetch_spans","parameters":[{"name":"trace_id","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Trace Id"}},{"name":"trace_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Trace Ids"}},{"name":"span_id","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Span Id"}},{"name":"span_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Ids"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SpansResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/spans/query":{"post":{"tags":["Traces"],"summary":"Query Spans","description":"Query spans as a flat list.\n\nThin wrapper over the shared span-query backend that forces\n`focus = \"span\"`. Use this when you want a paged list of spans\nregardless of trace hierarchy — for example, to surface all LLM\ncalls across traces or to stream spans into an external system.\n\n## Request body\n\n- `filtering` — span-level conditions (fields on `Span` and\n `attributes` paths).\n- `windowing` — cursor pagination and time range (see\n [Query Pattern](/reference/api-guide/query-pattern#windowing)).\n- `query_ref`, `query_variant_ref`, `query_revision_ref` — resolve\n filtering and windowing from a saved query revision. If the\n revision's stored `formatting.focus` is `trace`, this endpoint\n returns `409` — call `POST /traces/query` for that revision.\n\n## Response\n\nReturns `{count, spans}`. For the nested per-trace shape, call\n`POST /traces/query` or `POST /tracing/spans/query` with\n`focus=\"trace\"` instead.","operationId":"query_spans","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SpansQueryRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SpansResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/spans/analytics/query":{"post":{"tags":["Traces"],"summary":"Query Analytics","operationId":"query_spans_analytics","parameters":[{"name":"focus","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/Focus"},{"type":"null"}],"title":"Focus"}},{"name":"format","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/Format"},{"type":"null"}],"title":"Format"}},{"name":"oldest","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"null"}],"title":"Oldest"}},{"name":"newest","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"null"}],"title":"Newest"}},{"name":"interval","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"}},{"name":"rate","in":"query","required":false,"schema":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Rate"}},{"name":"filter","in":"query","required":false,"schema":{"title":"Filter"}},{"name":"specs","in":"query","required":false,"schema":{"title":"Specs"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyticsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/spans/sessions/query":{"post":{"tags":["Traces"],"summary":"Query Sessions","operationId":"query_spans_sessions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionsQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionIdsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/spans/users/query":{"post":{"tags":["Traces"],"summary":"Query Users","operationId":"query_spans_users","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UsersQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserIdsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/spans/{trace_id}/{span_id}":{"get":{"tags":["Traces"],"summary":"Fetch Span","description":"Fetch a single span by `trace_id` + `span_id`.\n\nReturns `{count: 1, span}` when found and `{count: 0}` otherwise.\nBoth IDs are required path parameters. Use this to drill in on one\nspan from a trace waterfall without pulling the full tree.","operationId":"fetch_span","parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}},{"name":"span_id","in":"path","required":true,"schema":{"type":"string","title":"Span Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SpanResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/traces/":{"post":{"tags":["Traces"],"summary":"Create Trace","description":"Create a single-span \"simple\" trace.\n\nThis endpoint is a higher-level helper for the common case of\nrecording one self-contained event — an evaluator output, a human\nannotation, a feedback entry, a manually-logged inference. It\ncreates one span under a fresh `trace_id` and returns the resulting\nhandle.\n\n## When to use this vs. `/tracing/spans/ingest`\n\n- **Use this endpoint** when you have a single payload to record\n with no internal hierarchy: evaluation results, human feedback,\n manual annotations, or a standalone completion. It takes care of\n `trace_id`/`span_id` generation, attribute namespacing, and link\n wiring for you.\n- **Use `POST /tracing/spans/ingest`** when you need multi-span\n traces (e.g. an agent run with nested tool calls and LLM spans),\n precise control over IDs, timings, or parent/child relationships,\n or when forwarding traces from another OTel-compatible source.\n\n## Request body\n\nSend a `trace` object with:\n\n- `origin` — who produced the trace (`human`, `auto`, `custom`).\n- `kind` — intent (`adhoc`, `eval`, `play`).\n- `channel` — transport that produced it (`sdk`, `api`, `web`, `otlp`).\n- `data` — required dict carrying the actual payload (inputs,\n outputs, or evaluator results).\n- `tags`, `meta` — optional free-form dicts for filtering and\n metadata.\n- `references` — optional links to Agenta entities (application,\n variant, revision, evaluator, testset, etc.).\n- `links` — optional OTel-style links to other traces/spans.\n\nUse `PATCH /preview/tracing/traces/{trace_id}` to update fields\nlater, `GET` to fetch, and `DELETE` to remove. See\n[Tracing — References and links](/reference/api-guide/tracing#references-and-entity-linking)\nfor when to use `references` vs. `links`.","operationId":"create_simple_trace","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTraceCreateRequest"}}},"required":true},"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTraceResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/traces/{trace_id}":{"get":{"tags":["Traces"],"summary":"Fetch Trace","description":"Fetch a single \"simple\" trace by `trace_id`.\n\nReturns the high-level `SimpleTrace` view (origin, kind, channel,\ndata, references, links) rather than the raw OTel span shape. Use\nthis for evaluation results, feedback entries, and annotations\ncreated via `POST /simple/traces/`. For the span-level view of the\nsame trace, call `GET /tracing/traces/{trace_id}`.","operationId":"fetch_simple_trace","parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTraceResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Traces"],"summary":"Edit Trace","description":"Update an existing \"simple\" trace.\n\nSupplied fields overwrite the existing trace. Fields not present\nin the request body are left unchanged. `data` is required (the\npayload being recorded); `tags`, `meta`, `references`, and\n`links` are optional.\n\nThis endpoint is intended for annotations and feedback entries,\nwhere the `data.outputs` is the part that typically gets revised.\nFor span-level edits, use `PUT /tracing/traces/{trace_id}`.","operationId":"edit_simple_trace","parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTraceEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTraceResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Traces"],"summary":"Delete Trace","description":"Delete a \"simple\" trace.\n\nRemoves the single-span trace created via\n`POST /simple/traces/`. Returns the `(trace_id, span_id)` pair\nthat was removed, for logging or downstream cleanup. Use\n`DELETE /tracing/traces/{trace_id}` when operating on a\nmulti-span trace.","operationId":"delete_simple_trace","parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTraceLinkResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/traces/query":{"post":{"tags":["Traces"],"summary":"Query Traces","description":"Query \"simple\" traces.\n\nFilter annotations and feedback by `origin`, `kind`, `channel`,\n`tags`, `meta`, `references`, and `links`. The shape of the\nrequest body is described in the\n[Simple Endpoints](/reference/api-guide/simple-endpoints#query-traces)\nguide, including the distinction between filtering via\n`trace.links` (inbound links on the trace) and the top-level\n`links` (batch GET by the trace's own IDs).\n\nUse this endpoint when building feedback or annotation UIs.\nFor span-level queries across all trace types, use\n`POST /tracing/spans/query`.","operationId":"query_simple_traces","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTraceQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTracesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/invocations/":{"post":{"tags":["Invocations"],"summary":"Create Invocation","operationId":"create_invocation","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvocationCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvocationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/invocations/{trace_id}":{"get":{"tags":["Invocations"],"summary":"Fetch Invocation","operationId":"fetch_invocation","parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}},{"name":"span_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvocationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Invocations"],"summary":"Edit Invocation","operationId":"edit_invocation","parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}},{"name":"span_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvocationEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvocationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Invocations"],"summary":"Delete Invocation","operationId":"delete_invocation","parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}},{"name":"span_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvocationLinkResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/invocations/query":{"post":{"tags":["Invocations"],"summary":"Query Invocations","operationId":"query_invocations","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvocationQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvocationsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/annotations/":{"post":{"tags":["Annotations"],"summary":"Create Annotation","operationId":"create_annotation","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnnotationCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnnotationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/annotations/{trace_id}":{"get":{"tags":["Annotations"],"summary":"Fetch Annotation","operationId":"fetch_annotation","parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}},{"name":"span_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnnotationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Annotations"],"summary":"Edit Annotation","operationId":"edit_annotation","parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}},{"name":"span_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnnotationEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnnotationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Annotations"],"summary":"Delete Annotation","operationId":"delete_annotation","parameters":[{"name":"trace_id","in":"path","required":true,"schema":{"type":"string","title":"Trace Id"}},{"name":"span_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnnotationLinkResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/annotations/query":{"post":{"tags":["Annotations"],"summary":"Query Annotations","operationId":"query_annotations","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnnotationQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnnotationsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testcases/":{"get":{"tags":["Testcases"],"summary":"Fetch Testcases","operationId":"fetch_testcases","parameters":[{"name":"testcase_id","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Testcase Id"}},{"name":"testcase_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Testcase Ids"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestcasesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testcases/{testcase_id}":{"get":{"tags":["Testcases"],"summary":"Fetch Testcase","operationId":"fetch_testcase","parameters":[{"name":"testcase_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testcase Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestcaseResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testcases/query":{"post":{"tags":["Testcases"],"summary":"Query Testcases","operationId":"query_testcases","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestcasesQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestcasesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/":{"post":{"tags":["Testsets"],"summary":"Create Testset","description":"Create an empty testset artifact.\n\nOnly creates the artifact row (name, slug, metadata). No variant or\nrevision is created; add testcases by committing a revision with\n`/testsets/revisions/commit`, or use `/simple/testsets/` to create\na testset with seed rows in a single call.","operationId":"create_testset","parameters":[{"name":"testset_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/{testset_id}":{"get":{"tags":["Testsets"],"summary":"Fetch Testset","description":"Fetch a testset artifact by ID.\n\nReturns the artifact row only; testcases are stored on revisions and\nmust be fetched via `/testsets/revisions/retrieve` or\n`/testcases/query`.","operationId":"fetch_testset","parameters":[{"name":"testset_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Testsets"],"summary":"Edit Testset","description":"Update metadata on a testset artifact.\n\nOnly artifact-level fields (name, description, slug, flags, tags,\nmeta, folder) are editable here. Testcase changes are committed as\nnew revisions via `/testsets/revisions/commit`.","operationId":"edit_testset","parameters":[{"name":"testset_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/{testset_id}/archive":{"post":{"tags":["Testsets"],"summary":"Archive Testset","description":"Soft-delete a testset artifact.\n\nSets `deleted_at` on the testset. Archived testsets are excluded\nfrom `/testsets/query` unless `include_archived` is true. Use\n`/testsets/{testset_id}/unarchive` to restore.","operationId":"archive_testset","parameters":[{"name":"testset_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/{testset_id}/unarchive":{"post":{"tags":["Testsets"],"summary":"Unarchive Testset","description":"Restore a previously archived testset artifact.\n\nClears `deleted_at` on the testset so it shows up in queries again.","operationId":"unarchive_testset","parameters":[{"name":"testset_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/query":{"post":{"tags":["Testsets"],"summary":"Query Testsets","description":"List and filter testset artifacts.\n\nFollows the shared query pattern: attribute filters on the testset\nbody, optional `testset_refs` to restrict by id/slug, cursor-based\npagination via `windowing`. Only artifact rows are returned — no\ntestcases. See the Query Pattern guide for the full body shape.","operationId":"query_testsets","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/variants/":{"post":{"tags":["Testsets"],"summary":"Create Testset Variant","description":"Create a variant (history branch) on a testset.\n\nMost testsets only need one variant. Create additional variants to\nmaintain parallel revision histories (for example, a staging branch\nseparate from the main one).","operationId":"create_testset_variant","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetVariantCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/variants/{testset_variant_id}":{"get":{"tags":["Testsets"],"summary":"Fetch Testset Variant","description":"Fetch a variant by ID.\n\nReturns the variant row (branch metadata). Use\n`/testsets/revisions/retrieve` to get the latest revision on this\nvariant.","operationId":"fetch_testset_variant","parameters":[{"name":"testset_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Testsets"],"summary":"Edit Testset Variant","description":"Update metadata on a testset variant.\n\nVariants hold only branch-level metadata (name, description, slug,\nflags, tags, meta). Testcase content belongs to revisions.","operationId":"edit_testset_variant","parameters":[{"name":"testset_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Variant Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetVariantEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/variants/{testset_variant_id}/archive":{"post":{"tags":["Testsets"],"summary":"Archive Testset Variant","description":"Soft-delete a testset variant.\n\nArchiving a variant excludes it from `/testsets/variants/query`\nunless `include_archived` is true. Its revisions stay in place and\ncan still be retrieved by ID.","operationId":"archive_testset_variant","parameters":[{"name":"testset_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/variants/{testset_variant_id}/unarchive":{"post":{"tags":["Testsets"],"summary":"Unarchive Testset Variant","description":"Restore a previously archived testset variant.","operationId":"unarchive_testset_variant","parameters":[{"name":"testset_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/variants/query":{"post":{"tags":["Testsets"],"summary":"Query Testset Variants","description":"List and filter testset variants.\n\nUse `testset_refs` to scope to one or more parent testsets. Use\n`testset_variant_refs` to restrict by specific variant id/slug.","operationId":"query_testset_variants","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetVariantQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetVariantsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/variants/fork":{"post":{"tags":["Testsets"],"summary":"Fork Testset Variant","description":"Fork an existing testset variant into a new variant.\n\nThe new variant starts from the source variant's head revision (or a\npinned revision if `testset_revision_ref` is provided). Provide `slug`\nand `name` in the fork body to identify the new variant.","operationId":"fork_testset_variant","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetVariantForkRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/revisions/":{"post":{"tags":["Testsets"],"summary":"Create Testset Revision","description":"Create and commit the initial revision for a testset variant.\n\nMost callers instead use `/testsets/revisions/commit`, which writes\nthe testcases and the revision together. This endpoint commits an\ninitial revision with the `initial` guard, preventing duplicate\ninitial revisions for the same variant.","operationId":"create_testset_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/revisions/{testset_revision_id}":{"get":{"tags":["Testsets"],"summary":"Fetch Testset Revision","operationId":"fetch_testset_revision","parameters":[{"name":"testset_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Revision Id"}},{"name":"include_testcases","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"Include full testcase objects. Default (null/true): include testcases. False: return only testcase IDs.","title":"Include Testcases"},"description":"Include full testcase objects. Default (null/true): include testcases. False: return only testcase IDs."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Testsets"],"summary":"Edit Testset Revision","operationId":"edit_testset_revision","parameters":[{"name":"testset_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Revision Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/revisions/{testset_revision_id}/archive":{"post":{"tags":["Testsets"],"summary":"Archive Testset Revision","operationId":"archive_testset_revision","parameters":[{"name":"testset_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/revisions/{testset_revision_id}/unarchive":{"post":{"tags":["Testsets"],"summary":"Unarchive Testset Revision","operationId":"unarchive_testset_revision","parameters":[{"name":"testset_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/revisions/{testset_revision_id}/download":{"post":{"tags":["Testsets"],"summary":"Fetch Testset Revision To File","operationId":"fetch_testset_revision_to_file","parameters":[{"name":"testset_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Revision Id"}},{"name":"file_type","in":"query","required":false,"schema":{"anyOf":[{"enum":["csv","json"],"type":"string"},{"type":"null"}],"description":"File type to download. Supported: 'csv' or 'json'. Default: 'csv'.","default":"csv","title":"File Type"},"description":"File type to download. Supported: 'csv' or 'json'. Default: 'csv'."},{"name":"file_name","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Optional custom filename for the download.","title":"File Name"},"description":"Optional custom filename for the download."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/revisions/{testset_revision_id}/upload":{"post":{"tags":["Testsets"],"summary":"Create Testset Revision From File","operationId":"create_testset_revision_from_file","parameters":[{"name":"testset_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Revision Id"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_create_testset_revision_from_file"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/revisions/query":{"post":{"tags":["Testsets"],"summary":"Query Testset Revisions","operationId":"query_testset_revisions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/revisions/commit":{"post":{"tags":["Testsets"],"summary":"Commit Testset Revision","operationId":"commit_testset_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionCommitRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/revisions/retrieve":{"post":{"tags":["Testsets"],"summary":"Retrieve Testset Revision","operationId":"retrieve_testset_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionRetrieveRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/testsets/revisions/log":{"post":{"tags":["Testsets"],"summary":"Log Testset Revisions","operationId":"log_testset_revisions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionsLogRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestsetRevisionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/testsets/":{"post":{"tags":["Testsets"],"summary":"Create Simple Testset","operationId":"create_simple_testset","parameters":[{"name":"testset_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTestsetCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTestsetResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/testsets/{testset_id}":{"get":{"tags":["Testsets"],"summary":"Fetch Simple Testset","operationId":"fetch_simple_testset","parameters":[{"name":"testset_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTestsetResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Testsets"],"summary":"Edit Simple Testset","operationId":"edit_simple_testset","parameters":[{"name":"testset_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTestsetEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTestsetResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/testsets/{testset_id}/archive":{"post":{"tags":["Testsets"],"summary":"Archive Simple Testset","operationId":"archive_simple_testset","parameters":[{"name":"testset_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTestsetResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/testsets/{testset_id}/unarchive":{"post":{"tags":["Testsets"],"summary":"Unarchive Simple Testset","operationId":"unarchive_simple_testset","parameters":[{"name":"testset_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTestsetResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/testsets/{testset_id}/upload":{"post":{"tags":["Testsets"],"summary":"Edit Simple Testset From File","operationId":"edit_simple_testset_from_file","parameters":[{"name":"testset_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Id"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_edit_simple_testset_from_file"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTestsetResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/testsets/{testset_id}/download":{"post":{"tags":["Testsets"],"summary":"Fetch Simple Testset To File","operationId":"fetch_simple_testset_to_file","parameters":[{"name":"testset_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Testset Id"}},{"name":"file_type","in":"query","required":false,"schema":{"anyOf":[{"enum":["csv","json"],"type":"string"},{"type":"null"}],"title":"File Type"}},{"name":"file_name","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"File Name"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/testsets/query":{"post":{"tags":["Testsets"],"summary":"Query Simple Testsets","operationId":"query_simple_testsets","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTestsetQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTestsetsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/testsets/upload":{"post":{"tags":["Testsets"],"summary":"Create Simple Testset From File","operationId":"create_simple_testset_from_file","requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_create_simple_testset_from_file"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleTestsetResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/":{"post":{"tags":["Queries"],"summary":"Create Query","operationId":"create_query","parameters":[{"name":"query_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Query Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/{query_id}":{"get":{"tags":["Queries"],"summary":"Fetch Query","operationId":"fetch_query","parameters":[{"name":"query_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Queries"],"summary":"Edit Query","operationId":"edit_query","parameters":[{"name":"query_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/{query_id}/archive":{"post":{"tags":["Queries"],"summary":"Archive Query","operationId":"archive_query","parameters":[{"name":"query_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/{query_id}/unarchive":{"post":{"tags":["Queries"],"summary":"Unarchive Query","operationId":"unarchive_query","parameters":[{"name":"query_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/query":{"post":{"tags":["Queries"],"summary":"Query Queries","operationId":"query_queries","parameters":[{"name":"query_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Query Id"}},{"name":"query_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Query Ids"}},{"name":"query_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Query Slug"}},{"name":"query_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Query Slugs"}},{"name":"name","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"}},{"name":"description","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},{"name":"flags","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Flags"}},{"name":"tags","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tags"}},{"name":"meta","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Meta"}},{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"}},{"name":"next","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Next"}},{"name":"newest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Newest"}},{"name":"oldest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Oldest"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"enum":["ascending","descending"],"type":"string"},{"type":"null"}],"title":"Order"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueriesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/variants/":{"post":{"tags":["Queries"],"summary":"Create Query Variant","operationId":"create_query_variant","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryVariantCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/variants/{query_variant_id}":{"get":{"tags":["Queries"],"summary":"Fetch Query Variant","operationId":"fetch_query_variant","parameters":[{"name":"query_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Queries"],"summary":"Edit Query Variant","operationId":"edit_query_variant","parameters":[{"name":"query_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Variant Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryVariantEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/variants/{query_variant_id}/archive":{"post":{"tags":["Queries"],"summary":"Archive Query Variant","operationId":"archive_query_variant","parameters":[{"name":"query_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/variants/{query_variant_id}/unarchive":{"post":{"tags":["Queries"],"summary":"Unarchive Query Variant","operationId":"unarchive_query_variant","parameters":[{"name":"query_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/variants/query":{"post":{"tags":["Queries"],"summary":"Query Query Variants","operationId":"query_query_variants","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryVariantQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryVariantsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/variants/fork":{"post":{"tags":["Queries"],"summary":"Fork Query Variant","description":"Fork an existing query variant into a new variant.\n\nThe new variant starts from the source variant's head revision (or a\npinned revision if `query_revision_ref` is provided). Provide `slug`\nand `name` in the fork body to identify the new variant.","operationId":"fork_query_variant","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryVariantForkRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/revisions/retrieve":{"post":{"tags":["Queries"],"summary":"Retrieve Query Revision","operationId":"retrieve_query_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRevisionRetrieveRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/revisions/":{"post":{"tags":["Queries"],"summary":"Create Query Revision","operationId":"create_query_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRevisionCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/revisions/{query_revision_id}":{"get":{"tags":["Queries"],"summary":"Fetch Query Revision","operationId":"fetch_query_revision","parameters":[{"name":"query_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Queries"],"summary":"Edit Query Revision","operationId":"edit_query_revision","parameters":[{"name":"query_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Revision Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRevisionEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/revisions/{query_revision_id}/archive":{"post":{"tags":["Queries"],"summary":"Archive Query Revision","operationId":"archive_query_revision","parameters":[{"name":"query_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/revisions/{query_revision_id}/unarchive":{"post":{"tags":["Queries"],"summary":"Unarchive Query Revision","operationId":"unarchive_query_revision","parameters":[{"name":"query_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/revisions/query":{"post":{"tags":["Queries"],"summary":"Query Query Revisions","operationId":"query_query_revisions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRevisionQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRevisionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/revisions/commit":{"post":{"tags":["Queries"],"summary":"Commit Query Revision","operationId":"commit_query_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRevisionCommitRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/queries/revisions/log":{"post":{"tags":["Queries"],"summary":"Log Query Revisions","operationId":"log_query_revisions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRevisionsLogRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRevisionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/queries/":{"post":{"tags":["Queries"],"summary":"Create Simple Query","operationId":"create_simple_query","parameters":[{"name":"query_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Query Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueryCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/queries/{query_id}":{"get":{"tags":["Queries"],"summary":"Fetch Simple Query","operationId":"fetch_simple_query","parameters":[{"name":"query_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Queries"],"summary":"Edit Simple Query","operationId":"edit_simple_query","parameters":[{"name":"query_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueryEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/queries/{query_id}/archive":{"post":{"tags":["Queries"],"summary":"Archive Simple Query","operationId":"archive_simple_query","parameters":[{"name":"query_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/queries/{query_id}/unarchive":{"post":{"tags":["Queries"],"summary":"Unarchive Simple Query","operationId":"unarchive_simple_query","parameters":[{"name":"query_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Query Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/queries/query":{"post":{"tags":["Queries"],"summary":"Query Simple Queries","operationId":"query_simple_queries","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueryQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueriesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/folders/":{"post":{"tags":["Folders"],"summary":"Create Folder","description":"Create a folder.\n\nThe folder name must match `[\\w -]+` (letters, digits, underscore,\nspace, hyphen); other characters return `400`. The resulting path\n(the slug joined to the parent's path with a dot) must be unique\nwithin the project, otherwise the call returns `409`. Passing a\n`parent_id` that does not exist returns `404`. Paths are capped at\n10 levels of nesting and slugs at 64 characters.","operationId":"create_folder","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FolderCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FolderResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/folders/{folder_id}":{"get":{"tags":["Folders"],"summary":"Fetch Folder","description":"Fetch one folder by id.\n\nReturns a single `folder` envelope. If the folder does not exist in\nthe caller's project, `count` is `0` and `folder` is omitted.","operationId":"fetch_folder","parameters":[{"name":"folder_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Folder Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FolderResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Folders"],"summary":"Edit Folder","description":"Rename or move a folder.\n\nUse this endpoint to change a folder's `slug`, `name`, or\n`parent_id`. The `id` in the request body must match the path\nparameter or the call returns `400`. Name and path-uniqueness rules\nfrom create apply: invalid names return `400`, a path collision\nreturns `409`, and a missing `parent_id` returns `404`.","operationId":"edit_folder","parameters":[{"name":"folder_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Folder Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FolderEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FolderResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Folders"],"summary":"Delete Folder","description":"Delete a folder and every descendant.\n\nRemoves the folder identified by `folder_id` together with every\nfolder beneath it, in a single transaction. Deletion is\nunconditional; there is no archive or unarchive step. Resources\nthat were assigned to any of the removed folders continue to\nexist and are no longer reachable through the deleted folder.","operationId":"delete_folder","parameters":[{"name":"folder_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Folder Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FolderIdResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/folders/query":{"post":{"tags":["Folders"],"summary":"Query Folders","description":"Filter folders inside the caller's project.\n\nFollows the general response envelope described in the\n[Query Pattern](/reference/api-guide/query-pattern) guide, but\ndoes not accept `windowing` or `include_archived` — folders are\nhard-deleted and the response always returns the full filtered\nset. Filters include `id`/`ids`, `slug`/`slugs`, `kind`/`kinds`,\n`parent_id`/`parent_ids` (use `parent_id: null` for root folders),\n`path`/`paths`, and `prefix`/`prefixes` for subtree lookup.","operationId":"query_folders","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FolderQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FoldersResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/streams/":{"get":{"tags":["Sessions","Sessions"],"summary":"Fetch Session Stream","operationId":"fetch_session_stream","parameters":[{"name":"session_id","in":"query","required":true,"schema":{"type":"string","title":"Session Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionStreamResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["Sessions","Sessions"],"summary":"Set Session Stream","operationId":"set_session_stream","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionStreamCommandRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionStreamCommandResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Sessions","Sessions"],"summary":"Delete Session Stream","operationId":"delete_session_stream","parameters":[{"name":"session_id","in":"query","required":true,"schema":{"type":"string","title":"Session Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Delete Session Stream"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/streams/query":{"post":{"tags":["Sessions","Sessions"],"summary":"Query Session Streams","operationId":"query_session_streams","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionStreamQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionStreamsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/streams/detach":{"post":{"tags":["Sessions","Sessions"],"summary":"Detach Session Stream","operationId":"detach_session_stream","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionDetachRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Detach Session Stream"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/streams/heartbeat":{"post":{"tags":["Sessions","Sessions"],"summary":"Heartbeat Session Stream","operationId":"heartbeat_session_stream","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionHeartbeatRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionHeartbeatResult"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/streams/header":{"put":{"tags":["Sessions","Sessions"],"summary":"Set Session Stream Header","operationId":"set_session_stream_header","parameters":[{"name":"session_id","in":"query","required":true,"schema":{"type":"string","title":"Session Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionStreamHeaderEdit"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionStreamResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["Sessions","Sessions"],"summary":"Set Session Stream Header","operationId":"set_session_stream_header","parameters":[{"name":"session_id","in":"query","required":true,"schema":{"type":"string","title":"Session Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionStreamHeaderEdit"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionStreamResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/streams/watch":{"get":{"tags":["Sessions","Sessions"],"summary":"Watch Session Stream","description":"Server-sent events relay for one session (M3 live relay).\n\nEmits change notifications only — never record payloads; clients\nrevalidate through the regular query endpoints on each event:\n\n- ``event: records-changed`` — ``{\"session_id\"}``; new/updated rows\n landed in the record log (published post-DB-commit).\n- ``event: lifecycle`` — ``{\"session_id\", \"state\": \"running\"|\"ended\"}``.\n- ``event: interaction`` — ``{\"session_id\", \"status\": \"pending\"|\"resolved\"}``.\n- ``: heartbeat`` comment frames while idle (keep-alive).\n\nAuth is the standard middleware (cookie ``sAccessToken``, ApiKey, or\nBearer) evaluated once at connect; scope is the credential's project.\nBrowsers authenticate by cookie — ``EventSource`` cannot set headers —\nso a connect landing on an expired access token 401s like any other\nrequest. There is no interceptor to refresh-and-retry a stream, so the\nclient must refresh the session itself and reopen (see the web hooks).\n\nThe stream has no replay/cursor semantics — ``EventSource`` reconnects\nand clients revalidate once on every ``open``, which covers any missed\nnotifications.\n\nNOTE (spec surface): this route appears in OpenAPI for documentation,\nbut Fern does not model SSE — consume it with a native ``EventSource``\n(same-origin ``/api`` + cookie auth needs no custom headers), not the\ngenerated client.","operationId":"watch_session_stream","parameters":[{"name":"session_id","in":"query","required":true,"schema":{"type":"string","title":"Session Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/watch":{"get":{"tags":["Sessions","Sessions"],"summary":"Watch Project","description":"Relay low-frequency entity changes for the authorized project.\n\nA caller with only one required view permission cannot open this stream and falls back to\nthe lists' polling behavior.","operationId":"watch_project","parameters":[{"name":"project_id","in":"query","required":true,"schema":{"type":"string","format":"uuid","title":"Project Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/catalog/types/":{"get":{"tags":["Applications"],"summary":"List Application Catalog Types","description":"List shared catalog types.\n\nCatalog types are reusable JSON-Schema building blocks referenced from\ntemplate schemas (for example `message`, `prompt-template`). Types are\nread-only and version with the product.\nSee the [Applications guide](/reference/api-guide/applications#catalog).","operationId":"list_application_catalog_types","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationCatalogTypesResponse"}}}}}}},"/applications/catalog/templates/":{"get":{"tags":["Applications"],"summary":"List Application Catalog Templates","description":"List application templates available in the catalog.\n\nTemplates describe the handler (`uri`) and JSON schemas used to create\na new application. Pass `include_archived=true` to include retired\ntemplates (useful when editing applications created from an old\ntemplate). Templates are global and read-only.","operationId":"list_application_catalog_templates","parameters":[{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationCatalogTemplatesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/catalog/templates/{template_key}":{"get":{"tags":["Applications"],"summary":"Fetch Application Catalog Template","description":"Fetch one application template by key.\n\nUse this to inspect the exact `uri`, `data`, and JSON Schemas for a\ntemplate before creating an application from it. `template_key` comes\nfrom the `key` field of a template returned by the list endpoint\n(for example `completion`, `chat`, `hook`).","operationId":"fetch_application_catalog_template","parameters":[{"name":"template_key","in":"path","required":true,"schema":{"type":"string","title":"Template Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationCatalogTemplateResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/catalog/templates/{template_key}/presets/":{"get":{"tags":["Applications"],"summary":"List Application Catalog Presets","description":"List presets scoped to a template.\n\nPresets are named parameter sets (for example a curated prompt +\nmodel combination) that scaffold the first revision when creating an\napplication from a template. Pass `include_archived=true` to include\nretired presets.","operationId":"list_application_catalog_presets","parameters":[{"name":"template_key","in":"path","required":true,"schema":{"type":"string","title":"Template Key"}},{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationCatalogPresetsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/catalog/templates/{template_key}/presets/{preset_key}":{"get":{"tags":["Applications"],"summary":"Fetch Application Catalog Preset","description":"Fetch one preset by key within a template.\n\nReturns the preset's `data` so clients can use it as the payload for a\nfirst revision when creating an application from a template.","operationId":"fetch_application_catalog_preset","parameters":[{"name":"template_key","in":"path","required":true,"schema":{"type":"string","title":"Template Key"}},{"name":"preset_key","in":"path","required":true,"schema":{"type":"string","title":"Preset Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationCatalogPresetResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/":{"post":{"tags":["Applications"],"summary":"Create Application","description":"Create an application artifact only.\n\nReturns an empty application without any variants or revisions.\nMost callers should use `POST /simple/applications/` instead — it\ncreates the artifact, a default variant, and a first committed\nrevision in one request.\nSee the [Applications guide](/reference/api-guide/applications).","operationId":"create_application","parameters":[{"name":"application_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/{application_id}":{"get":{"tags":["Applications"],"summary":"Fetch Application","description":"Fetch one application artifact by ID.\n\nReturns artifact-level fields only. To get the current variant,\nrevision, and `data` in a single call, use\n`GET /simple/applications/{application_id}`.","operationId":"fetch_application","parameters":[{"name":"application_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Applications"],"summary":"Edit Application","description":"Edit artifact-level fields on an application.\n\nEditable fields: `description`, `flags`, `tags`, `meta`. Editing `name`\nis currently disabled and returns `400`. Prompt or model-parameter\nchanges go through `POST /applications/revisions/commit`, not this\nendpoint.","operationId":"edit_application","parameters":[{"name":"application_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/{application_id}/archive":{"post":{"tags":["Applications"],"summary":"Archive Application","description":"Soft-delete an application.\n\nArchiving sets `deleted_at` on the application and hides it from\nqueries that don't set `include_archived: true`. Its variants and\nrevisions become unreachable from listing but their IDs remain\nresolvable so historical traces stay intact.\nSee [Versioning](/reference/api-guide/versioning#archive-and-unarchive).","operationId":"archive_application","parameters":[{"name":"application_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/{application_id}/unarchive":{"post":{"tags":["Applications"],"summary":"Unarchive Application","description":"Restore a previously archived application.\n\nClears `deleted_at` and makes the application visible to standard\nqueries again. Safe to call on an already-active application; it is a\nno-op in that case.","operationId":"unarchive_application","parameters":[{"name":"application_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/query":{"post":{"tags":["Applications"],"summary":"Query Applications","description":"Query application artifacts.\n\nReturns only artifact-level fields; the variant, revision, and `data`\npayload are not included. For one row per application with those\nmerged in, use `POST /simple/applications/query`.\nSee [Query Pattern](/reference/api-guide/query-pattern).","operationId":"query_applications","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/variants/":{"post":{"tags":["Applications"],"summary":"Create Application Variant","description":"Create a new variant on an existing application.\n\nA variant is an independent branch of the application's history. The\nnew variant starts empty — call `POST /applications/revisions/commit`\nto add its first revision. Use `POST /applications/variants/fork` when\nyou want the new variant to inherit an existing revision history.","operationId":"create_application_variant","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationVariantCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/variants/{application_variant_id}":{"get":{"tags":["Applications"],"summary":"Fetch Application Variant","description":"Fetch one variant by ID.\n\nReturns variant-level fields. To get the variant's tip revision and\nits `data`, call `POST /applications/revisions/retrieve` with\n`application_variant_ref`.","operationId":"fetch_application_variant","parameters":[{"name":"application_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Applications"],"summary":"Edit Application Variant","description":"Edit a variant's header fields (`name`, `description`, `tags`, `meta`).\n\nConfiguration changes go through a new commit via\n`POST /applications/revisions/commit`. This endpoint only touches\nvariant-level metadata.","operationId":"edit_application_variant","parameters":[{"name":"application_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Variant Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationVariantEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/variants/{application_variant_id}/archive":{"post":{"tags":["Applications"],"summary":"Archive Application Variant","description":"Soft-delete a variant.\n\nThe variant and its revisions are hidden from queries unless\n`include_archived: true` is sent. Revision IDs remain resolvable so\nhistorical traces are preserved.","operationId":"archive_application_variant","parameters":[{"name":"application_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/variants/{application_variant_id}/unarchive":{"post":{"tags":["Applications"],"summary":"Unarchive Application Variant","description":"Restore a previously archived variant.","operationId":"unarchive_application_variant","parameters":[{"name":"application_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/variants/query":{"post":{"tags":["Applications"],"summary":"Query Application Variants","description":"Query variants across one or more applications.\n\nFilters are parsed from both query-string parameters and the request\nbody; body values take precedence. Use `application_refs` to scope to\nspecific applications, `application_variant_refs` to narrow to\nspecific variants.\nSee [Query Pattern](/reference/api-guide/query-pattern).","operationId":"query_application_variants","parameters":[{"name":"application_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Id"}},{"name":"application_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Application Ids"}},{"name":"application_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Slug"}},{"name":"application_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Application Slugs"}},{"name":"application_variant_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Variant Id"}},{"name":"application_variant_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Application Variant Ids"}},{"name":"application_variant_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Variant Slug"}},{"name":"application_variant_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Application Variant Slugs"}},{"name":"name","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"}},{"name":"description","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},{"name":"flags","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Flags"}},{"name":"tags","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tags"}},{"name":"meta","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Meta"}},{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"}},{"name":"next","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Next"}},{"name":"newest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Newest"}},{"name":"oldest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Oldest"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"enum":["ascending","descending"],"type":"string"},{"type":"null"}],"title":"Order"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationVariantsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/variants/fork":{"post":{"tags":["Applications"],"summary":"Fork Application Variant","description":"Fork an existing variant into a new variant on the same application.\n\nUse this to experiment without touching the source variant's history.\nThe fork copies the source variant's revisions up to the specified\nrevision (or tip) into the new variant, then commits the supplied\n`revision` object on top. Both `variant` and `revision` sub-objects\nin the request must be present; the server returns `count: 0` when\neither is missing. Returns `400 Bad Request` if the fork target is\ninvalid (for example, the source variant or revision cannot be\nlocated in this application's lineage).","operationId":"fork_application_variant","parameters":[{"name":"application_variant_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Variant Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationVariantForkRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/revisions/retrieve":{"post":{"tags":["Applications"],"summary":"Retrieve Application Revision","description":"Retrieve one application revision by reference.\n\nAccepts application / variant / revision references for direct lookup,\nor an environment reference (with optional `key`) to resolve the\ncurrently-deployed revision in that environment. Returns the revision\nincluding its `data` payload (URL, parameters, schemas), which clients\nuse to invoke the application.\nSet `resolve: true` to inline embedded references inside `data`.","operationId":"retrieve_application_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionRetrieveRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/revisions/deploy":{"post":{"tags":["Applications"],"summary":"Deploy Application Revision","description":"Deploy an application revision to an environment.\n\nWrites a reference from the environment revision to the application\nrevision under `key` (default: `{application_slug}.revision`). Clients\nthat subsequently call `/applications/revisions/retrieve` with the\nsame `environment_ref` and `key` resolve to this revision.\nSee the [Applications guide](/reference/api-guide/applications#deployment).","operationId":"deploy_application_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionDeployRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/revisions/":{"post":{"tags":["Applications"],"summary":"Create Application Revision","description":"Create and commit the initial revision for an application variant.\n\nAdvanced use only. For normal development loops prefer\n`POST /applications/revisions/commit`, which commits the new revision\nas the variant's tip and assigns a version number.","operationId":"create_application_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/revisions/{application_revision_id}":{"get":{"tags":["Applications"],"summary":"Fetch Application Revision","description":"Fetch one revision by its ID.\n\nReturns the revision including its `data` payload. For lookup by\nvariant slug or environment, use `POST /applications/revisions/retrieve`.","operationId":"fetch_application_revision","parameters":[{"name":"application_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Applications"],"summary":"Edit Application Revision","description":"Edit a revision's header fields only.\n\nRevisions are immutable snapshots; `data`, `author`, `date`, and\n`message` cannot be changed. This endpoint updates header fields such\nas `description` and `tags`. To change configuration, commit a new\nrevision with `POST /applications/revisions/commit`.","operationId":"edit_application_revision","parameters":[{"name":"application_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Revision Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/revisions/{application_revision_id}/archive":{"post":{"tags":["Applications"],"summary":"Archive Application Revision","description":"Soft-delete a revision.\n\nArchived revisions are hidden from `/query` and `/log` responses\nunless `include_archived: true` is set. The ID remains resolvable for\ntraces and deployed environment references.","operationId":"archive_application_revision","parameters":[{"name":"application_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/revisions/{application_revision_id}/unarchive":{"post":{"tags":["Applications"],"summary":"Unarchive Application Revision","description":"Restore a previously archived revision.","operationId":"unarchive_application_revision","parameters":[{"name":"application_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/revisions/query":{"post":{"tags":["Applications"],"summary":"Query Application Revisions","description":"Query revisions across one or more applications or variants.\n\nUse `application_refs` / `application_variant_refs` to scope the\nquery, or filter on commit metadata (`author`, `date`, `message`) via\nthe `application_revision` object. For the ordered history of a\nsingle variant, `POST /applications/revisions/log` is more direct.","operationId":"query_application_revisions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/revisions/commit":{"post":{"tags":["Applications"],"summary":"Commit Application Revision","description":"Commit a new revision on a variant.\n\nThe new revision becomes the variant's tip and is assigned the next\n`version` number. Revisions are immutable once committed; to change\nconfiguration, commit a new revision.\nSee [Versioning](/reference/api-guide/versioning#committing-a-revision).","operationId":"commit_application_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionCommitRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/revisions/log":{"post":{"tags":["Applications"],"summary":"Log Application Revisions","description":"Return the ordered revision log for a variant.\n\nPass `application_variant_id` to list the full history of that\nvariant; optionally pass `application_revision_id` + `depth` to walk\nback a bounded number of commits from a specific revision. Entries\nare returned newest-first and include the full revision record.","operationId":"log_application_revisions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionsLogRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/applications/revisions/resolve":{"post":{"tags":["Applications"],"summary":"Resolve Application Revision","description":"Fetch a revision with embedded references inlined.\n\nWhen a revision's `data` carries references to other entities\n(snippets, linked revisions), this endpoint resolves them in place and\nreturns the fully-inlined configuration along with `resolution_info`\ndescribing what was substituted. Use it when clients need a\nself-contained configuration for invocation or export.","operationId":"resolve_application_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionResolveRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplicationRevisionResolveResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/applications/":{"post":{"tags":["Applications"],"summary":"Create Simple Application","description":"Create an application end-to-end.\n\nCreates the application artifact, a default variant, and a first\ncommitted revision whose `data` comes from the request. This is the\nrecommended entry point for \"spin up a new application from a\ntemplate\". For more control over variant and revision creation, use\nthe structured endpoints under `/applications/`.\nSee [Simple Endpoints](/reference/api-guide/simple-endpoints).","operationId":"create_simple_application","parameters":[{"name":"application_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleApplicationCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleApplicationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/applications/query":{"post":{"tags":["Applications"],"summary":"Query Simple Applications","description":"Query applications with variant, revision, and `data` merged per row.\n\nThis is the shape most clients want for dashboards or invocation\npickers: each row carries `variant_id`, `revision_id`, and `data`\n(URL, parameters, schemas) alongside the artifact fields. For the\nstructured query that returns artifacts only, use\n`POST /applications/query`.","operationId":"query_simple_applications","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleApplicationQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleApplicationsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/applications/{application_id}":{"get":{"tags":["Applications"],"summary":"Fetch Simple Application","description":"Fetch one application with its current variant, revision, and `data` merged.\n\nThe returned `data` includes the invocation `url`, the `parameters`\nthe revision was committed with, and the JSON `schemas` for inputs,\noutputs, and parameters.","operationId":"fetch_simple_application","parameters":[{"name":"application_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleApplicationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Applications"],"summary":"Edit Simple Application","description":"Edit an application and commit a new revision if configuration changed.\n\nFields other than `id` in the request body are treated as changes and\nproduce a new committed revision. Supplying `data` changes the\nconfiguration; supplying only header fields (`flags`, `tags`, `meta`)\nstill produces a new revision with the updated header but the\nexisting `data`. Editing the application `name` is currently\ndisabled and returns `400`.","operationId":"edit_simple_application","parameters":[{"name":"application_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleApplicationEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleApplicationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/applications/{application_id}/archive":{"post":{"tags":["Applications"],"summary":"Archive Simple Application","description":"Archive an application through the simple endpoint layer.\n\nEquivalent to `POST /applications/{application_id}/archive`; returns\nthe archived application in the simple shape (with its last known\nvariant, revision, and `data`).","operationId":"archive_simple_application","parameters":[{"name":"application_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleApplicationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/applications/{application_id}/unarchive":{"post":{"tags":["Applications"],"summary":"Unarchive Simple Application","description":"Unarchive an application through the simple endpoint layer.\n\nEquivalent to `POST /applications/{application_id}/unarchive`, with\nthe response shape of `/simple/applications/`.","operationId":"unarchive_simple_application","parameters":[{"name":"application_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Application Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleApplicationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/catalog/types/":{"get":{"tags":["Workflows"],"summary":"List Workflow Catalog Types","description":"List the shared JSON Schema fragments available to workflow schemas.\n\nWorkflow input/output schemas reference these via `x-ag-type-ref` (for\nexample, `message` or `prompt`). Use this endpoint to discover what\ntype keys exist before building a schema.\n\nSee: [Workflows](/reference/api-guide/workflows).","operationId":"list_workflow_catalog_types","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowCatalogTypesResponse"}}}}}}},"/workflows/catalog/types/{ag_type}":{"get":{"tags":["Workflows"],"summary":"Fetch Workflow Catalog Type","description":"Return the JSON Schema for a single shared type key.\n\nReturns 404 when the `ag_type` is not part of the shipped catalog.\n\nSee: [Workflows](/reference/api-guide/workflows).","operationId":"fetch_workflow_catalog_type","parameters":[{"name":"ag_type","in":"path","required":true,"schema":{"type":"string","title":"Ag Type"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowCatalogTypeResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/catalog/harnesses/":{"get":{"tags":["Workflows"],"summary":"List Workflow Catalog Harnesses","description":"List the agent harness records shipped with the product.\n\nEach record carries the harness `capabilities` (providers, deployments, connection\nmodes, model selection, models). A workflow's harness field references one via\n`x-ag-harness-ref`, resolved against `/catalog/harnesses/{ag_harness}`.\n\nSee: [Workflows](/reference/api-guide/workflows).","operationId":"list_workflow_catalog_harnesses","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowCatalogHarnessesResponse"}}}}}}},"/workflows/catalog/harnesses/{ag_harness}":{"get":{"tags":["Workflows"],"summary":"Fetch Workflow Catalog Harness","description":"Return a single harness record (with its `capabilities`).\n\nReturns 404 when the `ag_harness` is not part of the shipped catalog.\n\nSee: [Workflows](/reference/api-guide/workflows).","operationId":"fetch_workflow_catalog_harness","parameters":[{"name":"ag_harness","in":"path","required":true,"schema":{"type":"string","title":"Ag Harness"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowCatalogHarnessResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/catalog/templates/":{"get":{"tags":["Workflows"],"summary":"List Workflow Catalog Templates","description":"List workflow blueprints shipped with the product.\n\nFilter by domain with `is_application`, `is_evaluator`, or\n`is_snippet`. Archived templates are hidden unless `include_archived`\nis true. Templates are global and read-only.\n\nSee: [Workflows](/reference/api-guide/workflows).","operationId":"list_workflow_catalog_templates","parameters":[{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"}},{"name":"is_application","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Application"}},{"name":"is_evaluator","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Evaluator"}},{"name":"is_snippet","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Snippet"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowCatalogTemplatesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/catalog/templates/{template_key}":{"get":{"tags":["Workflows"],"summary":"Fetch Workflow Catalog Template","description":"Return a single workflow template by its key.\n\nReturns `count=0` when the template is not found. Templates are global\nmetadata and are not scoped to a project.\n\nSee: [Workflows](/reference/api-guide/workflows).","operationId":"fetch_workflow_catalog_template","parameters":[{"name":"template_key","in":"path","required":true,"schema":{"type":"string","title":"Template Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowCatalogTemplateResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/catalog/templates/{template_key}/presets/":{"get":{"tags":["Workflows"],"summary":"List Workflow Catalog Presets","description":"List presets defined against a template.\n\nPresets are named parameter sets that can be committed as the first\nrevision of a new variant. Returns an empty list when a template has\nno canned presets.\n\nSee: [Workflows](/reference/api-guide/workflows).","operationId":"list_workflow_catalog_presets","parameters":[{"name":"template_key","in":"path","required":true,"schema":{"type":"string","title":"Template Key"}},{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowCatalogPresetsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/catalog/templates/{template_key}/presets/{preset_key}":{"get":{"tags":["Workflows"],"summary":"Fetch Workflow Catalog Preset","description":"Return a single preset for a template by key.\n\nReturns `count=0` when the preset is not defined.\n\nSee: [Workflows](/reference/api-guide/workflows).","operationId":"fetch_workflow_catalog_preset","parameters":[{"name":"template_key","in":"path","required":true,"schema":{"type":"string","title":"Template Key"}},{"name":"preset_key","in":"path","required":true,"schema":{"type":"string","title":"Preset Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowCatalogPresetResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/":{"post":{"tags":["Workflows"],"summary":"Create Workflow","description":"Create a workflow artifact.\n\nCreates the top-level container only; commit a revision on a variant\nbefore the workflow can be retrieved or invoked. Use when you need the\nlower-level primitive — pick `/applications/` for serving logic or\n`/evaluators/` for scoring logic.\n\nSee: [Workflows](/reference/api-guide/workflows),\n[Versioning](/reference/api-guide/versioning).","operationId":"create_workflow","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/{workflow_id}":{"get":{"tags":["Workflows"],"summary":"Fetch Workflow","description":"Fetch a workflow artifact by ID.\n\nReturns the artifact only — variants and revisions are not included.\nUse `/workflows/variants/query` and `/workflows/revisions/query` for\nthe child entities.\n\nSee: [Workflows](/reference/api-guide/workflows).","operationId":"fetch_workflow","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Workflows"],"summary":"Edit Workflow","description":"Update artifact-level fields on a workflow.\n\nThe `id` in the body must match the path parameter. Only supplied\nfields are modified. Configuration (parameters, URL, schemas) lives on\nrevisions — commit a new revision to change those.\n\nSee: [Workflows](/reference/api-guide/workflows),\n[Versioning](/reference/api-guide/versioning).","operationId":"edit_workflow","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/{workflow_id}/archive":{"post":{"tags":["Workflows"],"summary":"Archive Workflow","description":"Archive a workflow artifact (soft delete).\n\nSets `deleted_at` on the workflow and its variants. Archived\nworkflows are hidden from queries unless `include_archived=true`.\nRevision IDs remain resolvable so historical traces stay intact.\n\nSee: [Versioning](/reference/api-guide/versioning).","operationId":"archive_workflow","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/{workflow_id}/unarchive":{"post":{"tags":["Workflows"],"summary":"Unarchive Workflow","description":"Restore a previously archived workflow.\n\nClears `deleted_at` on the workflow. Archived variants and revisions\nare restored with the workflow.\n\nSee: [Versioning](/reference/api-guide/versioning).","operationId":"unarchive_workflow","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/query":{"post":{"tags":["Workflows"],"summary":"Query Workflows","description":"Query workflow artifacts with filters and pagination.\n\nAccepts the same filters as query parameters or in the request body;\nbody fields win when both are supplied. Results are ordered by\ncreation time; pass `windowing.next` back for the following page.\n\nSee: [Query Pattern](/reference/api-guide/query-pattern).","operationId":"query_workflows","parameters":[{"name":"workflow_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"}},{"name":"workflow_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Workflow Ids"}},{"name":"workflow_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Slug"}},{"name":"workflow_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Workflow Slugs"}},{"name":"name","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"}},{"name":"description","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},{"name":"flags","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Flags"}},{"name":"tags","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tags"}},{"name":"meta","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Meta"}},{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"}},{"name":"next","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Next"}},{"name":"newest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Newest"}},{"name":"oldest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Oldest"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"enum":["ascending","descending"],"type":"string"},{"type":"null"}],"title":"Order"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/variants/":{"post":{"tags":["Workflows"],"summary":"Create Workflow Variant","description":"Create a new variant under an existing workflow.\n\nVariants are branches of an artifact's history; each maintains its own\nrevision log. Variant slugs are unique within the project — reuse of a\nslug already in use returns a 409 conflict.\n\nSee: [Versioning](/reference/api-guide/versioning).","operationId":"create_workflow_variant","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowVariantCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/variants/{workflow_variant_id}":{"get":{"tags":["Workflows"],"summary":"Fetch Workflow Variant","description":"Fetch a workflow variant by ID.\n\nReturns the variant metadata only — use\n`/workflows/revisions/retrieve` or `/workflows/revisions/log` for the\nvariant's revisions.\n\nSee: [Workflows](/reference/api-guide/workflows).","operationId":"fetch_workflow_variant","parameters":[{"name":"workflow_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Workflows"],"summary":"Edit Workflow Variant","description":"Update metadata on a workflow variant.\n\nThe `id` in the body must match the path parameter. Revisions on the\nvariant are not affected — commit a new revision to change data.\n\nSee: [Versioning](/reference/api-guide/versioning).","operationId":"edit_workflow_variant","parameters":[{"name":"workflow_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Variant Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowVariantEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/variants/{workflow_variant_id}/archive":{"post":{"tags":["Workflows"],"summary":"Archive Workflow Variant","description":"Archive a workflow variant.\n\nSoft-deletes the variant and its revisions. Archived variants are\nhidden from queries unless `include_archived=true`. See\n[Versioning](/reference/api-guide/versioning).","operationId":"archive_workflow_variant","parameters":[{"name":"workflow_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/variants/{workflow_variant_id}/unarchive":{"post":{"tags":["Workflows"],"summary":"Unarchive Workflow Variant","description":"Restore a previously archived workflow variant.\n\nClears `deleted_at` on the variant and its revisions. See\n[Versioning](/reference/api-guide/versioning).","operationId":"unarchive_workflow_variant","parameters":[{"name":"workflow_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/variants/query":{"post":{"tags":["Workflows"],"summary":"Query Workflow Variants","description":"Query workflow variants with filters and pagination.\n\nScope the query by `workflow_refs` (parent artifact) or\n`workflow_variant_refs` (specific variants). Accepts the same fields\nas query parameters or in the request body.\n\nSee: [Query Pattern](/reference/api-guide/query-pattern).","operationId":"query_workflow_variants","parameters":[{"name":"workflow_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"}},{"name":"workflow_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Workflow Ids"}},{"name":"workflow_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Slug"}},{"name":"workflow_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Workflow Slugs"}},{"name":"workflow_variant_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"}},{"name":"workflow_variant_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Workflow Variant Ids"}},{"name":"workflow_variant_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Variant Slug"}},{"name":"workflow_variant_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Workflow Variant Slugs"}},{"name":"name","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"}},{"name":"description","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},{"name":"flags","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Flags"}},{"name":"tags","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tags"}},{"name":"meta","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Meta"}},{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"}},{"name":"next","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Next"}},{"name":"newest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Newest"}},{"name":"oldest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Oldest"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"enum":["ascending","descending"],"type":"string"},{"type":"null"}],"title":"Order"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowVariantsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/variants/fork":{"post":{"tags":["Workflows"],"summary":"Fork Workflow Variant","operationId":"fork_workflow_variant","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowVariantForkRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/revisions/retrieve":{"post":{"tags":["Workflows"],"summary":"Retrieve Workflow Revision","operationId":"retrieve_workflow_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionRetrieveRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/revisions/deploy":{"post":{"tags":["Workflows"],"summary":"Deploy Workflow Revision","operationId":"deploy_workflow_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionDeployRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/revisions/":{"post":{"tags":["Workflows"],"summary":"Create Workflow Revision","operationId":"create_workflow_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/revisions/{workflow_revision_id}":{"get":{"tags":["Workflows"],"summary":"Fetch Workflow Revision","operationId":"fetch_workflow_revision","parameters":[{"name":"workflow_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Workflows"],"summary":"Edit Workflow Revision","operationId":"edit_workflow_revision","parameters":[{"name":"workflow_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Revision Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/revisions/{workflow_revision_id}/archive":{"post":{"tags":["Workflows"],"summary":"Archive Workflow Revision","operationId":"archive_workflow_revision","parameters":[{"name":"workflow_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/revisions/{workflow_revision_id}/unarchive":{"post":{"tags":["Workflows"],"summary":"Unarchive Workflow Revision","operationId":"unarchive_workflow_revision","parameters":[{"name":"workflow_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/revisions/query":{"post":{"tags":["Workflows"],"summary":"Query Workflow Revisions","operationId":"query_workflow_revisions","parameters":[{"name":"workflow_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"}},{"name":"workflow_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Workflow Ids"}},{"name":"workflow_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Slug"}},{"name":"workflow_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Workflow Slugs"}},{"name":"workflow_variant_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"}},{"name":"workflow_variant_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Workflow Variant Ids"}},{"name":"workflow_variant_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Variant Slug"}},{"name":"workflow_variant_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Workflow Variant Slugs"}},{"name":"workflow_revision_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Revision Id"}},{"name":"workflow_revision_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Workflow Revision Ids"}},{"name":"workflow_revision_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Revision Slug"}},{"name":"workflow_revision_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Workflow Revision Slugs"}},{"name":"workflow_revision_version","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Revision Version"}},{"name":"workflow_revision_versions","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Workflow Revision Versions"}},{"name":"name","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"}},{"name":"description","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},{"name":"flags","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Flags"}},{"name":"tags","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tags"}},{"name":"meta","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Meta"}},{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"}},{"name":"next","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Next"}},{"name":"newest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Newest"}},{"name":"oldest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Oldest"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"enum":["ascending","descending"],"type":"string"},{"type":"null"}],"title":"Order"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/revisions/commit":{"post":{"tags":["Workflows"],"summary":"Commit Workflow Revision","description":"The human and SDK route: no write scope, the caller owns the whole revision.","operationId":"commit_workflow_revision","parameters":[{"name":"workflow_variant_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionCommitRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/revisions/log":{"post":{"tags":["Workflows"],"summary":"Log Workflow Revisions","operationId":"log_workflow_revisions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionsLogRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workflows/revisions/resolve":{"post":{"tags":["Workflows"],"summary":"Resolve Workflow Revision Endpoint","description":"Resolve embedded references in a workflow revision configuration.\n\nThis endpoint:\n1. Fetches the workflow revision\n2. Resolves all @ag.references tokens in the configuration\n3. Returns the revision with resolved configuration + metadata","operationId":"resolve_workflow_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionResolveRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRevisionResolveResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/workflows/":{"post":{"tags":["Workflows"],"summary":"Create Simple Workflow","operationId":"create_simple_workflow","parameters":[{"name":"workflow_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleWorkflowCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleWorkflowResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/workflows/{workflow_id}":{"get":{"tags":["Workflows"],"summary":"Fetch Simple Workflow","operationId":"fetch_simple_workflow","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleWorkflowResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Workflows"],"summary":"Edit Simple Workflow","operationId":"edit_simple_workflow","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleWorkflowEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleWorkflowResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/workflows/{workflow_id}/archive":{"post":{"tags":["Workflows"],"summary":"Archive Simple Workflow","operationId":"archive_simple_workflow","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleWorkflowResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/workflows/{workflow_id}/unarchive":{"post":{"tags":["Workflows"],"summary":"Unarchive Simple Workflow","operationId":"unarchive_simple_workflow","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Workflow Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleWorkflowResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/workflows/query":{"post":{"tags":["Workflows"],"summary":"Query Simple Workflows","operationId":"query_simple_workflows","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleWorkflowQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleWorkflowsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/catalog/types/":{"get":{"tags":["Evaluators"],"summary":"List Evaluator Catalog Types","description":"List the JSON schema types the evaluator catalog understands.\n\nTypes are static metadata shipped with the product. Use this when\nrendering a catalog UI or validating that a template's schema is\nsupported. See the Evaluators guide for how the catalog relates\nto user-owned evaluator artifacts.","operationId":"list_evaluator_catalog_types","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorCatalogTypesResponse"}}}}}}},"/evaluators/catalog/templates/":{"get":{"tags":["Evaluators"],"summary":"List Evaluator Catalog Templates","description":"List evaluator templates from the catalog.\n\nTemplates are blueprints that describe an evaluator's handler\nURI, JSON schemas, and default configuration. Pass\n`include_archived=true` to include deprecated templates. Use the\nreturned `key` with `/catalog/templates/{template_key}/presets/`\nto list its presets.","operationId":"list_evaluator_catalog_templates","parameters":[{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorCatalogTemplatesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/catalog/templates/{template_key}":{"get":{"tags":["Evaluators"],"summary":"Fetch Evaluator Catalog Template","description":"Fetch one evaluator template by key.\n\nReturns an empty envelope (`count: 0`) when no template matches\nthe key. Template keys come from\n`GET /catalog/templates/`.","operationId":"fetch_evaluator_catalog_template","parameters":[{"name":"template_key","in":"path","required":true,"schema":{"type":"string","title":"Template Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorCatalogTemplateResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/catalog/templates/{template_key}/presets/":{"get":{"tags":["Evaluators"],"summary":"List Evaluator Catalog Presets","description":"List presets defined against one evaluator template.\n\nA preset is a named set of parameter values pre-filled against\nthe template. Use the returned `key` to fetch a specific preset\nvia `GET /catalog/templates/{template_key}/presets/{preset_key}`.","operationId":"list_evaluator_catalog_presets","parameters":[{"name":"template_key","in":"path","required":true,"schema":{"type":"string","title":"Template Key"}},{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorCatalogPresetsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/catalog/templates/{template_key}/presets/{preset_key}":{"get":{"tags":["Evaluators"],"summary":"Fetch Evaluator Catalog Preset","description":"Fetch one evaluator preset by template and preset key.\n\nPresets are not separate entities; they are metadata. Use the\nreturned preset payload as the starting point when creating a\nnew evaluator from a template. See the Evaluators guide.","operationId":"fetch_evaluator_catalog_preset","parameters":[{"name":"template_key","in":"path","required":true,"schema":{"type":"string","title":"Template Key"}},{"name":"preset_key","in":"path","required":true,"schema":{"type":"string","title":"Preset Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorCatalogPresetResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/":{"post":{"tags":["Evaluators"],"summary":"Create Evaluator","description":"Create an evaluator artifact, its first variant, and its initial revision.\n\nUse this endpoint when you already know you want to manage the\nartifact / variant / revision layers independently. For a\none-shot \"create and forget\" call that returns a flat record,\nsee `POST /simple/evaluators/`. See the Versioning guide for\ncommit semantics.","operationId":"create_evaluator","parameters":[{"name":"evaluator_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/{evaluator_id}":{"get":{"tags":["Evaluators"],"summary":"Fetch Evaluator","description":"Fetch an evaluator artifact by id.\n\nReturns the artifact-level record (slug, name, flags, lifecycle)\nwithout variant or revision data. Use the variant and revision\nendpoints to retrieve those layers.","operationId":"fetch_evaluator","parameters":[{"name":"evaluator_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Evaluators"],"summary":"Edit Evaluator","description":"Edit an evaluator artifact's metadata.\n\nEdits are limited to metadata fields (description, tags, meta).\nRenaming is temporarily disabled and returns 400. To change\nevaluator behavior, commit a new revision on the variant — see\n`/evaluators/revisions/commit`.","operationId":"edit_evaluator","parameters":[{"name":"evaluator_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/{evaluator_id}/archive":{"post":{"tags":["Evaluators"],"summary":"Archive Evaluator","description":"Soft-delete an evaluator artifact.\n\nSets `deleted_at` on the evaluator and hides it from subsequent\n`/query` responses unless `include_archived=true`. Revision IDs\nremain resolvable so historical traces stay intact.","operationId":"archive_evaluator","parameters":[{"name":"evaluator_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/{evaluator_id}/unarchive":{"post":{"tags":["Evaluators"],"summary":"Unarchive Evaluator","description":"Restore a soft-deleted evaluator artifact.\n\nClears `deleted_at` on the evaluator so it re-appears in `/query`\nresponses.","operationId":"unarchive_evaluator","parameters":[{"name":"evaluator_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/query":{"post":{"tags":["Evaluators"],"summary":"Query Evaluators","description":"Query evaluator artifacts with filters and pagination.\n\nReturns artifact-level records only. The request body follows\nthe shared query pattern (filter + refs + windowing). Send `{}`\nto list all evaluators in the project. See the Query Pattern\nguide.","operationId":"query_evaluators","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/variants/":{"post":{"tags":["Evaluators"],"summary":"Create Evaluator Variant","description":"Create a new variant on an existing evaluator.\n\nA variant is a named branch of the evaluator's history. New\nrevisions committed to this variant do not touch other variants.\nSee the Versioning guide.","operationId":"create_evaluator_variant","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorVariantCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/variants/{evaluator_variant_id}":{"get":{"tags":["Evaluators"],"summary":"Fetch Evaluator Variant","description":"Fetch an evaluator variant by id.\n\nReturns the variant record (slug, flags, lifecycle) without the\ncommitted revisions. Use `/evaluators/revisions/retrieve` to\nread the variant's current revision payload.","operationId":"fetch_evaluator_variant","parameters":[{"name":"evaluator_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Evaluators"],"summary":"Edit Evaluator Variant","description":"Edit a variant's metadata.\n\nEdits only touch variant-level metadata. To change evaluator\nbehavior commit a new revision via\n`/evaluators/revisions/commit`.","operationId":"edit_evaluator_variant","parameters":[{"name":"evaluator_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Variant Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorVariantEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/variants/{evaluator_variant_id}/archive":{"post":{"tags":["Evaluators"],"summary":"Archive Evaluator Variant","description":"Soft-delete an evaluator variant.\n\nSets `deleted_at` on the variant. Its revisions stay resolvable\nby id; they are hidden from `/query` unless the caller sets\n`include_archived=true`.","operationId":"archive_evaluator_variant","parameters":[{"name":"evaluator_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/variants/{evaluator_variant_id}/unarchive":{"post":{"tags":["Evaluators"],"summary":"Unarchive Evaluator Variant","description":"Restore a soft-deleted evaluator variant.","operationId":"unarchive_evaluator_variant","parameters":[{"name":"evaluator_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/variants/query":{"post":{"tags":["Evaluators"],"summary":"Query Evaluator Variants","description":"Query evaluator variants with filters, reference scoping, and pagination.\n\nAccepts parameters from both the query string and a JSON body;\nthe two are merged. Use `evaluator_refs` to scope to one or more\nevaluators, or `evaluator_variant_refs` for specific variants.","operationId":"query_evaluator_variants","parameters":[{"name":"evaluator_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Id"}},{"name":"evaluator_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Evaluator Ids"}},{"name":"evaluator_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Evaluator Slug"}},{"name":"evaluator_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Evaluator Slugs"}},{"name":"evaluator_variant_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Variant Id"}},{"name":"evaluator_variant_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Evaluator Variant Ids"}},{"name":"evaluator_variant_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Evaluator Variant Slug"}},{"name":"evaluator_variant_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Evaluator Variant Slugs"}},{"name":"name","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"}},{"name":"description","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},{"name":"flags","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Flags"}},{"name":"tags","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tags"}},{"name":"meta","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Meta"}},{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"}},{"name":"next","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Next"}},{"name":"newest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Newest"}},{"name":"oldest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Oldest"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"enum":["ascending","descending"],"type":"string"},{"type":"null"}],"title":"Order"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorVariantsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/variants/fork":{"post":{"tags":["Evaluators"],"summary":"Fork Evaluator Variant","description":"Fork an evaluator variant into a new variant.\n\nCreates a new branch whose initial revision is copied from the\nsource. Use this to experiment without touching the original.\nThe returned variant has a fresh id and slug but inherits\nlineage metadata from its source.","operationId":"fork_evaluator_variant","parameters":[{"name":"evaluator_variant_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Variant Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorVariantForkRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/revisions/retrieve":{"post":{"tags":["Evaluators"],"summary":"Retrieve Evaluator Revision","description":"Retrieve one evaluator revision, either directly or via an environment key.\n\nProvide one of:\nan evaluator / variant / revision reference (returns that\nrevision, or the latest revision on the variant or evaluator),\nor an environment reference plus `key` (returns the revision\ncurrently pinned to that key). Supplying both forms returns 400.\nPass `resolve=true` to expand embedded references on the\nreturned payload.","operationId":"retrieve_evaluator_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionRetrieveRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/revisions/deploy":{"post":{"tags":["Evaluators"],"summary":"Deploy Evaluator Revision","description":"Pin an evaluator revision into an environment revision under a key.\n\nRequires an evaluator ref (`evaluator_ref`,\n`evaluator_variant_ref`, or `evaluator_revision_ref`) and an\nenvironment ref. When `key` is omitted it defaults to\n`.revision`. The deployment is recorded as a\nnew commit on the environment revision. See the Evaluators\nguide for the deployment model.","operationId":"deploy_evaluator_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionDeployRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/revisions/":{"post":{"tags":["Evaluators"],"summary":"Create Evaluator Revision","description":"Create and commit the initial revision for an evaluator variant.\n\nPrefer `/evaluators/revisions/commit` for the standard commit\nflow. This endpoint commits an initial revision with the `initial`\nguard, preventing duplicate initial revisions for the same variant.","operationId":"create_evaluator_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/revisions/{evaluator_revision_id}":{"get":{"tags":["Evaluators"],"summary":"Fetch Evaluator Revision","description":"Fetch a specific evaluator revision by id.\n\nReturns the full revision including `data` (handler uri,\nschemas, and parameters). To pick the latest revision on a\nvariant without knowing its id, use\n`/evaluators/revisions/retrieve`.","operationId":"fetch_evaluator_revision","parameters":[{"name":"evaluator_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Evaluators"],"summary":"Edit Evaluator Revision","description":"Edit a revision's metadata.\n\nRevision `data` is immutable once committed. This endpoint is\nfor metadata fields only (description, tags, meta). To change\nevaluator behavior, commit a new revision instead.","operationId":"edit_evaluator_revision","parameters":[{"name":"evaluator_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Revision Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/revisions/{evaluator_revision_id}/archive":{"post":{"tags":["Evaluators"],"summary":"Archive Evaluator Revision","description":"Soft-delete an evaluator revision.\n\nArchived revisions remain resolvable by id but are excluded from\nrevision logs and queries unless `include_archived=true`.","operationId":"archive_evaluator_revision","parameters":[{"name":"evaluator_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/revisions/{evaluator_revision_id}/unarchive":{"post":{"tags":["Evaluators"],"summary":"Unarchive Evaluator Revision","description":"Restore a soft-deleted evaluator revision.","operationId":"unarchive_evaluator_revision","parameters":[{"name":"evaluator_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/revisions/query":{"post":{"tags":["Evaluators"],"summary":"Query Evaluator Revisions","description":"Query evaluator revisions with filters, reference scoping, and pagination.\n\nReturns revision payloads. Use `evaluator_refs`,\n`evaluator_variant_refs`, or `evaluator_revision_refs` to scope\nthe query.","operationId":"query_evaluator_revisions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/revisions/commit":{"post":{"tags":["Evaluators"],"summary":"Commit Evaluator Revision","description":"Commit a new revision on an evaluator variant.\n\nThe commit body carries the target `evaluator_variant_id`, an\noptional `message`, and the revision `data` (handler uri,\nschemas, parameters). A committed revision is immutable.","operationId":"commit_evaluator_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionCommitRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/revisions/log":{"post":{"tags":["Evaluators"],"summary":"Log Evaluator Revisions","description":"List the revision log of an evaluator variant.\n\nReturns revisions in commit order. Scope the log by supplying\nan evaluator, variant, or revision reference. Use the retrieve\nendpoint to fetch a specific revision's full payload.","operationId":"log_evaluator_revisions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionsLogRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluators/revisions/resolve":{"post":{"tags":["Evaluators"],"summary":"Resolve Evaluator Revision","description":"Resolve embedded references on an evaluator revision's `data`.\n\nWalks embedded references (for example, references to other\nrevisions or to secrets) up to `max_depth` and `max_embeds`.\nThe response includes a `resolution_info` block with counts,\ndepth reached, and errors according to `error_policy`.","operationId":"resolve_evaluator_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionResolveRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorRevisionResolveResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluators/":{"post":{"tags":["Evaluators"],"summary":"Create Simple Evaluator","description":"Create an evaluator via the simple surface.\n\nCreates the artifact, its first variant, and its initial\nrevision in one call. Returns the flat evaluator record\n(latest revision merged into `data`). Use this when you do not\nneed to manage variants or revisions directly.","operationId":"create_simple_evaluator","parameters":[{"name":"evaluator_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluatorCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluatorResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluators/templates":{"get":{"tags":["Evaluators"],"summary":"List Evaluator Templates","description":"List the legacy built-in evaluator templates.\n\nReturns static evaluator-type definitions shipped with the\nproduct. Prefer the `/evaluators/catalog/*` endpoints for new\nintegrations; this endpoint is kept for older clients. Pass\n`include_archived=true` to include deprecated templates.","operationId":"list_evaluator_templates","parameters":[{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"default":false,"title":"Include Archived"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatorTemplatesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluators/{evaluator_id}":{"get":{"tags":["Evaluators"],"summary":"Fetch Simple Evaluator","description":"Fetch one evaluator via the simple surface.\n\nReturns the flat evaluator record including its current variant\nand revision ids and the merged `data` payload.","operationId":"fetch_simple_evaluator","parameters":[{"name":"evaluator_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluatorResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Evaluators"],"summary":"Edit Simple Evaluator","description":"Edit an evaluator via the simple surface.\n\nTouches metadata and (when `data` is supplied) commits a new\nrevision on the evaluator's variant. Renaming is temporarily\ndisabled and returns 400.","operationId":"edit_simple_evaluator","parameters":[{"name":"evaluator_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluatorEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluatorResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluators/{evaluator_id}/archive":{"post":{"tags":["Evaluators"],"summary":"Archive Simple Evaluator","description":"Soft-delete an evaluator via the simple surface.\n\nArchives the underlying artifact. Historical traces that\nreference specific revision ids remain resolvable.","operationId":"archive_simple_evaluator","parameters":[{"name":"evaluator_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluatorResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluators/{evaluator_id}/unarchive":{"post":{"tags":["Evaluators"],"summary":"Unarchive Simple Evaluator","description":"Restore a soft-deleted evaluator via the simple surface.","operationId":"unarchive_simple_evaluator","parameters":[{"name":"evaluator_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluator Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluatorResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluators/query":{"post":{"tags":["Evaluators"],"summary":"Query Simple Evaluators","description":"Query evaluators via the simple surface with filters and pagination.\n\nReturns flat evaluator records (one per artifact with its\nlatest variant and revision merged into `data`). Send `{}` to\nlist all evaluators in the project. See the Query Pattern\nguide.","operationId":"query_simple_evaluators","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluatorQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluatorsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/":{"post":{"tags":["Environments"],"summary":"Create Environment","operationId":"create_environment","parameters":[{"name":"environment_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/{environment_id}":{"get":{"tags":["Environments"],"summary":"Fetch Environment","operationId":"fetch_environment","parameters":[{"name":"environment_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Environments"],"summary":"Edit Environment","operationId":"edit_environment","parameters":[{"name":"environment_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/{environment_id}/archive":{"post":{"tags":["Environments"],"summary":"Archive Environment","operationId":"archive_environment","parameters":[{"name":"environment_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/{environment_id}/unarchive":{"post":{"tags":["Environments"],"summary":"Unarchive Environment","operationId":"unarchive_environment","parameters":[{"name":"environment_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/query":{"post":{"tags":["Environments"],"summary":"Query Environments","operationId":"query_environments","parameters":[{"name":"environment_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Id"}},{"name":"environment_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Environment Ids"}},{"name":"environment_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Environment Slug"}},{"name":"environment_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Environment Slugs"}},{"name":"name","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"}},{"name":"description","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},{"name":"tags","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tags"}},{"name":"meta","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Meta"}},{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"}},{"name":"next","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Next"}},{"name":"newest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Newest"}},{"name":"oldest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Oldest"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"enum":["ascending","descending"],"type":"string"},{"type":"null"}],"title":"Order"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/variants/":{"post":{"tags":["Environments"],"summary":"Create Environment Variant","operationId":"create_environment_variant","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentVariantCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/variants/{environment_variant_id}":{"get":{"tags":["Environments"],"summary":"Fetch Environment Variant","operationId":"fetch_environment_variant","parameters":[{"name":"environment_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Environments"],"summary":"Edit Environment Variant","operationId":"edit_environment_variant","parameters":[{"name":"environment_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Variant Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentVariantEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/variants/{environment_variant_id}/archive":{"post":{"tags":["Environments"],"summary":"Archive Environment Variant","operationId":"archive_environment_variant","parameters":[{"name":"environment_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/variants/{environment_variant_id}/unarchive":{"post":{"tags":["Environments"],"summary":"Unarchive Environment Variant","operationId":"unarchive_environment_variant","parameters":[{"name":"environment_variant_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Variant Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/variants/query":{"post":{"tags":["Environments"],"summary":"Query Environment Variants","operationId":"query_environment_variants","parameters":[{"name":"environment_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Id"}},{"name":"environment_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Environment Ids"}},{"name":"environment_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Environment Slug"}},{"name":"environment_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Environment Slugs"}},{"name":"environment_variant_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Variant Id"}},{"name":"environment_variant_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Environment Variant Ids"}},{"name":"environment_variant_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Environment Variant Slug"}},{"name":"environment_variant_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Environment Variant Slugs"}},{"name":"name","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"}},{"name":"description","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},{"name":"tags","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tags"}},{"name":"meta","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Meta"}},{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"}},{"name":"next","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Next"}},{"name":"newest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Newest"}},{"name":"oldest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Oldest"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"enum":["ascending","descending"],"type":"string"},{"type":"null"}],"title":"Order"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentVariantsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/variants/fork":{"post":{"tags":["Environments"],"summary":"Fork Environment Variant","description":"Fork an existing environment variant into a new variant.\n\nThe new variant starts from the source variant's head revision (or a\npinned revision if `environment_revision_ref` is provided). Provide\n`slug` and `name` in the fork body to identify the new variant.","operationId":"fork_environment_variant","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentVariantForkRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentVariantResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/revisions/retrieve":{"post":{"tags":["Environments"],"summary":"Retrieve Environment Revision","operationId":"retrieve_environment_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionRetrieveRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/revisions/":{"post":{"tags":["Environments"],"summary":"Create Environment Revision","operationId":"create_environment_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/revisions/{environment_revision_id}":{"get":{"tags":["Environments"],"summary":"Fetch Environment Revision","operationId":"fetch_environment_revision","parameters":[{"name":"environment_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Environments"],"summary":"Edit Environment Revision","operationId":"edit_environment_revision","parameters":[{"name":"environment_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Revision Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/revisions/{environment_revision_id}/archive":{"post":{"tags":["Environments"],"summary":"Archive Environment Revision","operationId":"archive_environment_revision","parameters":[{"name":"environment_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/revisions/{environment_revision_id}/unarchive":{"post":{"tags":["Environments"],"summary":"Unarchive Environment Revision","operationId":"unarchive_environment_revision","parameters":[{"name":"environment_revision_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Revision Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/revisions/query":{"post":{"tags":["Environments"],"summary":"Query Environment Revisions","operationId":"query_environment_revisions","parameters":[{"name":"environment_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Id"}},{"name":"environment_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Environment Ids"}},{"name":"environment_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Environment Slug"}},{"name":"environment_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Environment Slugs"}},{"name":"environment_variant_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Variant Id"}},{"name":"environment_variant_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Environment Variant Ids"}},{"name":"environment_variant_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Environment Variant Slug"}},{"name":"environment_variant_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Environment Variant Slugs"}},{"name":"environment_revision_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Revision Id"}},{"name":"environment_revision_ids","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string","format":"uuid"}},{"type":"null"}],"title":"Environment Revision Ids"}},{"name":"environment_revision_slug","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Environment Revision Slug"}},{"name":"environment_revision_slugs","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Environment Revision Slugs"}},{"name":"environment_revision_version","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Environment Revision Version"}},{"name":"environment_revision_versions","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Environment Revision Versions"}},{"name":"name","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"}},{"name":"description","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},{"name":"tags","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tags"}},{"name":"meta","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Meta"}},{"name":"include_archived","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"}},{"name":"next","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Next"}},{"name":"newest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Newest"}},{"name":"oldest","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Oldest"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"enum":["ascending","descending"],"type":"string"},{"type":"null"}],"title":"Order"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/revisions/commit":{"post":{"tags":["Environments"],"summary":"Commit Environment Revision","operationId":"commit_environment_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionCommitRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/revisions/log":{"post":{"tags":["Environments"],"summary":"Log Environment Revisions","operationId":"log_environment_revisions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionsLogRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/environments/revisions/resolve":{"post":{"tags":["Environments"],"summary":"Resolve Environment Revision Endpoint","description":"Resolve embedded references in an environment revision configuration.\n\nThis endpoint:\n1. Fetches the environment revision\n2. Resolves all @ag.references tokens in the configuration\n3. Returns the revision with resolved configuration + metadata","operationId":"resolve_environment_revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionResolveRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnvironmentRevisionResolveResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/environments/":{"post":{"tags":["Environments"],"summary":"Create Simple Environment","operationId":"create_simple_environment","parameters":[{"name":"environment_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEnvironmentCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEnvironmentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/environments/{environment_id}":{"get":{"tags":["Environments"],"summary":"Fetch Simple Environment","operationId":"fetch_simple_environment","parameters":[{"name":"environment_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEnvironmentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Environments"],"summary":"Edit Simple Environment","operationId":"edit_simple_environment","parameters":[{"name":"environment_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEnvironmentEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEnvironmentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/environments/{environment_id}/archive":{"post":{"tags":["Environments"],"summary":"Archive Simple Environment","operationId":"archive_simple_environment","parameters":[{"name":"environment_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEnvironmentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/environments/{environment_id}/unarchive":{"post":{"tags":["Environments"],"summary":"Unarchive Simple Environment","operationId":"unarchive_simple_environment","parameters":[{"name":"environment_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEnvironmentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/environments/query":{"post":{"tags":["Environments"],"summary":"Query Simple Environments","operationId":"query_simple_environments","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEnvironmentQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEnvironmentsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/environments/{environment_id}/guard":{"post":{"tags":["Environments"],"summary":"Guard Simple Environment","operationId":"guard_simple_environment","parameters":[{"name":"environment_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEnvironmentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/environments/{environment_id}/unguard":{"post":{"tags":["Environments"],"summary":"Unguard Simple Environment","operationId":"unguard_simple_environment","parameters":[{"name":"environment_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Environment Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEnvironmentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/variants/configs/fetch":{"post":{"tags":["Deprecated"],"summary":"Configs Fetch","operationId":"configs_fetch_variants_configs_fetch_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Body_configs_fetch_variants_configs_fetch_post"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConfigResponseModel"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"deprecated":true}},"/tools/catalog/providers/":{"get":{"tags":["Tools"],"summary":"List Providers","operationId":"list_tool_providers","parameters":[{"name":"full_details","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Full Details"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolCatalogProvidersResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tools/catalog/providers/{provider_key}":{"get":{"tags":["Tools"],"summary":"Get Provider","operationId":"fetch_tool_provider","parameters":[{"name":"provider_key","in":"path","required":true,"schema":{"type":"string","title":"Provider Key"}},{"name":"full_details","in":"query","required":false,"schema":{"type":"boolean","default":true,"title":"Full Details"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolCatalogProviderResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tools/catalog/providers/{provider_key}/integrations/":{"get":{"tags":["Tools"],"summary":"List Integrations","operationId":"list_tool_integrations","parameters":[{"name":"provider_key","in":"path","required":true,"schema":{"type":"string","title":"Provider Key"}},{"name":"search","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Search"}},{"name":"sort_by","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sort By"}},{"name":"category","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Category"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"name":"full_details","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Full Details"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolCatalogIntegrationsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tools/catalog/providers/{provider_key}/categories/":{"get":{"tags":["Tools"],"summary":"List Categories","operationId":"list_tool_categories","parameters":[{"name":"provider_key","in":"path","required":true,"schema":{"type":"string","title":"Provider Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolCatalogCategoriesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tools/catalog/providers/{provider_key}/integrations/{integration_key}":{"get":{"tags":["Tools"],"summary":"Get Integration","operationId":"fetch_tool_integration","parameters":[{"name":"provider_key","in":"path","required":true,"schema":{"type":"string","title":"Provider Key"}},{"name":"integration_key","in":"path","required":true,"schema":{"type":"string","title":"Integration Key"}},{"name":"full_details","in":"query","required":false,"schema":{"type":"boolean","default":true,"title":"Full Details"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolCatalogIntegrationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tools/catalog/providers/{provider_key}/integrations/{integration_key}/actions/":{"get":{"tags":["Tools"],"summary":"List Actions","operationId":"list_tool_actions","parameters":[{"name":"provider_key","in":"path","required":true,"schema":{"type":"string","title":"Provider Key"}},{"name":"integration_key","in":"path","required":true,"schema":{"type":"string","title":"Integration Key"}},{"name":"query","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Query"}},{"name":"categories","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Categories"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}},{"name":"full_details","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Full Details"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolCatalogActionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tools/catalog/providers/{provider_key}/integrations/{integration_key}/actions/{action_key}":{"get":{"tags":["Tools"],"summary":"Get Action","operationId":"fetch_tool_action","parameters":[{"name":"provider_key","in":"path","required":true,"schema":{"type":"string","title":"Provider Key"}},{"name":"integration_key","in":"path","required":true,"schema":{"type":"string","title":"Integration Key"}},{"name":"action_key","in":"path","required":true,"schema":{"type":"string","title":"Action Key"}},{"name":"full_details","in":"query","required":false,"schema":{"type":"boolean","default":true,"title":"Full Details"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolCatalogActionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tools/connections/query":{"post":{"tags":["Tools"],"summary":"Query Connections","description":"Query connections with optional filtering.","operationId":"query_tool_connections","parameters":[{"name":"provider_key","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider Key"}},{"name":"integration_key","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Integration Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolConnectionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tools/connections/":{"post":{"tags":["Tools"],"summary":"Create Connection","description":"Create a new tool connection.","operationId":"create_tool_connection","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolConnectionCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolConnectionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tools/connections/callback":{"get":{"tags":["Tools"],"summary":"Callback Connection","description":"Handle OAuth callback from Composio.","operationId":"callback_tool_connection","parameters":[{"name":"connected_account_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Connected Account Id"}},{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status"}},{"name":"error_message","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error Message"}},{"name":"state","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"State"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tools/connections/{connection_id}":{"get":{"tags":["Tools"],"summary":"Get Connection","description":"Get a connection by ID.","operationId":"fetch_tool_connection","parameters":[{"name":"connection_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Connection Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolConnectionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Tools"],"summary":"Delete Connection","description":"Delete a connection by ID.","operationId":"delete_tool_connection","parameters":[{"name":"connection_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Connection Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tools/connections/{connection_id}/refresh":{"post":{"tags":["Tools"],"summary":"Refresh Connection","description":"Refresh a connection's credentials.","operationId":"refresh_tool_connection","parameters":[{"name":"connection_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Connection Id"}},{"name":"force","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Force"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolConnectionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tools/connections/{connection_id}/revoke":{"post":{"tags":["Tools"],"summary":"Revoke Connection","description":"Mark a connection invalid locally (does not revoke at the provider).","operationId":"revoke_tool_connection","parameters":[{"name":"connection_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Connection Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolConnectionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tools/resolve":{"post":{"tags":["Tools"],"summary":"Resolve Tools","description":"Resolve an agent's tool references into model-ready specs.\n\nValidates Composio connections up front and enriches each action from the\ncatalog, so a running agent (e.g. Pi) gets ``customTools`` whose ``execute``\nroutes back through ``POST /tools/call`` — provider keys stay server-side.","operationId":"resolve_tools","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolResolveRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolResolveResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tools/discover":{"post":{"tags":["Tools"],"summary":"Discover Capabilities","description":"Discover the tools that fit a set of use cases, translated to Agenta terms.\n\nWraps the provider's semantic search and reports each integration's connection\nstate for the calling project. Read-only; project scope comes from caller auth.\nSee ``docs/design/agent-workflows/projects/tool-discovery/design.md``.","operationId":"discover_tool_capabilities","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CapabilitiesQuery"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CapabilitiesResult"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/tools/call":{"post":{"tags":["Tools"],"summary":"Call Tool","description":"Call a tool action with a connection.","operationId":"call_tool","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolCall"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolCallResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/composio/events/":{"post":{"tags":["Triggers"],"summary":"Ingest Composio Event","description":"Receive a Composio provider event; verify, demux, ack-fast, enqueue.\n\nPublic (no Agenta auth) — mirrors the Stripe events receiver. Scope and\nattribution are recovered downstream from the resolved subscription row.","operationId":"ingest_composio_event","responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerEventAck"}}}}}}},"/triggers/catalog/providers/":{"get":{"tags":["Triggers"],"summary":"List Providers","operationId":"list_trigger_providers","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCatalogProvidersResponse"}}}}}}},"/triggers/catalog/providers/{provider_key}":{"get":{"tags":["Triggers"],"summary":"Get Provider","operationId":"fetch_trigger_provider","parameters":[{"name":"provider_key","in":"path","required":true,"schema":{"type":"string","title":"Provider Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCatalogProviderResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/catalog/providers/{provider_key}/integrations/":{"get":{"tags":["Triggers"],"summary":"List Integrations","operationId":"list_trigger_integrations","parameters":[{"name":"provider_key","in":"path","required":true,"schema":{"type":"string","title":"Provider Key"}},{"name":"search","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Search"}},{"name":"sort_by","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sort By"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCatalogIntegrationsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/catalog/providers/{provider_key}/integrations/{integration_key}":{"get":{"tags":["Triggers"],"summary":"Get Integration","operationId":"fetch_trigger_integration","parameters":[{"name":"provider_key","in":"path","required":true,"schema":{"type":"string","title":"Provider Key"}},{"name":"integration_key","in":"path","required":true,"schema":{"type":"string","title":"Integration Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCatalogIntegrationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/catalog/providers/{provider_key}/integrations/{integration_key}/events/":{"get":{"tags":["Triggers"],"summary":"List Events","operationId":"list_trigger_events","parameters":[{"name":"provider_key","in":"path","required":true,"schema":{"type":"string","title":"Provider Key"}},{"name":"integration_key","in":"path","required":true,"schema":{"type":"string","title":"Integration Key"}},{"name":"query","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Query"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"}},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCatalogEventsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/catalog/providers/{provider_key}/integrations/{integration_key}/events/{event_key}":{"get":{"tags":["Triggers"],"summary":"Get Event","operationId":"fetch_trigger_event","parameters":[{"name":"provider_key","in":"path","required":true,"schema":{"type":"string","title":"Provider Key"}},{"name":"integration_key","in":"path","required":true,"schema":{"type":"string","title":"Integration Key"}},{"name":"event_key","in":"path","required":true,"schema":{"type":"string","title":"Event Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCatalogEventResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/discover":{"post":{"tags":["Triggers"],"summary":"Discover Triggers","operationId":"discover_triggers","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerDiscoveryQuery"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCapabilitiesResult"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/connections/query":{"post":{"tags":["Triggers"],"summary":"Query Connections","operationId":"query_trigger_connections","parameters":[{"name":"provider_key","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider Key"}},{"name":"integration_key","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Integration Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerConnectionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/connections/":{"post":{"tags":["Triggers"],"summary":"Create Connection","operationId":"create_trigger_connection","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerConnectionCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerConnectionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/connections/{connection_id}":{"get":{"tags":["Triggers"],"summary":"Get Connection","operationId":"fetch_trigger_connection","parameters":[{"name":"connection_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Connection Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerConnectionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Triggers"],"summary":"Delete Connection","operationId":"delete_trigger_connection","parameters":[{"name":"connection_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Connection Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/connections/{connection_id}/refresh":{"post":{"tags":["Triggers"],"summary":"Refresh Connection","operationId":"refresh_trigger_connection","parameters":[{"name":"connection_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Connection Id"}},{"name":"force","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Force"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerConnectionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/connections/{connection_id}/revoke":{"post":{"tags":["Triggers"],"summary":"Revoke Connection","operationId":"revoke_trigger_connection","parameters":[{"name":"connection_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Connection Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerConnectionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/subscriptions/":{"get":{"tags":["Triggers"],"summary":"List Subscriptions","operationId":"list_trigger_subscriptions","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerSubscriptionsResponse"}}}}}},"post":{"tags":["Triggers"],"summary":"Create Subscription","operationId":"create_trigger_subscription","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerSubscriptionCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerSubscriptionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/subscriptions/query":{"post":{"tags":["Triggers"],"summary":"Query Subscriptions","operationId":"query_trigger_subscriptions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerSubscriptionQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerSubscriptionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/subscriptions/test":{"post":{"tags":["Triggers"],"summary":"Test Subscription","operationId":"test_trigger_subscription","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerSubscriptionCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerDeliveryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/subscriptions/{subscription_id}/refresh":{"post":{"tags":["Triggers"],"summary":"Refresh Subscription","operationId":"refresh_trigger_subscription","parameters":[{"name":"subscription_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Subscription Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerSubscriptionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/subscriptions/{subscription_id}/revoke":{"post":{"tags":["Triggers"],"summary":"Revoke Subscription","operationId":"revoke_trigger_subscription","parameters":[{"name":"subscription_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Subscription Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerSubscriptionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/subscriptions/{subscription_id}/start":{"post":{"tags":["Triggers"],"summary":"Start Subscription","operationId":"start_trigger_subscription","parameters":[{"name":"subscription_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Subscription Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerSubscriptionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/subscriptions/{subscription_id}/stop":{"post":{"tags":["Triggers"],"summary":"Stop Subscription","operationId":"stop_trigger_subscription","parameters":[{"name":"subscription_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Subscription Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerSubscriptionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/subscriptions/{subscription_id}":{"get":{"tags":["Triggers"],"summary":"Fetch Subscription","operationId":"fetch_trigger_subscription","parameters":[{"name":"subscription_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Subscription Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerSubscriptionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Triggers"],"summary":"Edit Subscription","operationId":"edit_trigger_subscription","parameters":[{"name":"subscription_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Subscription Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerSubscriptionEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerSubscriptionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Triggers"],"summary":"Delete Subscription","operationId":"delete_trigger_subscription","parameters":[{"name":"subscription_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Subscription Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/schedules/":{"get":{"tags":["Triggers"],"summary":"List Schedules","operationId":"list_trigger_schedules","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerSchedulesResponse"}}}}}},"post":{"tags":["Triggers"],"summary":"Create Schedule","operationId":"create_trigger_schedule","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerScheduleCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerScheduleResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/schedules/query":{"post":{"tags":["Triggers"],"summary":"Query Schedules","operationId":"query_trigger_schedules","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerScheduleQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerSchedulesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/schedules/{schedule_id}":{"get":{"tags":["Triggers"],"summary":"Fetch Schedule","operationId":"fetch_trigger_schedule","parameters":[{"name":"schedule_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Schedule Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerScheduleResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Triggers"],"summary":"Edit Schedule","operationId":"edit_trigger_schedule","parameters":[{"name":"schedule_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Schedule Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerScheduleEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerScheduleResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Triggers"],"summary":"Delete Schedule","operationId":"delete_trigger_schedule","parameters":[{"name":"schedule_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Schedule Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/schedules/{schedule_id}/start":{"post":{"tags":["Triggers"],"summary":"Start Schedule","operationId":"start_trigger_schedule","parameters":[{"name":"schedule_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Schedule Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerScheduleResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/schedules/{schedule_id}/stop":{"post":{"tags":["Triggers"],"summary":"Stop Schedule","operationId":"stop_trigger_schedule","parameters":[{"name":"schedule_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Schedule Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerScheduleResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/deliveries":{"get":{"tags":["Triggers"],"summary":"List Deliveries","operationId":"list_trigger_deliveries","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerDeliveriesResponse"}}}}}}},"/triggers/deliveries/query":{"post":{"tags":["Triggers"],"summary":"Query Deliveries","operationId":"query_trigger_deliveries","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerDeliveryQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerDeliveriesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/triggers/deliveries/{delivery_id}":{"get":{"tags":["Triggers"],"summary":"Fetch Delivery","operationId":"fetch_trigger_delivery","parameters":[{"name":"delivery_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Delivery Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerDeliveryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/interactions/":{"post":{"tags":["Sessions"],"summary":"Create Interaction","operationId":"create_interaction","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionInteractionCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionInteractionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/interactions/query":{"post":{"tags":["Sessions"],"summary":"Query Interactions","operationId":"query_interactions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionInteractionQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionInteractionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/interactions/transition":{"post":{"tags":["Sessions"],"summary":"Transition Interaction","operationId":"transition_interaction","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionInteractionTransitionRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionInteractionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/interactions/cancel-stale":{"post":{"tags":["Sessions"],"summary":"Cancel Stale Interactions","operationId":"cancel_stale_interactions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionInteractionCancelStaleRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Cancel Stale Interactions"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/interactions/{interaction_id}":{"get":{"tags":["Sessions"],"summary":"Fetch Interaction","operationId":"fetch_interaction","parameters":[{"name":"interaction_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Interaction Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionInteractionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/interactions/{interaction_id}/respond":{"post":{"tags":["Sessions"],"summary":"Respond Interaction","operationId":"respond_interaction","parameters":[{"name":"interaction_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Interaction Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionInteractionRespondRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionInteractionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/runs/":{"post":{"tags":["Evaluations"],"summary":"Create Runs","operationId":"create_runs","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunsCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Evaluations"],"summary":"Delete Runs","operationId":"delete_runs","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunIdsRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunIdsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Evaluations"],"summary":"Edit Runs","operationId":"edit_runs","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunsEditRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/runs/query":{"post":{"tags":["Evaluations"],"summary":"Query Runs","operationId":"query_runs","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/runs/close":{"post":{"tags":["Evaluations"],"summary":"Close Runs","operationId":"close_runs","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunIdsRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/runs/open":{"post":{"tags":["Evaluations"],"summary":"Open Runs","operationId":"open_runs","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunIdsRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/runs/{run_id}":{"get":{"tags":["Evaluations"],"summary":"Fetch Run","operationId":"fetch_run","parameters":[{"name":"run_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Evaluations"],"summary":"Edit Run","operationId":"edit_run","parameters":[{"name":"run_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Run Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Evaluations"],"summary":"Delete Run","operationId":"delete_run","parameters":[{"name":"run_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunIdResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/runs/{run_id}/close":{"post":{"tags":["Evaluations"],"summary":"Close Run","operationId":"close_run","parameters":[{"name":"run_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Run Id"}},{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}],"title":"Status"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/runs/{run_id}/open":{"post":{"tags":["Evaluations"],"summary":"Open Run","operationId":"open_run","parameters":[{"name":"run_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/runs/{run_id}/queues/default":{"get":{"tags":["Evaluations"],"summary":"Fetch Default Queue","operationId":"fetch_default_queue","parameters":[{"name":"run_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationQueueResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/scenarios/":{"post":{"tags":["Evaluations"],"summary":"Create Scenarios","operationId":"create_scenarios","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationScenariosCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationScenariosResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Evaluations"],"summary":"Delete Scenarios","operationId":"delete_scenarios","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationScenarioIdsRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationScenarioIdsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Evaluations"],"summary":"Edit Scenarios","operationId":"edit_scenarios","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationScenariosEditRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationScenariosResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/scenarios/query":{"post":{"tags":["Evaluations"],"summary":"Query Scenarios","operationId":"query_scenarios","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationScenarioQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationScenariosResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/scenarios/{scenario_id}":{"get":{"tags":["Evaluations"],"summary":"Fetch Scenario","operationId":"fetch_scenario","parameters":[{"name":"scenario_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Scenario Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationScenarioResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Evaluations"],"summary":"Edit Scenario","operationId":"edit_scenario","parameters":[{"name":"scenario_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Scenario Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationScenarioEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationScenarioResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Evaluations"],"summary":"Delete Scenario","operationId":"delete_scenario","parameters":[{"name":"scenario_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Scenario Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationScenarioIdResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/results/":{"put":{"tags":["Evaluations"],"summary":"Set Results","operationId":"set_results","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationResultsSetRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationResultsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["Evaluations"],"summary":"Set Results","operationId":"set_results","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationResultsSetRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationResultsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Evaluations"],"summary":"Delete Results","operationId":"delete_results","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationResultIdsRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationResultIdsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Evaluations"],"summary":"Set Results","operationId":"set_results","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationResultsSetRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationResultsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/results/query":{"post":{"tags":["Evaluations"],"summary":"Query Results","operationId":"query_results","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationResultQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationResultsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/results/{result_id}":{"get":{"tags":["Evaluations"],"summary":"Fetch Result","operationId":"fetch_result","parameters":[{"name":"result_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Result Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationResultResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Evaluations"],"summary":"Delete Result","operationId":"delete_result","parameters":[{"name":"result_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Result Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationResultIdResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/metrics/refresh":{"post":{"tags":["Evaluations"],"summary":"Refresh Metrics","operationId":"refresh_metrics","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationMetricsRefreshRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationMetricsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/metrics/":{"put":{"tags":["Evaluations"],"summary":"Set Metrics","operationId":"set_metrics","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationMetricsSetRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationMetricsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["Evaluations"],"summary":"Set Metrics","operationId":"set_metrics","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationMetricsSetRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationMetricsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Evaluations"],"summary":"Delete Metrics","operationId":"delete_metrics","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationMetricsIdsRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationMetricsIdsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Evaluations"],"summary":"Set Metrics","operationId":"set_metrics","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationMetricsSetRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationMetricsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/metrics/query":{"post":{"tags":["Evaluations"],"summary":"Query Metrics","operationId":"query_metrics","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationMetricsQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationMetricsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/metrics/{metrics_id}":{"get":{"tags":["Evaluations"],"summary":"Fetch Metric","operationId":"fetch_metric","parameters":[{"name":"metrics_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Metrics Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationMetricsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Evaluations"],"summary":"Delete Metric","operationId":"delete_metric","parameters":[{"name":"metrics_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Metrics Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationMetricsIdsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/queues/":{"post":{"tags":["Evaluations"],"summary":"Create Queues","operationId":"create_queues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationQueuesCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationQueuesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Evaluations"],"summary":"Delete Queues","operationId":"delete_queues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationQueueIdsRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationQueueIdsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Evaluations"],"summary":"Edit Queues","operationId":"edit_queues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationQueuesEditRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationQueuesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/queues/query":{"post":{"tags":["Evaluations"],"summary":"Query Queues","operationId":"query_queues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationQueueQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationQueuesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/queues/{queue_id}":{"get":{"tags":["Evaluations"],"summary":"Fetch Queue","operationId":"fetch_queue","parameters":[{"name":"queue_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Queue Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationQueueResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Evaluations"],"summary":"Edit Queue","operationId":"edit_queue","parameters":[{"name":"queue_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Queue Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationQueueEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationQueueResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Evaluations"],"summary":"Delete Queue","operationId":"delete_queue","parameters":[{"name":"queue_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Queue Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationQueueIdResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/evaluations/queues/{queue_id}/scenarios/query":{"post":{"tags":["Evaluations"],"summary":"Query Queue Scenarios","operationId":"query_evaluation_queue_scenarios","parameters":[{"name":"queue_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Queue Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationQueueScenariosQueryRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationScenariosResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/":{"post":{"tags":["Evaluations"],"summary":"Create Evaluation","operationId":"create_simple_evaluation","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluationCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/query":{"post":{"tags":["Evaluations"],"summary":"Query Evaluations","operationId":"query_simple_evaluations","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluationQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluationsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/{evaluation_id}":{"get":{"tags":["Evaluations"],"summary":"Fetch Evaluation","operationId":"fetch_simple_evaluation","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Evaluations"],"summary":"Edit Evaluation","operationId":"edit_simple_evaluation","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluationEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Evaluations"],"summary":"Delete Evaluation","operationId":"delete_simple_evaluation","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluationIdResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/{evaluation_id}/start":{"post":{"tags":["Evaluations"],"summary":"Start Evaluation","operationId":"start_simple_evaluation","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/{evaluation_id}/stop":{"post":{"tags":["Evaluations"],"summary":"Stop Evaluation","operationId":"stop_simple_evaluation","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/{evaluation_id}/close":{"post":{"tags":["Evaluations"],"summary":"Close Evaluation","operationId":"close_simple_evaluation","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/{evaluation_id}/open":{"post":{"tags":["Evaluations"],"summary":"Open Evaluation","operationId":"open_simple_evaluation","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleEvaluationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/{evaluation_id}/populate":{"post":{"tags":["Evaluations"],"summary":"Populate Evaluation Slice","operationId":"populate_slice","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PopulateSliceRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationResultsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/{evaluation_id}/process":{"post":{"tags":["Evaluations"],"summary":"Process Evaluation Slice","operationId":"process_slice","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProcessSliceRequest"}}}},"responses":{"202":{"description":"Accepted.","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/{evaluation_id}/probe":{"post":{"tags":["Evaluations"],"summary":"Probe Evaluation Slice","operationId":"probe_slice","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProbeSliceRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationResultsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/{evaluation_id}/prune":{"post":{"tags":["Evaluations"],"summary":"Prune Evaluation Slice","operationId":"prune_slice","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PruneSliceRequest"}}}},"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/{evaluation_id}/refresh":{"post":{"tags":["Evaluations"],"summary":"Refresh Evaluation Slice","operationId":"refresh_slice","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshSliceRequest"}}}},"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/{evaluation_id}/scenarios/add":{"post":{"tags":["Evaluations"],"summary":"Add Evaluation Scenarios","operationId":"add_scenarios","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddScenariosRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationScenariosResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/{evaluation_id}/scenarios/remove":{"post":{"tags":["Evaluations"],"summary":"Remove Evaluation Scenarios","operationId":"remove_scenarios","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RemoveScenariosRequest"}}}},"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/{evaluation_id}/steps/add":{"post":{"tags":["Evaluations"],"summary":"Add Evaluation Steps","operationId":"add_steps","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddStepsRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/{evaluation_id}/steps/remove":{"post":{"tags":["Evaluations"],"summary":"Remove Evaluation Steps","operationId":"remove_steps","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RemoveStepsRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/evaluations/{evaluation_id}/repeats/set":{"post":{"tags":["Evaluations"],"summary":"Set Evaluation Repeats","operationId":"set_repeats","parameters":[{"name":"evaluation_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Evaluation Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetRepeatsRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluationRunResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/queues/":{"post":{"tags":["Evaluations"],"summary":"Create Simple Queue","operationId":"create_simple_queue","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueueCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueueResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Evaluations"],"summary":"Delete Simple Queues","operationId":"delete_simple_queues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueueIdsRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueueIdsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/queues/query":{"post":{"tags":["Evaluations"],"summary":"Query Simple Queues","operationId":"query_simple_queues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueueQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueuesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/queues/{queue_id}":{"get":{"tags":["Evaluations"],"summary":"Fetch Simple Queue","operationId":"fetch_simple_queue","parameters":[{"name":"queue_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Queue Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueueResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Evaluations"],"summary":"Delete Simple Queue","operationId":"delete_simple_queue","parameters":[{"name":"queue_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Queue Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueueIdResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/queues/{queue_id}/scenarios/query":{"post":{"tags":["Evaluations"],"summary":"Query Simple Queue Scenarios","operationId":"query_simple_queue_scenarios","parameters":[{"name":"queue_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Queue Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueueScenariosQueryRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueueScenariosResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/queues/{queue_id}/traces/":{"post":{"tags":["Evaluations"],"summary":"Add Simple Queue Traces","operationId":"add_simple_queue_traces","parameters":[{"name":"queue_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Queue Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueueTracesCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueueIdResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/simple/queues/{queue_id}/testcases/":{"post":{"tags":["Evaluations"],"summary":"Add Simple Queue Testcases","operationId":"add_simple_queue_testcases","parameters":[{"name":"queue_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Queue Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueueTestcasesCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SimpleQueueIdResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/mounts/":{"post":{"tags":["Mounts"],"summary":"Create Mount","operationId":"create_mount","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountCreateRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/mounts/query":{"post":{"tags":["Mounts"],"summary":"Query Mounts","operationId":"query_mounts","parameters":[{"name":"session_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Session Id"}},{"name":"agent_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Id"}},{"name":"include_archived","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Include Archived"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountQueryRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/mounts/agents/sign":{"post":{"tags":["Mounts"],"summary":"Sign Agent Mount Credentials","operationId":"sign_agent_mount_credentials","parameters":[{"name":"artifact_id","in":"query","required":true,"schema":{"type":"string","title":"Artifact Id"}},{"name":"name","in":"query","required":false,"schema":{"type":"string","default":"default","title":"Name"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountCredentialsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/mounts/agents/query":{"post":{"tags":["Mounts"],"summary":"Query Agent Mount","operationId":"query_agent_mount","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentMountQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/mounts/{mount_id}":{"get":{"tags":["Mounts"],"summary":"Fetch Mount","operationId":"fetch_mount","parameters":[{"name":"mount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Mount Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Mounts"],"summary":"Edit Mount","operationId":"edit_mount","parameters":[{"name":"mount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Mount Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountEditRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/mounts/{mount_id}/sign":{"post":{"tags":["Mounts"],"summary":"Sign Mount Credentials","operationId":"sign_mount_credentials","parameters":[{"name":"mount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Mount Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountCredentialsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/mounts/files/export":{"post":{"tags":["Mounts"],"summary":"Export Mount Files","operationId":"export_mount_files","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountArchiveRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/zip":{"schema":{"type":"string","format":"binary"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/mounts/{mount_id}/archive":{"post":{"tags":["Mounts"],"summary":"Archive Mount","operationId":"archive_mount","parameters":[{"name":"mount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Mount Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/mounts/{mount_id}/unarchive":{"post":{"tags":["Mounts"],"summary":"Unarchive Mount","operationId":"unarchive_mount","parameters":[{"name":"mount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Mount Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/mounts/{mount_id}/files/folder":{"post":{"tags":["Mounts"],"summary":"Create Folder","operationId":"create_mount_folder","parameters":[{"name":"mount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Mount Id"}},{"name":"path","in":"query","required":true,"schema":{"type":"string","title":"Path"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountFolderCreatedResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/mounts/{mount_id}/files/upload":{"post":{"tags":["Mounts"],"summary":"Upload Mount File","operationId":"upload_mount_file","parameters":[{"name":"mount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Mount Id"}},{"name":"path","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_upload_mount_file"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountFileWrittenResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/mounts/{mount_id}/files/download":{"get":{"tags":["Mounts"],"summary":"Download Mount File","operationId":"download_mount_file","parameters":[{"name":"mount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Mount Id"}},{"name":"path","in":"query","required":true,"schema":{"type":"string","title":"Path"}}],"responses":{"200":{"description":"Successful Response","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/mounts/{mount_id}/files":{"get":{"tags":["Mounts"],"summary":"Get Mount Files","operationId":"get_mount_files","parameters":[{"name":"mount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Mount Id"}},{"name":"path","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path"}},{"name":"read","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Read"}},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"enum":["recent","name","path"],"type":"string"},{"type":"null"}],"title":"Order"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","minimum":0},{"type":"null"}],"title":"Limit"}},{"name":"depth","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","maximum":1,"minimum":1},{"type":"null"}],"title":"Depth"}},{"name":"with_counts","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"With Counts"}},{"name":"git_aware","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Git Aware"}},{"name":"include_gitignored","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Include Gitignored"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Mounts"],"summary":"Write Mount File","operationId":"write_mount_file","parameters":[{"name":"mount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Mount Id"}},{"name":"path","in":"query","required":true,"schema":{"type":"string","title":"Path"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountFileWrittenResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Mounts"],"summary":"Delete Mount File","operationId":"delete_mount_file","parameters":[{"name":"mount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Mount Id"}},{"name":"path","in":"query","required":true,"schema":{"type":"string","title":"Path"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountFileDeletedResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/attachments":{"post":{"tags":["Sessions"],"summary":"Create Session Attachment","operationId":"create_session_attachment","parameters":[{"name":"session_id","in":"query","required":true,"schema":{"type":"string","title":"Session Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionAttachmentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/attachments/{attachment_id}/content":{"get":{"tags":["Sessions"],"summary":"Download Session Attachment Content","operationId":"download_session_attachment_content","parameters":[{"name":"attachment_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Attachment Id"}},{"name":"session_id","in":"query","required":true,"schema":{"type":"string","title":"Session Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/attachments/reference":{"post":{"tags":["Sessions"],"summary":"Reference Session Attachments","operationId":"reference_session_attachments","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionAttachmentReferenceRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionAttachmentsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/mounts/":{"get":{"tags":["Sessions"],"summary":"Fetch Session Mounts","operationId":"fetch_session_mounts","parameters":[{"name":"session_id","in":"query","required":true,"schema":{"type":"string","title":"Session Id"}},{"name":"include_archived","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Include Archived"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionMountsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/mounts/query":{"post":{"tags":["Sessions"],"summary":"Query Session Mounts","operationId":"query_session_mounts","parameters":[{"name":"session_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Session Id"}},{"name":"include_archived","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Include Archived"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionMountQueryRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionMountsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/mounts/sign":{"post":{"tags":["Sessions"],"summary":"Sign Session Mount Credentials","operationId":"sign_session_mount_credentials","parameters":[{"name":"session_id","in":"query","required":true,"schema":{"type":"string","title":"Session Id"}},{"name":"name","in":"query","required":false,"schema":{"type":"string","description":"Which session-scoped mount to sign, e.g. 'cwd' (default) or a per-harness transcript dir mount (e.g. 'claude-projects', 'pi-sessions'). Each name is its own mount row / durable prefix.","default":"cwd","title":"Name"},"description":"Which session-scoped mount to sign, e.g. 'cwd' (default) or a per-harness transcript dir mount (e.g. 'claude-projects', 'pi-sessions'). Each name is its own mount row / durable prefix."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountCredentialsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/mounts/{mount_id}/files/upload":{"post":{"tags":["Sessions"],"summary":"Upload Session Mount File","operationId":"upload_session_mount_file","parameters":[{"name":"mount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Mount Id"}},{"name":"path","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_upload_session_mount_file"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MountFileWrittenResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/mounts/{mount_id}/files/download":{"get":{"tags":["Sessions"],"summary":"Download Session Mount File","operationId":"download_session_mount_file","parameters":[{"name":"mount_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Mount Id"}},{"name":"path","in":"query","required":true,"schema":{"type":"string","title":"Path"}}],"responses":{"200":{"description":"Successful Response","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/records/query":{"post":{"tags":["Sessions"],"summary":"Query Records","operationId":"query_records","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionRecordQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionRecordsQueryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/records/{record_id}":{"get":{"tags":["Sessions"],"summary":"Get Record Event","operationId":"get_record_event","parameters":[{"name":"record_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Record Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionRecordResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/records/ingest":{"post":{"tags":["Sessions","Sessions"],"summary":"Ingest Record Event","operationId":"ingest_record","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionRecordIngestRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Ingest Record"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/turns/":{"post":{"tags":["Sessions"],"summary":"Append Turn","operationId":"append_turn","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionTurnAppendRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionTurnResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/turns/complete":{"post":{"tags":["Sessions"],"summary":"Complete Turn","operationId":"complete_turn","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionTurnCompleteRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionTurnResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/turns/query":{"post":{"tags":["Sessions"],"summary":"Query Turns","operationId":"query_turns","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionTurnQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionTurnsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/turns/{turn_id}":{"get":{"tags":["Sessions"],"summary":"Fetch Turn","operationId":"fetch_turn","parameters":[{"name":"turn_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Turn Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionTurnResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/accounts/":{"post":{"tags":["Admin"],"summary":"Create accounts","operationId":"create_accounts_admin_accounts__post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAccountsCreate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAccountsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Admin"],"summary":"Delete accounts","operationId":"delete_accounts_admin_accounts__delete","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAccountsDelete"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminDeleteResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/":{"post":{"tags":["Admin"],"summary":"Create simple accounts","operationId":"create_simple_accounts_admin_simple_accounts__post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSimpleAccountsCreate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSimpleAccountsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Admin"],"summary":"Delete simple accounts","operationId":"delete_simple_accounts_admin_simple_accounts__delete","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSimpleAccountsDelete"}}},"required":true},"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/users/":{"post":{"tags":["Admin"],"summary":"Create users","operationId":"create_user_admin_simple_accounts_users__post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSimpleAccountsUsersCreate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAccountsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/users/{user_id}":{"delete":{"tags":["Admin"],"summary":"Delete user","operationId":"delete_user_admin_simple_accounts_users__user_id__delete","parameters":[{"name":"user_id","in":"path","required":true,"schema":{"type":"string","title":"User Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminDeleteResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/users/identities/":{"post":{"tags":["Admin"],"summary":"Create user identities","operationId":"create_user_identity_admin_simple_accounts_users_identities__post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSimpleAccountsUsersIdentitiesCreate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAccountsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/users/{user_id}/identities/{identity_id}":{"delete":{"tags":["Admin"],"summary":"Delete user identity","operationId":"delete_user_identity_admin_simple_accounts_users__user_id__identities__identity_id__delete","parameters":[{"name":"user_id","in":"path","required":true,"schema":{"type":"string","title":"User Id"}},{"name":"identity_id","in":"path","required":true,"schema":{"type":"string","title":"Identity Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminDeleteResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/organizations/":{"post":{"tags":["Admin"],"summary":"Create organizations","operationId":"create_organization_admin_simple_accounts_organizations__post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSimpleAccountsOrganizationsCreate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAccountsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/organizations/{organization_id}":{"delete":{"tags":["Admin"],"summary":"Delete organization","operationId":"delete_organization_admin_simple_accounts_organizations__organization_id__delete","parameters":[{"name":"organization_id","in":"path","required":true,"schema":{"type":"string","title":"Organization Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminDeleteResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/organizations/memberships/":{"post":{"tags":["Admin"],"summary":"Create organization memberships","operationId":"create_organization_membership_admin_simple_accounts_organizations_memberships__post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSimpleAccountsOrganizationsMembershipsCreate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAccountsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/organizations/{organization_id}/memberships/{membership_id}":{"delete":{"tags":["Admin"],"summary":"Delete organization membership","operationId":"delete_organization_membership_admin_simple_accounts_organizations__organization_id__memberships__membership_id__delete","parameters":[{"name":"organization_id","in":"path","required":true,"schema":{"type":"string","title":"Organization Id"}},{"name":"membership_id","in":"path","required":true,"schema":{"type":"string","title":"Membership Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminDeleteResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/workspaces/":{"post":{"tags":["Admin"],"summary":"Create workspaces","operationId":"create_workspace_admin_simple_accounts_workspaces__post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSimpleAccountsWorkspacesCreate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAccountsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/workspaces/{workspace_id}":{"delete":{"tags":["Admin"],"summary":"Delete workspace","operationId":"delete_workspace_admin_simple_accounts_workspaces__workspace_id__delete","parameters":[{"name":"workspace_id","in":"path","required":true,"schema":{"type":"string","title":"Workspace Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminDeleteResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/workspaces/memberships/":{"post":{"tags":["Admin"],"summary":"Create workspace memberships","operationId":"create_workspace_membership_admin_simple_accounts_workspaces_memberships__post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSimpleAccountsWorkspacesMembershipsCreate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAccountsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/workspaces/{workspace_id}/memberships/{membership_id}":{"delete":{"tags":["Admin"],"summary":"Delete workspace membership","operationId":"delete_workspace_membership_admin_simple_accounts_workspaces__workspace_id__memberships__membership_id__delete","parameters":[{"name":"workspace_id","in":"path","required":true,"schema":{"type":"string","title":"Workspace Id"}},{"name":"membership_id","in":"path","required":true,"schema":{"type":"string","title":"Membership Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminDeleteResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/projects/":{"post":{"tags":["Admin"],"summary":"Create projects","operationId":"create_project_admin_simple_accounts_projects__post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSimpleAccountsProjectsCreate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAccountsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/projects/{project_id}":{"delete":{"tags":["Admin"],"summary":"Delete project","operationId":"delete_project_admin_simple_accounts_projects__project_id__delete","parameters":[{"name":"project_id","in":"path","required":true,"schema":{"type":"string","title":"Project Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminDeleteResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/projects/memberships/":{"post":{"tags":["Admin"],"summary":"Create project memberships","operationId":"create_project_membership_admin_simple_accounts_projects_memberships__post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSimpleAccountsProjectsMembershipsCreate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAccountsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/projects/{project_id}/memberships/{membership_id}":{"delete":{"tags":["Admin"],"summary":"Delete project membership","operationId":"delete_project_membership_admin_simple_accounts_projects__project_id__memberships__membership_id__delete","parameters":[{"name":"project_id","in":"path","required":true,"schema":{"type":"string","title":"Project Id"}},{"name":"membership_id","in":"path","required":true,"schema":{"type":"string","title":"Membership Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminDeleteResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/api-keys/":{"post":{"tags":["Admin"],"summary":"Create API keys","operationId":"create_api_key_admin_simple_accounts_api_keys__post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSimpleAccountsApiKeysCreate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminAccountsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/api-keys/{api_key_id}":{"delete":{"tags":["Admin"],"summary":"Delete API key","operationId":"delete_api_key_admin_simple_accounts_api_keys__api_key_id__delete","parameters":[{"name":"api_key_id","in":"path","required":true,"schema":{"type":"string","title":"Api Key Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminDeleteResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/reset-password":{"post":{"tags":["Admin"],"summary":"Reset user password","operationId":"reset_password_admin_simple_accounts_reset_password_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSimpleAccountsUsersResetPassword"}}},"required":true},"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/simple/accounts/transfer-ownership":{"post":{"tags":["Admin"],"summary":"Transfer organization ownership","operationId":"transfer_ownership_admin_simple_accounts_transfer_ownership_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSimpleAccountsOrganizationsTransferOwnership"}}},"required":true},"responses":{"204":{"description":"Successful Response"},"200":{"description":"Partial transfer — some orgs could not be transferred.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminSimpleAccountsOrganizationsTransferOwnershipResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/query":{"post":{"tags":["Sessions","Sessions"],"summary":"Query Sessions","operationId":"query_sessions","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/":{"delete":{"tags":["Sessions","Sessions"],"summary":"Delete Session","operationId":"delete_session","parameters":[{"name":"session_id","in":"query","required":true,"schema":{"type":"string","title":"Session Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Delete Session"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/archive":{"post":{"tags":["Sessions","Sessions"],"summary":"Archive Session","operationId":"archive_session","parameters":[{"name":"session_id","in":"query","required":true,"schema":{"type":"string","title":"Session Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/sessions/unarchive":{"post":{"tags":["Sessions","Sessions"],"summary":"Unarchive Session","operationId":"unarchive_session","parameters":[{"name":"session_id","in":"query","required":true,"schema":{"type":"string","title":"Session Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/health":{"get":{"tags":["Status"],"summary":"Health Check","operationId":"health_check","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/.well-known/jwks.json":{"get":{"tags":["Status"],"summary":"Store Jwks","description":"Public JWKS the object store's OIDC IAM fetches to verify our web-identity tokens.","operationId":"store_jwks","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/access/permissions/check":{"get":{"tags":["Access"],"summary":"Check Permissions","operationId":"check_permissions","parameters":[{"name":"action","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Action"}},{"name":"scope_type","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Scope Type"}},{"name":"scope_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Scope Id"}},{"name":"resource_type","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Resource Type"}},{"name":"resource_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Resource Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/access/roles":{"get":{"tags":["Access"],"summary":"Fetch Roles","description":"Return the effective role catalog per scope (organization,\nworkspace, project). RBAC is an OSS feature, so this is served in both\neditions; the frontend reads the `workspace` scope for the members UI.","operationId":"fetch_access_roles","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":{"items":{"additionalProperties":true,"type":"object"},"type":"array"},"type":"object","title":"Response Fetch Access Roles"}}}}}}},"/projects":{"get":{"tags":["Projects"],"summary":"Get Projects","operationId":"get_projects","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/ProjectsResponse"},"type":"array","title":"Response Get Projects"}}}}}},"post":{"tags":["Projects"],"summary":"Create Project","operationId":"create_project","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProjectRequest"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/projects/{project_id}":{"get":{"tags":["Projects"],"summary":"Get Project","operationId":"get_project","parameters":[{"name":"project_id","in":"path","required":true,"schema":{"type":"string","title":"Project Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Projects"],"summary":"Delete Project","operationId":"delete_project","parameters":[{"name":"project_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Project Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Projects"],"summary":"Update Project","operationId":"update_project","parameters":[{"name":"project_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Project Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProjectRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/profile":{"get":{"tags":["Users"],"summary":"User Profile","operationId":"fetch_user_profile","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}},"delete":{"tags":["Users"],"summary":"Delete User Account","description":"Self-serve deletion of the caller's own account (EE only).\n\nRequires an interactive SuperTokens session. API keys and service tokens are\nrejected: this is an irreversible destructive action, so a leaked or embedded\nintegration key must not be enough to delete the owning account.","operationId":"delete_user_account","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/profile/username":{"put":{"tags":["Users"],"summary":"Update User Username","operationId":"update_user_username","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserUpdate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/profile/reset-password":{"post":{"tags":["Users"],"summary":"Reset User Password","operationId":"reset_user_password","parameters":[{"name":"user_id","in":"query","required":true,"schema":{"type":"string","title":"User Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/keys":{"get":{"tags":["Keys"],"summary":"List Api Keys","description":"List all API keys associated with the authenticated user.\n\nArgs:\n request (Request): The incoming request object.\n\nReturns:\n List[ListAPIKeysResponse]: A list of API Keys associated with the user.","operationId":"list_api_keys","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/ListAPIKeysResponse"},"type":"array","title":"Response List Api Keys"}}}}}},"post":{"tags":["Keys"],"summary":"Create Api Key","description":"Creates an API key for a user.\n\nArgs:\n request (Request): The request object containing the user ID in the request state.\n\nReturns:\n str: The created API key.","operationId":"create_api_key","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"string","title":"Response Create Api Key"}}}}}}},"/keys/{key_prefix}":{"delete":{"tags":["Keys"],"summary":"Delete Api Key","description":"Delete an API key with the given key prefix for the authenticated user.\n\nArgs:\n key_prefix (str): The prefix of the API key to be deleted.\n request (Request): The incoming request object.\n\nReturns:\n dict: A dictionary containing a success message upon successful deletion.\n\nRaises:\n HTTPException: If the API key is not found or does not belong to the user.","operationId":"delete_api_key","parameters":[{"name":"key_prefix","in":"path","required":true,"schema":{"type":"string","title":"Key Prefix"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Delete Api Key"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations":{"get":{"tags":["Organizations"],"summary":"List Organizations","description":"Returns a list of organizations associated with the user's session.\n\nReturns:\n list[Organization]: A list of organizations associated with the user's session.\n\nRaises:\n HTTPException: If there is an error retrieving the organizations from the database.","operationId":"list_organizations","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Organization"},"type":"array","title":"Response List Organizations"}}}}}},"post":{"tags":["Organizations"],"summary":"Create Organization","description":"Create a new organization.","operationId":"create_organization","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateOrganizationPayload"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations/{organization_id}":{"get":{"tags":["Organizations"],"summary":"Fetch Organization Details","description":"Return the details of the organization.","operationId":"fetch_organization_details","parameters":[{"name":"organization_id","in":"path","required":true,"schema":{"type":"string","title":"Organization Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationDetails"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Organizations"],"summary":"Update Organization","operationId":"patch_organization","parameters":[{"name":"organization_id","in":"path","required":true,"schema":{"type":"string","title":"Organization Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Organizations"],"summary":"Update Organization","operationId":"update_organization","parameters":[{"name":"organization_id","in":"path","required":true,"schema":{"type":"string","title":"Organization Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Organizations"],"summary":"Delete Organization","description":"Delete an organization (owner only).","operationId":"delete_organization","parameters":[{"name":"organization_id","in":"path","required":true,"schema":{"type":"string","title":"Organization Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations/{organization_id}/workspaces/{workspace_id}/invite":{"post":{"tags":["Organizations"],"summary":"Invite User To Organization","description":"Assigns a role to a user in an organization.\n\nArgs:\n organization_id (str): The ID of the organization.\n payload (InviteRequest): The payload containing the organization id, user email, and role to assign.\n workspace_id (str): The ID of the workspace.\n\nReturns:\n bool: True if the role was successfully assigned, False otherwise.\n\nRaises:\n HTTPException: If the user does not have permission to perform this action.\n HTTPException: If there is an error assigning the role to the user.","operationId":"invite_user_to_workspace","parameters":[{"name":"organization_id","in":"path","required":true,"schema":{"type":"string","title":"Organization Id"}},{"name":"workspace_id","in":"path","required":true,"schema":{"type":"string","title":"Workspace Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/InviteRequest"},"title":"Payload"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations/{organization_id}/workspaces/{workspace_id}/invite/resend":{"post":{"tags":["Organizations"],"summary":"Resend User Invitation To Organization","description":"Resend an invitation to a user to an Organization.\n\nRaises:\n HTTPException: _description_; status_code: 500\n HTTPException: Invitation not found or has expired; status_code: 400\n HTTPException: You already belong to this organization; status_code: 400\n\nReturns:\n JSONResponse: Resent invitation to user; status_code: 200","operationId":"resend_invitation","parameters":[{"name":"organization_id","in":"path","required":true,"schema":{"type":"string","title":"Organization Id"}},{"name":"workspace_id","in":"path","required":true,"schema":{"type":"string","title":"Workspace Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResendInviteRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations/{organization_id}/workspaces/{workspace_id}/invite/accept":{"post":{"tags":["Organizations"],"summary":"Accept Organization Invitation","description":"Accept an invitation to an organization.\n\nRaises:\n HTTPException: _description_; status_code: 500\n HTTPException: Invitation not found or has expired; status_code: 400\n HTTPException: You already belong to this organization; status_code: 400\n\nReturns:\n JSONResponse: Accepted invitation to workspace; status_code: 200","operationId":"accept_invitation","parameters":[{"name":"organization_id","in":"path","required":true,"schema":{"type":"string","title":"Organization Id"}},{"name":"workspace_id","in":"path","required":true,"schema":{"type":"string","title":"Workspace Id"}},{"name":"project_id","in":"query","required":true,"schema":{"type":"string","title":"Project Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InviteToken"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations/{organization_id}/transfer/{new_owner_id}":{"post":{"tags":["Organizations"],"summary":"Transfer Organization Ownership","description":"Transfer organization ownership to another member.","operationId":"transfer_organization_ownership","parameters":[{"name":"organization_id","in":"path","required":true,"schema":{"type":"string","title":"Organization Id"}},{"name":"new_owner_id","in":"path","required":true,"schema":{"type":"string","title":"New Owner Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations/{organization_id}/workspaces":{"post":{"tags":["Organizations"],"summary":"Create Workspace","description":"Create a new workspace in an organization (owner only).","operationId":"create_workspace","parameters":[{"name":"organization_id","in":"path","required":true,"schema":{"type":"string","title":"Organization Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspace"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/organizations/{organization_id}/workspaces/{workspace_id}":{"put":{"tags":["Organizations"],"summary":"Update Workspace","description":"Update a workspace's details (requires EDIT_WORKSPACE permission).","operationId":"update_workspace","parameters":[{"name":"organization_id","in":"path","required":true,"schema":{"type":"string","title":"Organization Id"}},{"name":"workspace_id","in":"path","required":true,"schema":{"type":"string","title":"Workspace Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspace"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workspaces":{"get":{"tags":["Workspaces"],"summary":"Get Workspace","description":"Get workspace details.\n\nReturns details about the workspace associated with the user's session.\n\nReturns:\n Workspace: The details of the workspace.\n\nRaises:\n HTTPException: If the user does not have permission to perform this action.","operationId":"get_workspace","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Workspace"},"type":"array","title":"Response Get Workspace"}}}}}}},"/workspaces/roles":{"get":{"tags":["Workspaces"],"summary":"Get All Workspace Roles","description":"Get all workspace roles.\n\nReturns a list of all available workspace roles.\n\nReturns:\n List[WorkspaceRoleResponse]: A list of DefaultRole objects representing the available workspace roles.\n\nRaises:\n HTTPException: If an error occurs while retrieving the workspace roles.","operationId":"get_all_workspace_roles","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"additionalProperties":{"type":"string"},"type":"object"},"type":"array","title":"Response Get All Workspace Roles"}}}}}}},"/workspaces/permissions":{"get":{"tags":["Workspaces"],"summary":"Get All Workspace Permissions","description":"Get all available workspace permissions.","operationId":"get_all_workspace_permissions","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Permission"},"type":"array","title":"Response Get All Workspace Permissions"}}}}}}},"/workspaces/{workspace_id}/roles":{"post":{"tags":["Workspaces"],"summary":"Assign Role To User","description":"Assign a role to a user in a workspace.\n\nArgs:\n payload (UserRole): The organization id, user email, and role to assign.\n workspace_id (str): The ID of the workspace.","operationId":"assign_role_to_user","parameters":[{"name":"workspace_id","in":"path","required":true,"schema":{"type":"string","title":"Workspace Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserRole"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["Workspaces"],"summary":"Unassign Role From User","description":"Remove a role assignment from a user in a workspace.\n\nArgs:\n email (str): The email of the user.\n organization_id (str): The ID of the organization.\n role (str): The role to remove.\n workspace_id (str): The ID of the workspace.","operationId":"unassign_role_from_user","parameters":[{"name":"workspace_id","in":"path","required":true,"schema":{"type":"string","title":"Workspace Id"}},{"name":"email","in":"query","required":true,"schema":{"type":"string","title":"Email"}},{"name":"organization_id","in":"query","required":true,"schema":{"type":"string","title":"Organization Id"}},{"name":"role","in":"query","required":true,"schema":{"type":"string","title":"Role"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/workspaces/{workspace_id}/users":{"delete":{"tags":["Workspaces"],"summary":"Remove User From Workspace","description":"Remove a user from a workspace.\n\nArgs:\n email (str): The email address of the user to be removed\n workspace_id (str): The ID of the workspace.","operationId":"remove_user_from_workspace","parameters":[{"name":"workspace_id","in":"path","required":true,"schema":{"type":"string","title":"Workspace Id"}},{"name":"email","in":"query","required":true,"schema":{"type":"string","title":"Email"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}}},"components":{"schemas":{"AddScenariosRequest":{"properties":{"count":{"type":"integer","title":"Count"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Timestamp"}},"type":"object","required":["count"],"title":"AddScenariosRequest"},"AddStepsRequest":{"properties":{"steps":{"items":{"$ref":"#/components/schemas/EvaluationRunDataStep-Input"},"type":"array","title":"Steps"}},"type":"object","required":["steps"],"title":"AddStepsRequest"},"AdminAccountCreateOptions":{"properties":{"dry_run":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Dry Run","default":false},"idempotency_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Idempotency Key"},"create_identities":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Create Identities"},"create_api_keys":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Create Api Keys"},"return_api_keys":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Return Api Keys"},"seed_defaults":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Seed Defaults","default":true},"reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Reason"}},"type":"object","title":"AdminAccountCreateOptions"},"AdminAccountRead":{"properties":{"users":{"additionalProperties":{"$ref":"#/components/schemas/AdminUserRead"},"type":"object","title":"Users","default":{}},"user_identities":{"additionalProperties":{"$ref":"#/components/schemas/AdminUserIdentityRead"},"type":"object","title":"User Identities","default":{}},"organizations":{"additionalProperties":{"$ref":"#/components/schemas/AdminOrganizationRead"},"type":"object","title":"Organizations","default":{}},"workspaces":{"additionalProperties":{"$ref":"#/components/schemas/AdminWorkspaceRead"},"type":"object","title":"Workspaces","default":{}},"projects":{"additionalProperties":{"$ref":"#/components/schemas/AdminProjectRead"},"type":"object","title":"Projects","default":{}},"organization_memberships":{"additionalProperties":{"$ref":"#/components/schemas/AdminOrganizationMembershipRead"},"type":"object","title":"Organization Memberships","default":{}},"workspace_memberships":{"additionalProperties":{"$ref":"#/components/schemas/AdminWorkspaceMembershipRead"},"type":"object","title":"Workspace Memberships","default":{}},"project_memberships":{"additionalProperties":{"$ref":"#/components/schemas/AdminProjectMembershipRead"},"type":"object","title":"Project Memberships","default":{}},"subscriptions":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminSubscriptionRead"},"type":"object"},{"type":"null"}],"title":"Subscriptions"},"api_keys":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminApiKeyResponse"},"type":"object"},{"type":"null"}],"title":"Api Keys"}},"type":"object","title":"AdminAccountRead","description":"Per-account projection in the full graph response (plural entity maps)."},"AdminAccountsCreate":{"properties":{"options":{"anyOf":[{"$ref":"#/components/schemas/AdminAccountCreateOptions"},{"type":"null"}]},"users":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminUserCreate"},"type":"object"},{"type":"null"}],"title":"Users"},"user_identities":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminUserIdentityCreate"},"type":"object"},{"type":"null"}],"title":"User Identities"},"organizations":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminOrganizationCreate"},"type":"object"},{"type":"null"}],"title":"Organizations"},"workspaces":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminWorkspaceCreate"},"type":"object"},{"type":"null"}],"title":"Workspaces"},"projects":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminProjectCreate"},"type":"object"},{"type":"null"}],"title":"Projects"},"organization_memberships":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminOrganizationMembershipCreate"},"type":"object"},{"type":"null"}],"title":"Organization Memberships"},"workspace_memberships":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminWorkspaceMembershipCreate"},"type":"object"},{"type":"null"}],"title":"Workspace Memberships"},"project_memberships":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminProjectMembershipCreate"},"type":"object"},{"type":"null"}],"title":"Project Memberships"},"api_keys":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminApiKeyCreate"},"type":"object"},{"type":"null"}],"title":"Api Keys"},"subscriptions":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminSubscriptionCreate"},"type":"object"},{"type":"null"}],"title":"Subscriptions"}},"type":"object","title":"AdminAccountsCreate"},"AdminAccountsDelete":{"properties":{"target":{"$ref":"#/components/schemas/AdminAccountsDeleteTarget"},"dry_run":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Dry Run","default":true},"reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Reason"},"confirm":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Confirm"}},"type":"object","required":["target"],"title":"AdminAccountsDelete"},"AdminAccountsDeleteTarget":{"properties":{"user_ids":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"User Ids"},"user_emails":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"User Emails"},"organization_ids":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Organization Ids"},"workspace_ids":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Workspace Ids"},"project_ids":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Project Ids"}},"type":"object","title":"AdminAccountsDeleteTarget"},"AdminAccountsResponse":{"properties":{"accounts":{"items":{"$ref":"#/components/schemas/AdminAccountRead"},"type":"array","title":"Accounts","default":[]},"errors":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminStructuredError"},"type":"array"},{"type":"null"}],"title":"Errors"}},"type":"object","title":"AdminAccountsResponse"},"AdminApiKeyCreate":{"properties":{"project_ref":{"$ref":"#/components/schemas/EntityRef"},"user_ref":{"$ref":"#/components/schemas/EntityRef"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"expires_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Expires At"}},"type":"object","required":["project_ref","user_ref"],"title":"AdminApiKeyCreate"},"AdminApiKeyResponse":{"properties":{"id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Id"},"prefix":{"type":"string","title":"Prefix"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"project_id":{"type":"string","title":"Project Id"},"user_id":{"type":"string","title":"User Id"},"expires_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Expires At"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"revoked_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Revoked At"},"value":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Value"},"returned_once":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Returned Once"}},"type":"object","required":["prefix","project_id","user_id"],"title":"AdminApiKeyResponse"},"AdminDeleteResponse":{"properties":{"dry_run":{"type":"boolean","title":"Dry Run","default":false},"deleted":{"$ref":"#/components/schemas/AdminDeletedEntities","default":{}},"skipped":{"anyOf":[{"$ref":"#/components/schemas/AdminDeletedEntities"},{"type":"null"}]},"errors":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminStructuredError"},"type":"array"},{"type":"null"}],"title":"Errors"}},"type":"object","title":"AdminDeleteResponse"},"AdminDeletedEntities":{"properties":{"users":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminDeletedEntity"},"type":"array"},{"type":"null"}],"title":"Users"},"user_identities":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminDeletedEntity"},"type":"array"},{"type":"null"}],"title":"User Identities"},"organizations":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminDeletedEntity"},"type":"array"},{"type":"null"}],"title":"Organizations"},"workspaces":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminDeletedEntity"},"type":"array"},{"type":"null"}],"title":"Workspaces"},"projects":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminDeletedEntity"},"type":"array"},{"type":"null"}],"title":"Projects"},"organization_memberships":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminDeletedEntity"},"type":"array"},{"type":"null"}],"title":"Organization Memberships"},"workspace_memberships":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminDeletedEntity"},"type":"array"},{"type":"null"}],"title":"Workspace Memberships"},"project_memberships":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminDeletedEntity"},"type":"array"},{"type":"null"}],"title":"Project Memberships"},"api_keys":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminDeletedEntity"},"type":"array"},{"type":"null"}],"title":"Api Keys"}},"type":"object","title":"AdminDeletedEntities"},"AdminDeletedEntity":{"properties":{"id":{"type":"string","title":"Id"},"ref":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ref"}},"type":"object","required":["id"],"title":"AdminDeletedEntity"},"AdminOrganizationCreate":{"properties":{"name":{"type":"string","title":"Name"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"owner_user_ref":{"anyOf":[{"$ref":"#/components/schemas/EntityRef"},{"type":"null"}]},"metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metadata"}},"type":"object","required":["name"],"title":"AdminOrganizationCreate"},"AdminOrganizationMembershipCreate":{"properties":{"organization_ref":{"$ref":"#/components/schemas/EntityRef"},"user_ref":{"$ref":"#/components/schemas/EntityRef"},"role":{"type":"string","title":"Role"}},"type":"object","required":["organization_ref","user_ref","role"],"title":"AdminOrganizationMembershipCreate"},"AdminOrganizationMembershipRead":{"properties":{"id":{"type":"string","title":"Id"},"organization_id":{"type":"string","title":"Organization Id"},"user_id":{"type":"string","title":"User Id"},"role":{"type":"string","title":"Role"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Updated At"}},"type":"object","required":["id","organization_id","user_id","role"],"title":"AdminOrganizationMembershipRead"},"AdminOrganizationRead":{"properties":{"id":{"type":"string","title":"Id"},"name":{"type":"string","title":"Name"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"owner_user_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Owner User Id"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Updated At"},"metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metadata"}},"type":"object","required":["id","name"],"title":"AdminOrganizationRead"},"AdminProjectCreate":{"properties":{"name":{"type":"string","title":"Name"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"organization_ref":{"$ref":"#/components/schemas/EntityRef"},"workspace_ref":{"$ref":"#/components/schemas/EntityRef"},"is_default":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Default"},"metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metadata"}},"type":"object","required":["name","organization_ref","workspace_ref"],"title":"AdminProjectCreate"},"AdminProjectMembershipCreate":{"properties":{"project_ref":{"$ref":"#/components/schemas/EntityRef"},"user_ref":{"$ref":"#/components/schemas/EntityRef"},"role":{"type":"string","title":"Role"}},"type":"object","required":["project_ref","user_ref","role"],"title":"AdminProjectMembershipCreate"},"AdminProjectMembershipRead":{"properties":{"id":{"type":"string","title":"Id"},"project_id":{"type":"string","title":"Project Id"},"user_id":{"type":"string","title":"User Id"},"role":{"type":"string","title":"Role"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Updated At"}},"type":"object","required":["id","project_id","user_id","role"],"title":"AdminProjectMembershipRead"},"AdminProjectRead":{"properties":{"id":{"type":"string","title":"Id"},"name":{"type":"string","title":"Name"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"organization_id":{"type":"string","title":"Organization Id"},"workspace_id":{"type":"string","title":"Workspace Id"},"is_default":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Default"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Updated At"},"metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metadata"}},"type":"object","required":["id","name","organization_id","workspace_id"],"title":"AdminProjectRead"},"AdminSimpleAccountCreate":{"properties":{"options":{"anyOf":[{"$ref":"#/components/schemas/AdminAccountCreateOptions"},{"type":"null"}]},"user":{"$ref":"#/components/schemas/AdminUserCreate"},"user_identities":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminUserIdentityCreate"},"type":"array"},{"type":"null"}],"title":"User Identities"},"organization":{"anyOf":[{"$ref":"#/components/schemas/AdminOrganizationCreate"},{"type":"null"}]},"workspace":{"anyOf":[{"$ref":"#/components/schemas/AdminWorkspaceCreate"},{"type":"null"}]},"project":{"anyOf":[{"$ref":"#/components/schemas/AdminProjectCreate"},{"type":"null"}]},"organization_memberships":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminOrganizationMembershipCreate"},"type":"array"},{"type":"null"}],"title":"Organization Memberships"},"workspace_memberships":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminWorkspaceMembershipCreate"},"type":"array"},{"type":"null"}],"title":"Workspace Memberships"},"project_memberships":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminProjectMembershipCreate"},"type":"array"},{"type":"null"}],"title":"Project Memberships"},"api_keys":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminApiKeyCreate"},"type":"array"},{"type":"null"}],"title":"Api Keys"},"subscription":{"anyOf":[{"$ref":"#/components/schemas/AdminSubscriptionCreate"},{"type":"null"}]}},"type":"object","required":["user"],"title":"AdminSimpleAccountCreate","description":"One account entry in a batch simple-accounts create request."},"AdminSimpleAccountDeleteEntry":{"properties":{"user":{"$ref":"#/components/schemas/EntityRef"}},"type":"object","required":["user"],"title":"AdminSimpleAccountDeleteEntry","description":"One account entry in a batch simple-accounts delete request.\n\nIdentifies the account by its user (typically by id)."},"AdminSimpleAccountRead":{"properties":{"user":{"anyOf":[{"$ref":"#/components/schemas/AdminUserRead"},{"type":"null"}]},"user_identities":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminUserIdentityRead"},"type":"array"},{"type":"null"}],"title":"User Identities"},"organizations":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminOrganizationRead"},"type":"object"},{"type":"null"}],"title":"Organizations"},"workspaces":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminWorkspaceRead"},"type":"object"},{"type":"null"}],"title":"Workspaces"},"projects":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminProjectRead"},"type":"object"},{"type":"null"}],"title":"Projects"},"organization_memberships":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminOrganizationMembershipRead"},"type":"array"},{"type":"null"}],"title":"Organization Memberships"},"workspace_memberships":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminWorkspaceMembershipRead"},"type":"array"},{"type":"null"}],"title":"Workspace Memberships"},"project_memberships":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminProjectMembershipRead"},"type":"array"},{"type":"null"}],"title":"Project Memberships"},"subscriptions":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/AdminSubscriptionRead"},"type":"object"},{"type":"null"}],"title":"Subscriptions"},"api_keys":{"anyOf":[{"additionalProperties":{"type":"string"},"type":"object"},{"type":"null"}],"title":"Api Keys"}},"type":"object","title":"AdminSimpleAccountRead","description":"Per-account entry in the simple-accounts response.\n\n``user`` is a flat object (there is always exactly one per account).\n``organizations``, ``workspaces``, ``projects`` are named dicts (keys match\nthe ref keys used internally, e.g. \"org\", \"wrk\", \"prj\").\n``api_keys`` maps ref names to raw key values (plain strings, not DTOs)."},"AdminSimpleAccountsApiKeysCreate":{"properties":{"options":{"anyOf":[{"$ref":"#/components/schemas/AdminAccountCreateOptions"},{"type":"null"}]},"api_key":{"$ref":"#/components/schemas/AdminApiKeyCreate"}},"type":"object","required":["api_key"],"title":"AdminSimpleAccountsApiKeysCreate"},"AdminSimpleAccountsCreate":{"properties":{"options":{"anyOf":[{"$ref":"#/components/schemas/AdminAccountCreateOptions"},{"type":"null"}]},"accounts":{"additionalProperties":{"$ref":"#/components/schemas/AdminSimpleAccountCreate"},"type":"object","title":"Accounts"}},"type":"object","required":["accounts"],"title":"AdminSimpleAccountsCreate"},"AdminSimpleAccountsDelete":{"properties":{"accounts":{"additionalProperties":{"$ref":"#/components/schemas/AdminSimpleAccountDeleteEntry"},"type":"object","title":"Accounts"},"dry_run":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Dry Run","default":false},"reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Reason"},"confirm":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Confirm"}},"type":"object","required":["accounts"],"title":"AdminSimpleAccountsDelete"},"AdminSimpleAccountsOrganizationsCreate":{"properties":{"options":{"anyOf":[{"$ref":"#/components/schemas/AdminAccountCreateOptions"},{"type":"null"}]},"organization":{"$ref":"#/components/schemas/AdminOrganizationCreate"},"owner":{"anyOf":[{"$ref":"#/components/schemas/AdminUserCreate"},{"type":"null"}]}},"type":"object","required":["organization"],"title":"AdminSimpleAccountsOrganizationsCreate"},"AdminSimpleAccountsOrganizationsMembershipsCreate":{"properties":{"options":{"anyOf":[{"$ref":"#/components/schemas/AdminAccountCreateOptions"},{"type":"null"}]},"membership":{"$ref":"#/components/schemas/AdminOrganizationMembershipCreate"}},"type":"object","required":["membership"],"title":"AdminSimpleAccountsOrganizationsMembershipsCreate"},"AdminSimpleAccountsOrganizationsTransferOwnership":{"properties":{"organizations":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/EntityRef"},"type":"object"},{"type":"null"}],"title":"Organizations"},"users":{"additionalProperties":{"$ref":"#/components/schemas/EntityRef"},"type":"object","title":"Users"},"include_workspaces":{"anyOf":[{"type":"string","const":"all"},{"items":{"type":"string"},"type":"array"}],"title":"Include Workspaces","default":"all"},"include_projects":{"anyOf":[{"type":"string","const":"all"},{"items":{"type":"string"},"type":"array"}],"title":"Include Projects","default":"all"},"reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Reason"},"recovery":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Recovery"}},"type":"object","required":["users"],"title":"AdminSimpleAccountsOrganizationsTransferOwnership"},"AdminSimpleAccountsOrganizationsTransferOwnershipResponse":{"properties":{"transferred":{"items":{"type":"string"},"type":"array","title":"Transferred","default":[]},"errors":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminStructuredError"},"type":"array"},{"type":"null"}],"title":"Errors"}},"type":"object","title":"AdminSimpleAccountsOrganizationsTransferOwnershipResponse"},"AdminSimpleAccountsProjectsCreate":{"properties":{"options":{"anyOf":[{"$ref":"#/components/schemas/AdminAccountCreateOptions"},{"type":"null"}]},"project":{"$ref":"#/components/schemas/AdminProjectCreate"}},"type":"object","required":["project"],"title":"AdminSimpleAccountsProjectsCreate"},"AdminSimpleAccountsProjectsMembershipsCreate":{"properties":{"options":{"anyOf":[{"$ref":"#/components/schemas/AdminAccountCreateOptions"},{"type":"null"}]},"membership":{"$ref":"#/components/schemas/AdminProjectMembershipCreate"}},"type":"object","required":["membership"],"title":"AdminSimpleAccountsProjectsMembershipsCreate"},"AdminSimpleAccountsResponse":{"properties":{"accounts":{"additionalProperties":{"$ref":"#/components/schemas/AdminSimpleAccountRead"},"type":"object","title":"Accounts","default":{}},"errors":{"anyOf":[{"items":{"$ref":"#/components/schemas/AdminStructuredError"},"type":"array"},{"type":"null"}],"title":"Errors"}},"type":"object","title":"AdminSimpleAccountsResponse"},"AdminSimpleAccountsUsersCreate":{"properties":{"options":{"anyOf":[{"$ref":"#/components/schemas/AdminAccountCreateOptions"},{"type":"null"}]},"user":{"$ref":"#/components/schemas/AdminUserCreate"}},"type":"object","required":["user"],"title":"AdminSimpleAccountsUsersCreate"},"AdminSimpleAccountsUsersIdentitiesCreate":{"properties":{"options":{"anyOf":[{"$ref":"#/components/schemas/AdminAccountCreateOptions"},{"type":"null"}]},"user_ref":{"$ref":"#/components/schemas/EntityRef"},"user_identity":{"$ref":"#/components/schemas/AdminUserIdentityCreate"}},"type":"object","required":["user_ref","user_identity"],"title":"AdminSimpleAccountsUsersIdentitiesCreate"},"AdminSimpleAccountsUsersResetPassword":{"properties":{"user_identities":{"items":{"$ref":"#/components/schemas/AdminUserIdentityCreate"},"type":"array","title":"User Identities"}},"type":"object","required":["user_identities"],"title":"AdminSimpleAccountsUsersResetPassword"},"AdminSimpleAccountsWorkspacesCreate":{"properties":{"options":{"anyOf":[{"$ref":"#/components/schemas/AdminAccountCreateOptions"},{"type":"null"}]},"workspace":{"$ref":"#/components/schemas/AdminWorkspaceCreate"}},"type":"object","required":["workspace"],"title":"AdminSimpleAccountsWorkspacesCreate"},"AdminSimpleAccountsWorkspacesMembershipsCreate":{"properties":{"options":{"anyOf":[{"$ref":"#/components/schemas/AdminAccountCreateOptions"},{"type":"null"}]},"membership":{"$ref":"#/components/schemas/AdminWorkspaceMembershipCreate"}},"type":"object","required":["membership"],"title":"AdminSimpleAccountsWorkspacesMembershipsCreate"},"AdminStructuredError":{"properties":{"code":{"type":"string","title":"Code"},"message":{"type":"string","title":"Message"},"details":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Details"}},"type":"object","required":["code","message"],"title":"AdminStructuredError"},"AdminSubscriptionCreate":{"properties":{"plan":{"type":"string","title":"Plan"}},"type":"object","required":["plan"],"title":"AdminSubscriptionCreate"},"AdminSubscriptionRead":{"properties":{"plan":{"type":"string","title":"Plan"},"active":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Active"}},"type":"object","required":["plan"],"title":"AdminSubscriptionRead"},"AdminUserCreate":{"properties":{"email":{"type":"string","title":"Email"},"username":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Username"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"is_admin":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Admin"},"is_root":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Root"},"metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metadata"}},"type":"object","required":["email"],"title":"AdminUserCreate"},"AdminUserIdentityCreate":{"properties":{"user_ref":{"anyOf":[{"$ref":"#/components/schemas/EntityRef"},{"type":"null"}]},"method":{"type":"string","title":"Method"},"subject":{"type":"string","title":"Subject"},"domain":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Domain"},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email"},"password":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Password"},"verified":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Verified"},"provider_user_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider User Id"},"claims":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Claims"}},"type":"object","required":["method","subject"],"title":"AdminUserIdentityCreate"},"AdminUserIdentityRead":{"properties":{"id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Id"},"user_id":{"type":"string","title":"User Id"},"method":{"type":"string","title":"Method"},"subject":{"type":"string","title":"Subject"},"domain":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Domain"},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email"},"status":{"type":"string","enum":["created","linked","pending_confirmation","skipped","failed"],"title":"Status","default":"created"},"verified":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Verified"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Updated At"}},"type":"object","required":["user_id","method","subject"],"title":"AdminUserIdentityRead"},"AdminUserRead":{"properties":{"id":{"type":"string","title":"Id"},"uid":{"type":"string","title":"Uid"},"email":{"type":"string","title":"Email"},"username":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Username"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"is_admin":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Admin"},"is_root":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Root"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Updated At"},"metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metadata"}},"type":"object","required":["id","uid","email"],"title":"AdminUserRead"},"AdminWorkspaceCreate":{"properties":{"name":{"type":"string","title":"Name"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"organization_ref":{"$ref":"#/components/schemas/EntityRef"},"metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metadata"}},"type":"object","required":["name","organization_ref"],"title":"AdminWorkspaceCreate"},"AdminWorkspaceMembershipCreate":{"properties":{"workspace_ref":{"$ref":"#/components/schemas/EntityRef"},"user_ref":{"$ref":"#/components/schemas/EntityRef"},"role":{"type":"string","title":"Role"}},"type":"object","required":["workspace_ref","user_ref","role"],"title":"AdminWorkspaceMembershipCreate"},"AdminWorkspaceMembershipRead":{"properties":{"id":{"type":"string","title":"Id"},"workspace_id":{"type":"string","title":"Workspace Id"},"user_id":{"type":"string","title":"User Id"},"role":{"type":"string","title":"Role"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Updated At"}},"type":"object","required":["id","workspace_id","user_id","role"],"title":"AdminWorkspaceMembershipRead"},"AdminWorkspaceRead":{"properties":{"id":{"type":"string","title":"Id"},"name":{"type":"string","title":"Name"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"organization_id":{"type":"string","title":"Organization Id"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Updated At"},"metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metadata"}},"type":"object","required":["id","name","organization_id"],"title":"AdminWorkspaceRead"},"AgentMountQueryRequest":{"properties":{"artifact_id":{"type":"string","title":"Artifact Id"},"name":{"type":"string","title":"Name","default":"default"}},"type":"object","required":["artifact_id"],"title":"AgentMountQueryRequest"},"AgentTemplateOverlay":{"properties":{"tools":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Tools","description":"Platform tool configs and `@ag.embed` tool references."},"skills":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Skills","description":"`@ag.embed` references to authoring skills."},"sandbox":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Sandbox","description":"Sandbox section overlay, e.g. `{permissions: {...}}`."}},"type":"object","title":"AgentTemplateOverlay","description":"A documented subset of the `parameters.agent` authoring shape.\n\nCarries the platform-owned tools, authoring skills, and sandbox elevation the playground\nlayers on top of the draft for the build kit. Entries are intentionally open (platform-op\nconfigs and `@ag.embed` references), so they are typed loosely: the full `parameters.agent`\nauthoring template has no shared Pydantic model today (it rides as free-form\n`data.parameters`), and the SDK's runtime `AgentTemplate` is the flattened parse with\ndifferent field names, so neither can be reused 1:1 to type this overlay."},"Analytics":{"properties":{"count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Count","default":0},"duration":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Duration","default":0.0},"costs":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Costs","default":0.0},"tokens":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Tokens","default":0.0}},"type":"object","title":"Analytics"},"AnalyticsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of time buckets returned.","default":0},"buckets":{"items":{"$ref":"#/components/schemas/MetricsBucket"},"type":"array","title":"Buckets","description":"Time-bucketed aggregates. Each bucket's `metrics` dict is keyed by the dotted `path` of the corresponding `MetricSpec`, ordered oldest to newest.","default":[]},"query":{"$ref":"#/components/schemas/TracingQuery","description":"The resolved query used to compute the buckets."},"specs":{"items":{"$ref":"#/components/schemas/MetricSpec"},"type":"array","title":"Specs","description":"The resolved metric specs applied in each bucket.","default":[]}},"type":"object","title":"AnalyticsResponse","description":"Analytics response with user-specified metric specs."},"Annotation":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"span_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Id"},"trace_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Trace Id"},"attributes":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Attributes"},"origin":{"$ref":"#/components/schemas/SimpleTraceOrigin","default":"custom"},"kind":{"$ref":"#/components/schemas/SimpleTraceKind","default":"adhoc"},"channel":{"$ref":"#/components/schemas/SimpleTraceChannel","default":"api"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"data":{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object","title":"Data"},"references":{"$ref":"#/components/schemas/SimpleTraceReferences"},"links":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/OTelLink-Output"},"type":"object"},{"items":{"$ref":"#/components/schemas/OTelLink-Output"},"type":"array"}],"title":"Links"}},"type":"object","required":["data","references","links"],"title":"Annotation"},"AnnotationCreate":{"properties":{"origin":{"$ref":"#/components/schemas/SimpleTraceOrigin","default":"custom"},"kind":{"$ref":"#/components/schemas/SimpleTraceKind","default":"adhoc"},"channel":{"$ref":"#/components/schemas/SimpleTraceChannel","default":"api"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"data":{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object","title":"Data"},"references":{"$ref":"#/components/schemas/SimpleTraceReferences"},"links":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"object"},{"items":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"array"}],"title":"Links"}},"type":"object","required":["data","references","links"],"title":"AnnotationCreate"},"AnnotationCreateRequest":{"properties":{"annotation":{"$ref":"#/components/schemas/AnnotationCreate"}},"type":"object","required":["annotation"],"title":"AnnotationCreateRequest"},"AnnotationEdit":{"properties":{"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"data":{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object","title":"Data"},"references":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceReferences"},{"type":"null"}]},"links":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"object"},{"items":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"array"},{"type":"null"}],"title":"Links"}},"type":"object","required":["data"],"title":"AnnotationEdit"},"AnnotationEditRequest":{"properties":{"annotation":{"$ref":"#/components/schemas/AnnotationEdit"}},"type":"object","required":["annotation"],"title":"AnnotationEditRequest"},"AnnotationLinkResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"annotation_link":{"anyOf":[{"$ref":"#/components/schemas/OTelLink-Output"},{"type":"null"}]}},"type":"object","title":"AnnotationLinkResponse"},"AnnotationQuery":{"properties":{"origin":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceOrigin"},{"type":"null"}]},"kind":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceKind"},{"type":"null"}]},"channel":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceChannel"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"references":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceReferences"},{"type":"null"}]},"links":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"object"},{"items":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"array"},{"type":"null"}],"title":"Links"}},"type":"object","title":"AnnotationQuery"},"AnnotationQueryRequest":{"properties":{"annotation":{"anyOf":[{"$ref":"#/components/schemas/AnnotationQuery"},{"type":"null"}]},"annotation_links":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"array"},{"type":"null"}],"title":"Annotation Links"},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"AnnotationQueryRequest"},"AnnotationResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"annotation":{"anyOf":[{"$ref":"#/components/schemas/Annotation"},{"type":"null"}]}},"type":"object","title":"AnnotationResponse"},"AnnotationsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"annotations":{"items":{"$ref":"#/components/schemas/Annotation"},"type":"array","title":"Annotations","default":[]}},"type":"object","title":"AnnotationsResponse"},"Application":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/ApplicationArtifactFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"Application"},"ApplicationArtifactFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false}},"type":"object","title":"ApplicationArtifactFlags","description":"Application flags - is_application=True; other booleans use their normal defaults unless explicitly set."},"ApplicationArtifactQueryFlags":{"properties":{"is_application":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Application"},"is_evaluator":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Evaluator"},"is_snippet":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Snippet"}},"type":"object","title":"ApplicationArtifactQueryFlags","description":"Application query flags - filter for is_application=True."},"ApplicationCatalogPreset":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"key":{"type":"string","title":"Key"},"categories":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Categories"},"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowCatalogFlags"},{"type":"null"}]},"data":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevisionData-Output"},{"type":"null"}]}},"type":"object","required":["key"],"title":"ApplicationCatalogPreset"},"ApplicationCatalogPresetResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when found, `0` otherwise.","default":0},"preset":{"anyOf":[{"$ref":"#/components/schemas/ApplicationCatalogPreset"},{"type":"null"}],"description":"Catalog preset definition."}},"type":"object","title":"ApplicationCatalogPresetResponse","description":"Single preset response envelope."},"ApplicationCatalogPresetsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of presets returned.","default":0},"presets":{"items":{"$ref":"#/components/schemas/ApplicationCatalogPreset"},"type":"array","title":"Presets","description":"Named parameter sets for the template. Use a preset's `data` as the first revision when creating an application from a template."}},"type":"object","title":"ApplicationCatalogPresetsResponse","description":"List of catalog presets scoped to one template."},"ApplicationCatalogTemplate":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"key":{"type":"string","title":"Key"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"categories":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Categories"},"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowCatalogFlags"},{"type":"null"}]},"data":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevisionData-Output"},{"type":"null"}]}},"type":"object","required":["key"],"title":"ApplicationCatalogTemplate"},"ApplicationCatalogTemplateResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when found, `0` otherwise.","default":0},"template":{"anyOf":[{"$ref":"#/components/schemas/ApplicationCatalogTemplate"},{"type":"null"}],"description":"Catalog template definition."}},"type":"object","title":"ApplicationCatalogTemplateResponse","description":"Single template response envelope."},"ApplicationCatalogTemplatesResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of templates returned.","default":0},"templates":{"items":{"$ref":"#/components/schemas/ApplicationCatalogTemplate"},"type":"array","title":"Templates","description":"Built-in and custom templates an application can be created from. Each template carries a `key`, a `uri`, and the JSON Schemas that applications of that type expose."}},"type":"object","title":"ApplicationCatalogTemplatesResponse","description":"List of catalog templates."},"ApplicationCatalogType":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"key":{"type":"string","title":"Key"},"json_schema":{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object","title":"Json Schema"}},"type":"object","required":["key","json_schema"],"title":"ApplicationCatalogType"},"ApplicationCatalogTypesResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of types returned.","default":0},"types":{"items":{"$ref":"#/components/schemas/ApplicationCatalogType"},"type":"array","title":"Types","description":"Shared JSON Schema building blocks referenced by templates (for example `message`, `prompt-template`)."}},"type":"object","title":"ApplicationCatalogTypesResponse","description":"List of catalog types."},"ApplicationCreate":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/ApplicationFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"ApplicationCreate"},"ApplicationCreateRequest":{"properties":{"application":{"$ref":"#/components/schemas/ApplicationCreate","description":"Artifact-level fields for the new application: `slug`, `name`, `description`, `flags`, `tags`, `meta`. The `slug` must be unique within the project."}},"type":"object","required":["application"],"title":"ApplicationCreateRequest","description":"Request body for creating an application artifact.\n\nApplications are versioned resources; creating one produces an empty artifact.\nUse `POST /simple/applications/` if you want to create the artifact, a default\nvariant, and a first committed revision in a single call.\nSee the [Applications guide](/reference/api-guide/applications)."},"ApplicationEdit":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/ApplicationFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"ApplicationEdit"},"ApplicationEditRequest":{"properties":{"application":{"$ref":"#/components/schemas/ApplicationEdit","description":"Artifact fields to update. The `id` must match the `application_id` in the URL path."}},"type":"object","required":["application"],"title":"ApplicationEditRequest","description":"Request body for editing an application artifact.\n\nOnly artifact-level fields (flags, tags, meta) can be edited here. Editing\nthe `name` is currently disabled. To change the prompt or model parameters,\ncommit a new revision on a variant with `/applications/revisions/commit`."},"ApplicationFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false},"is_managed":{"type":"boolean","title":"Is Managed","default":false},"is_custom":{"type":"boolean","title":"Is Custom","default":false},"is_llm":{"type":"boolean","title":"Is Llm","default":false},"is_hook":{"type":"boolean","title":"Is Hook","default":false},"is_code":{"type":"boolean","title":"Is Code","default":false},"is_match":{"type":"boolean","title":"Is Match","default":false},"is_feedback":{"type":"boolean","title":"Is Feedback","default":false},"is_agent":{"type":"boolean","title":"Is Agent","default":false},"is_skill":{"type":"boolean","title":"Is Skill","default":false},"is_chat":{"type":"boolean","title":"Is Chat","default":false},"has_url":{"type":"boolean","title":"Has Url","default":false},"has_script":{"type":"boolean","title":"Has Script","default":false},"has_handler":{"type":"boolean","title":"Has Handler","default":false},"is_static":{"type":"boolean","title":"Is Static","default":false}},"type":"object","title":"ApplicationFlags","description":"Legacy full application flag set."},"ApplicationQuery":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/ApplicationArtifactQueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"slugs":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Slugs"}},"type":"object","title":"ApplicationQuery"},"ApplicationQueryRequest":{"properties":{"application":{"anyOf":[{"$ref":"#/components/schemas/ApplicationQuery"},{"type":"null"}],"description":"Attribute filter. Accepts `slug`, `slugs`, `flags`, `tags`, `meta`. All fields are AND-ed."},"application_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Application Refs","description":"Restrict the query to specific applications by `id` or `slug`. Combined with the `application` filter with AND semantics."},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived","description":"When `true`, include soft-deleted applications. Defaults to `false`."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor pagination and time-range controls."}},"type":"object","title":"ApplicationQueryRequest","description":"Request body for `POST /applications/query`.\n\nReturns artifact rows only. For rows that include the currently resolved\nvariant, revision, and `data` payload merged in, use\n`POST /simple/applications/query`.\nSee [Query Pattern](/reference/api-guide/query-pattern)."},"ApplicationResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when the application was found, `0` otherwise.","default":0},"application":{"anyOf":[{"$ref":"#/components/schemas/Application"},{"type":"null"}],"description":"The application artifact, or `null` if not found."}},"type":"object","title":"ApplicationResponse","description":"Single-application response envelope."},"ApplicationRevision-Input":{"properties":{"application_variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Variant Slug"},"workflow_variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Variant Slug"},"application_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Slug"},"workflow_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Slug"},"application_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Variant Id"},"workflow_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"},"application_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Variant Slug"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"author":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Author"},"date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Date"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"flags":{"anyOf":[{"$ref":"#/components/schemas/ApplicationRevisionFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/ApplicationRevisionData-Input"},{"type":"null"}]}},"type":"object","title":"ApplicationRevision"},"ApplicationRevision-Output":{"properties":{"application_variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Variant Slug"},"workflow_variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Variant Slug"},"application_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Slug"},"workflow_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Slug"},"application_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Variant Id"},"workflow_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"},"application_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Variant Slug"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"author":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Author"},"date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Date"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"flags":{"anyOf":[{"$ref":"#/components/schemas/ApplicationRevisionFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/ApplicationRevisionData-Output"},{"type":"null"}]}},"type":"object","title":"ApplicationRevision"},"ApplicationRevisionCommit":{"properties":{"application_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Variant Id"},"workflow_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"},"application_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/ApplicationFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"data":{"anyOf":[{"$ref":"#/components/schemas/ApplicationRevisionData-Input"},{"type":"null"}]},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"},"delta":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevisionDelta"},{"type":"null"}]},"base_revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Base Revision Id","description":"The revision this change was built on. Omit it to keep today's last-write-wins behavior. Send it on a legacy delta and the commit is refused with `409` when the variant's head has moved since: sending the field is how a caller asks for that check. An ordered delta requires it."}},"type":"object","title":"ApplicationRevisionCommit"},"ApplicationRevisionCommitRequest":{"properties":{"application_revision":{"$ref":"#/components/schemas/ApplicationRevisionCommit","description":"Commit payload. Must include `application_variant_id` and `data`. `message` is a human-readable commit message. `slug` is optional; if omitted, the server generates one."}},"type":"object","required":["application_revision"],"title":"ApplicationRevisionCommitRequest","description":"Request body for committing a new revision on a variant.\n\nThe commit becomes the variant's new tip. Revisions are immutable once\ncommitted; to change behavior, commit another revision.\nSee [Versioning](/reference/api-guide/versioning#committing-a-revision)."},"ApplicationRevisionCreate":{"properties":{"application_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Variant Id"},"workflow_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"},"application_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/ApplicationFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"ApplicationRevisionCreate"},"ApplicationRevisionCreateRequest":{"properties":{"application_revision":{"$ref":"#/components/schemas/ApplicationRevisionCreate","description":"Revision fields. Must reference the parent variant."}},"type":"object","required":["application_revision"],"title":"ApplicationRevisionCreateRequest","description":"Request body for creating a revision row without committing it.\n\nPrefer `POST /applications/revisions/commit` for normal use — commit creates\na revision and advances the variant's tip. The plain create endpoint exists\nfor advanced workflows that populate revision rows out of band."},"ApplicationRevisionData-Input":{"properties":{"uri":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Uri"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"headers":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/Reference"}]},"type":"object"},{"type":"null"}],"title":"Headers"},"runtime":{"anyOf":[{"type":"string","enum":["python","typescript","javascript"]},{"type":"null"}],"title":"Runtime"},"script":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Script"},"schemas":{"anyOf":[{"$ref":"#/components/schemas/JsonSchemas-Input"},{"type":"null"}]},"parameters":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Parameters"}},"additionalProperties":false,"type":"object","title":"ApplicationRevisionData"},"ApplicationRevisionData-Output":{"properties":{"uri":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Uri"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"headers":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/Reference"}]},"type":"object"},{"type":"null"}],"title":"Headers"},"runtime":{"anyOf":[{"type":"string","enum":["python","typescript","javascript"]},{"type":"null"}],"title":"Runtime"},"script":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Script"},"schemas":{"anyOf":[{"$ref":"#/components/schemas/JsonSchemas-Output"},{"type":"null"}]},"parameters":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Parameters"}},"additionalProperties":false,"type":"object","title":"ApplicationRevisionData"},"ApplicationRevisionDeployRequest":{"properties":{"application_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Application reference. If provided, the latest revision of the default variant is deployed."},"application_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Variant reference. Its latest revision is deployed."},"application_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Revision reference. The exact revision is deployed."},"environment_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Target environment (for example `{\"slug\": \"production\"}`)."},"environment_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Target environment variant."},"environment_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Target environment revision; advanced use only."},"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key","description":"Deployment key inside the environment revision. Defaults to `{application_slug}.revision`."},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message","description":"Optional commit message attached to the environment revision."}},"type":"object","title":"ApplicationRevisionDeployRequest","description":"Request body for `POST /applications/revisions/deploy`.\n\nAttaches an application revision to an environment under a key. Subsequent\ncalls to `/applications/revisions/retrieve` with the matching\n`environment_ref` resolve to this revision.\nSee the [Applications guide](/reference/api-guide/applications#deployment)."},"ApplicationRevisionEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/ApplicationFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"ApplicationRevisionEdit"},"ApplicationRevisionEditRequest":{"properties":{"application_revision":{"$ref":"#/components/schemas/ApplicationRevisionEdit","description":"Full revision body. Edit replaces the editable fields in a single PUT, so include every editable field even if its value is unchanged. `id` must match the `application_revision_id` in the URL path. `data`, `author`, `date`, and `message` are immutable."}},"type":"object","required":["application_revision"],"title":"ApplicationRevisionEditRequest","description":"Request body for editing a revision's header fields.\n\nRevisions are immutable snapshots of the application's configuration;\n`data`, `author`, `date`, and `message` cannot be edited. Use this only to\ncorrect metadata such as `description` or `tags`."},"ApplicationRevisionFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false},"is_managed":{"type":"boolean","title":"Is Managed","default":false},"is_custom":{"type":"boolean","title":"Is Custom","default":false},"is_llm":{"type":"boolean","title":"Is Llm","default":false},"is_hook":{"type":"boolean","title":"Is Hook","default":false},"is_code":{"type":"boolean","title":"Is Code","default":false},"is_match":{"type":"boolean","title":"Is Match","default":false},"is_feedback":{"type":"boolean","title":"Is Feedback","default":false},"is_agent":{"type":"boolean","title":"Is Agent","default":false},"is_skill":{"type":"boolean","title":"Is Skill","default":false},"is_chat":{"type":"boolean","title":"Is Chat","default":false},"has_url":{"type":"boolean","title":"Has Url","default":false},"has_script":{"type":"boolean","title":"Has Script","default":false},"has_handler":{"type":"boolean","title":"Has Handler","default":false},"is_static":{"type":"boolean","title":"Is Static","default":false}},"type":"object","title":"ApplicationRevisionFlags"},"ApplicationRevisionQuery":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/ApplicationRevisionQueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"slugs":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Slugs"},"author":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Author"},"authors":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Authors"},"date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Date"},"dates":{"anyOf":[{"items":{"type":"string","format":"date-time"},"type":"array"},{"type":"null"}],"title":"Dates"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"}},"type":"object","title":"ApplicationRevisionQuery"},"ApplicationRevisionQueryFlags":{"properties":{"is_application":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Application"},"is_evaluator":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Evaluator"},"is_snippet":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Snippet"},"is_managed":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Managed"},"is_custom":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Custom"},"is_llm":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Llm"},"is_hook":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Hook"},"is_code":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Code"},"is_match":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Match"},"is_feedback":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Feedback"},"is_agent":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Agent"},"is_skill":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Skill"},"is_chat":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Chat"},"has_url":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Url"},"has_script":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Script"},"has_handler":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Handler"},"is_static":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Static"}},"type":"object","title":"ApplicationRevisionQueryFlags"},"ApplicationRevisionQueryRequest":{"properties":{"application_revision":{"anyOf":[{"$ref":"#/components/schemas/ApplicationRevisionQuery"},{"type":"null"}],"description":"Attribute filter. Includes standard fields (`slug`, `slugs`, `flags`) plus revision-specific ones (`author`, `authors`, `date`, `dates`, `message`)."},"application_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Application Refs","description":"Scope to revisions belonging to these applications."},"application_variant_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Application Variant Refs","description":"Scope to revisions belonging to these variants."},"application_revision_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Application Revision Refs","description":"Restrict to specific revisions by `id` or by `slug` + `version`."},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived","description":"When `true`, include archived revisions. Defaults to `false`."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor pagination and time-range controls."}},"type":"object","title":"ApplicationRevisionQueryRequest","description":"Request body for `POST /applications/revisions/query`.\n\nReturns committed revisions across one or more variants. For the ordered\nlog of a single variant, use `POST /applications/revisions/log`."},"ApplicationRevisionResolveRequest":{"properties":{"application_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Application reference."},"application_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Variant reference; resolves the latest revision on it."},"application_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Revision reference; resolves that exact revision."},"application_revision":{"anyOf":[{"$ref":"#/components/schemas/ApplicationRevision-Input"},{"type":"null"}],"description":"Resolve the references embedded in this revision payload directly, without fetching it first. Only `data` is used; id and metadata are ignored."},"max_depth":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Depth","description":"Maximum nesting depth for embedded references. Protects against runaway recursion. Defaults to `10`.","default":10},"max_embeds":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Embeds","description":"Maximum total number of embedded references to follow. Defaults to `100`.","default":100},"error_policy":{"anyOf":[{"$ref":"#/components/schemas/ErrorPolicy"},{"type":"null"}],"description":"How to handle resolution errors. `exception` (default) aborts; `placeholder` substitutes a marker; `keep` leaves the original reference untouched.","default":"exception"}},"type":"object","title":"ApplicationRevisionResolveRequest","description":"Request body for `POST /applications/revisions/resolve`.\n\nFetches a revision and resolves any embedded references (snippets, linked\nrevisions) inside its `data`. Use when clients need the fully-inlined\nconfiguration instead of the raw stored form."},"ApplicationRevisionResolveResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when a revision was resolved, `0` otherwise.","default":0},"application_revision":{"anyOf":[{"$ref":"#/components/schemas/ApplicationRevision-Output"},{"type":"null"}],"description":"The revision with embedded references inlined into `data`."},"resolution_info":{"anyOf":[{"$ref":"#/components/schemas/ResolutionInfo"},{"type":"null"}],"description":"Diagnostic info about which references were resolved."},"retrieval_info":{"anyOf":[{"$ref":"#/components/schemas/RetrievalInfo"},{"type":"null"}],"description":"References (artifact / variant / revision) actually used to retrieve this revision."}},"type":"object","title":"ApplicationRevisionResolveResponse","description":"Response for `POST /applications/revisions/resolve`."},"ApplicationRevisionResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when a revision was found, `0` otherwise.","default":0},"application_revision":{"anyOf":[{"$ref":"#/components/schemas/ApplicationRevision-Output"},{"type":"null"}],"description":"The application revision, including its `data` payload (prompt, model parameters, schemas, URL)."},"resolution_info":{"anyOf":[{"$ref":"#/components/schemas/ResolutionInfo"},{"type":"null"}],"description":"Present only when the request set `resolve: true`. Describes which embedded references were resolved and any errors that occurred."},"retrieval_info":{"anyOf":[{"$ref":"#/components/schemas/RetrievalInfo"},{"type":"null"}],"description":"References used to retrieve the top-level revision."}},"type":"object","title":"ApplicationRevisionResponse","description":"Single-revision response envelope."},"ApplicationRevisionRetrieveRequest":{"properties":{"application_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Application artifact to look up. Identifies the artifact by `id` or `slug` (both project-unique). When no variant_ref or revision_ref is provided, returns the latest revision of the application's default variant."},"application_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Application variant to look up. Identifies the variant by `id` or `slug` (both project-unique). When no revision_ref is provided, returns the latest revision of this variant."},"application_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Application revision to look up. `id` alone identifies a revision (project-unique). `slug` alone identifies a revision (project-unique). `version` alone is a per-variant sequence number and is **not** sufficient on its own; it must be combined with an `application_variant_ref`. Sending only `version` without a variant ref returns HTTP 400."},"environment_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Environment reference. Returns the revision currently deployed to that environment under the given `key`."},"environment_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Environment variant reference; used together with `environment_ref`."},"environment_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Environment revision reference; used to pin to a specific environment commit instead of the current tip."},"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key","description":"Deployment key inside the environment revision. When omitted and `application_ref` is supplied, the server derives it as `{application_slug}.revision`."},"resolve":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Resolve","description":"When `true`, resolve embedded references in the returned revision's `data` (for example, snippet references)."}},"type":"object","title":"ApplicationRevisionRetrieveRequest","description":"Request body for `POST /applications/revisions/retrieve`.\n\nResolves to a single revision by one or more reference types. Every\nreference supplied must agree with the resolved revision; contradictions\nreturn HTTP 400. See the [Applications guide](/reference/api-guide/applications#invocation)."},"ApplicationRevisionsLog":{"properties":{"application_revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Revision Id"},"workflow_revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Revision Id"},"application_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Variant Id"},"workflow_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"},"application_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"depth":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Depth"}},"type":"object","title":"ApplicationRevisionsLog"},"ApplicationRevisionsLogRequest":{"properties":{"application_revisions":{"$ref":"#/components/schemas/ApplicationRevisionsLog","description":"Filter for the log. Typically set `application_variant_id` to list the revision history of a single variant; optionally set `application_revision_id` + `depth` to walk back a bounded number of commits from a specific revision."}},"type":"object","required":["application_revisions"],"title":"ApplicationRevisionsLogRequest","description":"Request body for `POST /applications/revisions/log`.\n\nReturns the ordered list of revisions committed to a variant, newest first.\nEach entry carries commit metadata and the full revision record."},"ApplicationRevisionsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of revisions in this page.","default":0},"application_revisions":{"items":{"$ref":"#/components/schemas/ApplicationRevision-Output"},"type":"array","title":"Application Revisions","description":"Application revisions matching the query or log."}},"type":"object","title":"ApplicationRevisionsResponse","description":"Paginated list of application revisions."},"ApplicationVariant":{"properties":{"application_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Slug"},"workflow_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Slug"},"application_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/ApplicationVariantFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"ApplicationVariant"},"ApplicationVariantCreate":{"properties":{"application_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Application Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/ApplicationFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"ApplicationVariantCreate"},"ApplicationVariantCreateRequest":{"properties":{"application_variant":{"$ref":"#/components/schemas/ApplicationVariantCreate","description":"Variant fields. Must include `application_id` (the artifact the variant belongs to) and a `slug` unique within the project."}},"type":"object","required":["application_variant"],"title":"ApplicationVariantCreateRequest","description":"Request body for creating a variant on an existing application."},"ApplicationVariantEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/ApplicationFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"ApplicationVariantEdit"},"ApplicationVariantEditRequest":{"properties":{"application_variant":{"$ref":"#/components/schemas/ApplicationVariantEdit","description":"Full variant body. Edit replaces the artifact-level fields in a single PUT, so include every editable field even if its value is unchanged. `id` must match the `application_variant_id` in the URL path; `slug` is immutable. Configuration changes (prompt, model parameters) go through `/applications/revisions/commit`, not this endpoint."}},"type":"object","required":["application_variant"],"title":"ApplicationVariantEditRequest","description":"Request body for editing a variant's artifact-level fields."},"ApplicationVariantFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false}},"type":"object","title":"ApplicationVariantFlags"},"ApplicationVariantFork":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/ApplicationFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"ApplicationVariantFork"},"ApplicationVariantForkRequest":{"properties":{"application_variant":{"$ref":"#/components/schemas/ApplicationVariantFork","description":"Config for the new variant (slug, name, description, flags)."},"application_variant_ref":{"$ref":"#/components/schemas/Reference","description":"Source variant to fork from."},"application_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Pin the fork to this revision; defaults to the source variant's head."}},"type":"object","required":["application_variant","application_variant_ref"],"title":"ApplicationVariantForkRequest"},"ApplicationVariantResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when a variant was found, `0` otherwise.","default":0},"application_variant":{"anyOf":[{"$ref":"#/components/schemas/ApplicationVariant"},{"type":"null"}],"description":"The application variant, or `null`."}},"type":"object","title":"ApplicationVariantResponse","description":"Single-variant response envelope."},"ApplicationVariantsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of variants in this page.","default":0},"application_variants":{"items":{"$ref":"#/components/schemas/ApplicationVariant"},"type":"array","title":"Application Variants","description":"Application variants matching the query."}},"type":"object","title":"ApplicationVariantsResponse","description":"Paginated list of application variants."},"ApplicationsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of applications in this page.","default":0},"applications":{"items":{"$ref":"#/components/schemas/Application"},"type":"array","title":"Applications","description":"Application artifacts matching the query."}},"type":"object","title":"ApplicationsResponse","description":"Paginated list of application artifacts."},"ArchiveMount":{"properties":{"mount_id":{"type":"string","format":"uuid","title":"Mount Id"},"prefix":{"type":"string","title":"Prefix","default":""},"path":{"type":"string","title":"Path","default":""}},"type":"object","required":["mount_id"],"title":"ArchiveMount","description":"One mount to include in an archive. `path` scopes it to a folder within the mount (\"\" = the\nwhole mount); `prefix` places its files under `prefix/` in the zip (the folded drive layout)."},"Body_configs_fetch_variants_configs_fetch_post":{"properties":{"variant_ref":{"anyOf":[{"$ref":"#/components/schemas/ReferenceRequestModel-Input"},{"type":"null"}]},"environment_ref":{"anyOf":[{"$ref":"#/components/schemas/ReferenceRequestModel-Input"},{"type":"null"}]},"application_ref":{"anyOf":[{"$ref":"#/components/schemas/ReferenceRequestModel-Input"},{"type":"null"}]}},"type":"object","title":"Body_configs_fetch_variants_configs_fetch_post"},"Body_create_simple_testset_from_file":{"properties":{"file":{"type":"string","contentMediaType":"application/octet-stream","title":"File"},"file_type":{"type":"string","enum":["csv","json"],"title":"File Type","default":"csv"},"testset_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Testset Slug"},"testset_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Testset Name"},"testset_description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Testset Description"},"testset_tags":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Testset Tags"},"testset_meta":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Testset Meta"}},"type":"object","required":["file"],"title":"Body_create_simple_testset_from_file"},"Body_create_testset_revision_from_file":{"properties":{"file":{"type":"string","contentMediaType":"application/octet-stream","title":"File"},"file_type":{"type":"string","enum":["csv","json"],"title":"File Type","default":"csv"},"include_testcases":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Testcases"}},"type":"object","required":["file"],"title":"Body_create_testset_revision_from_file"},"Body_edit_simple_testset_from_file":{"properties":{"file":{"type":"string","contentMediaType":"application/octet-stream","title":"File"},"file_type":{"type":"string","enum":["csv","json"],"title":"File Type","default":"csv"},"testset_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Testset Name"},"testset_description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Testset Description"},"testset_tags":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Testset Tags"},"testset_meta":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Testset Meta"}},"type":"object","required":["file"],"title":"Body_edit_simple_testset_from_file"},"Body_upload_mount_file":{"properties":{"file":{"type":"string","contentMediaType":"application/octet-stream","title":"File"}},"type":"object","required":["file"],"title":"Body_upload_mount_file"},"Body_upload_session_mount_file":{"properties":{"file":{"type":"string","contentMediaType":"application/octet-stream","title":"File"}},"type":"object","required":["file"],"title":"Body_upload_session_mount_file"},"Bucket":{"properties":{"timestamp":{"type":"string","format":"date-time","title":"Timestamp"},"interval":{"type":"integer","title":"Interval"},"total":{"$ref":"#/components/schemas/Analytics"},"errors":{"$ref":"#/components/schemas/Analytics"}},"type":"object","required":["timestamp","interval","total","errors"],"title":"Bucket"},"BuiltinToolConfig":{"properties":{"render":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Render"},"permission":{"anyOf":[{"type":"string","enum":["allow","ask","deny"]},{"type":"null"}],"title":"Permission"},"type":{"type":"string","const":"builtin","title":"Type","default":"builtin"},"name":{"type":"string","minLength":1,"title":"Name"}},"additionalProperties":false,"type":"object","required":["name"],"title":"BuiltinToolConfig","description":"Legacy entry, accepted so revisions written before the rework still parse.\n\nBuilt-in tools are always active and are no longer configured here; the resolver drops\nevery entry with a warning. Keep this arm until the dual-read window closes."},"CapabilitiesQuery":{"properties":{"use_cases":{"items":{"type":"string"},"type":"array","title":"Use Cases"},"provider":{"type":"string","title":"Provider","default":"composio"},"limit_alternatives":{"type":"integer","minimum":0.0,"title":"Limit Alternatives","default":3}},"type":"object","required":["use_cases"],"title":"CapabilitiesQuery","description":"Request body for ``POST /tools/discover``.\n\nThe response is the core ``CapabilitiesResult`` (see\n``docs/design/agent-workflows/projects/tool-discovery/design.md``). Project scope\ncomes from the caller's auth, not the body."},"CapabilitiesResult":{"properties":{"capabilities":{"items":{"$ref":"#/components/schemas/Capability"},"type":"array","title":"Capabilities"},"connections":{"items":{"$ref":"#/components/schemas/ConnectionRequirement"},"type":"array","title":"Connections"},"guidance":{"$ref":"#/components/schemas/CapabilityGuidance"},"ready":{"type":"boolean","title":"Ready","default":false},"notes":{"items":{"type":"string"},"type":"array","title":"Notes"}},"type":"object","title":"CapabilitiesResult","description":"The ``discover_tools`` response (Agenta-native)."},"Capability":{"properties":{"use_case":{"type":"string","title":"Use Case"},"integration":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Integration"},"tool":{"anyOf":[{"$ref":"#/components/schemas/DiscoveredTool"},{"type":"null"}]},"alternatives":{"items":{"$ref":"#/components/schemas/DiscoveredAlternative"},"type":"array","title":"Alternatives"},"connection":{"anyOf":[{"$ref":"#/components/schemas/CapabilityConnection"},{"type":"null"}]},"difficulty":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Difficulty"},"note":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Note"}},"type":"object","required":["use_case"],"title":"Capability","description":"One use_case resolved to a best-match tool, alternatives, and its state."},"CapabilityConnection":{"properties":{"state":{"$ref":"#/components/schemas/ToolConnectionState"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","required":["state"],"title":"CapabilityConnection","description":"The connection state for a capability's primary integration."},"CapabilityGuidance":{"properties":{"plan_steps":{"items":{"type":"string"},"type":"array","title":"Plan Steps"},"pitfalls":{"items":{"type":"string"},"type":"array","title":"Pitfalls"}},"type":"object","title":"CapabilityGuidance","description":"Structured operating knowledge the setup agent composes into ``agents_md``.\n\nComposio slugs in the text are mapped to the same ``integration.action`` names\nused elsewhere, so nothing Composio leaks."},"CollectStatusResponse":{"properties":{"status":{"type":"string","title":"Status","description":"Readiness string. `ready` means the router is mounted and accepts OTLP ingest."}},"type":"object","required":["status"],"title":"CollectStatusResponse","description":"OTLP endpoint readiness response."},"CommandMode":{"type":"string","enum":["send","steer","cancel","attach"],"title":"CommandMode","description":"Derived from the inputs/data × force matrix."},"CommitWarning":{"properties":{"code":{"type":"string","title":"Code"},"message":{"type":"string","title":"Message"},"target":{"anyOf":[{"items":{},"type":"array"},{"type":"null"}],"title":"Target"},"operation_index":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Operation Index"}},"type":"object","required":["code","message"],"title":"CommitWarning","description":"One thing the caller should know about a commit that still succeeded.\n\nThe codes are the engine's (change-set.md 7.1) plus `no_change`, which the commit\nwrapper owns. `target` carries contract-shaped segments, so a caller can act on the\nwarning without parsing its prose."},"ComparisonOperator":{"type":"string","enum":["is","is_not"],"title":"ComparisonOperator"},"Condition":{"properties":{"field":{"type":"string","title":"Field"},"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key"},"value":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"number"},{"type":"boolean"},{"items":{},"type":"array"},{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Value"},"operator":{"anyOf":[{"$ref":"#/components/schemas/ComparisonOperator"},{"$ref":"#/components/schemas/NumericOperator"},{"$ref":"#/components/schemas/StringOperator"},{"$ref":"#/components/schemas/ListOperator"},{"$ref":"#/components/schemas/DictOperator"},{"$ref":"#/components/schemas/ExistenceOperator"},{"type":"null"}],"title":"Operator","default":"is"},"options":{"anyOf":[{"$ref":"#/components/schemas/TextOptions"},{"$ref":"#/components/schemas/ListOptions"},{"type":"null"}],"title":"Options"}},"type":"object","required":["field"],"title":"Condition"},"ConfigResponseModel":{"properties":{"params":{"additionalProperties":true,"type":"object","title":"Params"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"application_ref":{"anyOf":[{"$ref":"#/components/schemas/ReferenceRequestModel-Output"},{"type":"null"}]},"service_ref":{"anyOf":[{"$ref":"#/components/schemas/ReferenceRequestModel-Output"},{"type":"null"}]},"variant_ref":{"anyOf":[{"$ref":"#/components/schemas/ReferenceRequestModel-Output"},{"type":"null"}]},"environment_ref":{"anyOf":[{"$ref":"#/components/schemas/ReferenceRequestModel-Output"},{"type":"null"}]},"application_lifecycle":{"anyOf":[{"$ref":"#/components/schemas/LegacyLifecycleDTO"},{"type":"null"}]},"service_lifecycle":{"anyOf":[{"$ref":"#/components/schemas/LegacyLifecycleDTO"},{"type":"null"}]},"variant_lifecycle":{"anyOf":[{"$ref":"#/components/schemas/LegacyLifecycleDTO"},{"type":"null"}]},"environment_lifecycle":{"anyOf":[{"$ref":"#/components/schemas/LegacyLifecycleDTO"},{"type":"null"}]}},"type":"object","title":"ConfigResponseModel"},"ConnectAffordance":{"properties":{"endpoint":{"type":"string","title":"Endpoint","default":"POST /tools/connections/"},"body":{"additionalProperties":true,"type":"object","title":"Body"}},"type":"object","required":["body"],"title":"ConnectAffordance","description":"The Agenta create-connection call to run when a connection is missing.\n\nSpeaks Agenta, not Composio: it points at ``POST /tools/connections/`` (which\nreturns a ``redirect_url``), never at ``COMPOSIO_MANAGE_CONNECTIONS``."},"ConnectionRequirement":{"properties":{"integration":{"type":"string","title":"Integration"},"state":{"$ref":"#/components/schemas/ToolConnectionState"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"connect":{"anyOf":[{"$ref":"#/components/schemas/ConnectAffordance"},{"type":"null"}]}},"type":"object","required":["integration","state"],"title":"ConnectionRequirement","description":"One integration's connection state, deduped across the result."},"CreateOrganizationPayload":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},"type":"object","title":"CreateOrganizationPayload"},"CreateProjectRequest":{"properties":{"name":{"type":"string","title":"Name"},"make_default":{"type":"boolean","title":"Make Default","default":false}},"type":"object","required":["name"],"title":"CreateProjectRequest"},"CreateSecretDTO":{"properties":{"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"header":{"$ref":"#/components/schemas/Header"},"secret":{"$ref":"#/components/schemas/SecretDTO"},"write_only":{"type":"boolean","title":"Write Only","default":true}},"additionalProperties":false,"type":"object","required":["header","secret"],"title":"CreateSecretDTO"},"CreateWorkspace":{"properties":{"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Type"}},"type":"object","required":["name"],"title":"CreateWorkspace"},"CredentialResult":{"properties":{"status":{"$ref":"#/components/schemas/CredentialStatus"},"message":{"type":"string","title":"Message"}},"type":"object","required":["status","message"],"title":"CredentialResult"},"CredentialStatus":{"type":"string","enum":["valid","invalid","unknown"],"title":"CredentialStatus","description":"Did the provider accept this credential?\n\n`unknown` is an honest answer, not a failure: it means Agenta found no free,\nread-only endpoint that proves the credential works. A public catalog endpoint\nanswering successfully never raises the status above `unknown`."},"CustomModelSettingsDTO":{"properties":{"slug":{"type":"string","title":"Slug"},"extras":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Extras"}},"type":"object","required":["slug"],"title":"CustomModelSettingsDTO"},"CustomProviderDTO":{"properties":{"kind":{"$ref":"#/components/schemas/CustomProviderKind"},"provider":{"$ref":"#/components/schemas/CustomProviderSettingsDTO"},"models":{"items":{"$ref":"#/components/schemas/CustomModelSettingsDTO"},"type":"array","title":"Models"},"harnesses":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Harnesses"},"provider_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider Slug"},"model_keys":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Model Keys"}},"type":"object","required":["kind","provider","models"],"title":"CustomProviderDTO"},"CustomProviderKind":{"type":"string","enum":["custom","azure","bedrock","sagemaker","vertex_ai","openai","cohere","anyscale","deepinfra","alephalpha","groq","minimax","mistral","mistralai","anthropic","perplexityai","together_ai","openrouter","gemini"],"title":"CustomProviderKind"},"CustomProviderSettingsDTO":{"properties":{"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key"},"extras":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Extras"}},"type":"object","title":"CustomProviderSettingsDTO"},"CustomSecretDTO":{"properties":{"secret":{"$ref":"#/components/schemas/CustomSecretSettingsDTO"}},"type":"object","required":["secret"],"title":"CustomSecretDTO"},"CustomSecretFormat":{"type":"string","enum":["text","json"],"title":"CustomSecretFormat"},"CustomSecretSettingsDTO":{"properties":{"format":{"$ref":"#/components/schemas/CustomSecretFormat"},"content":{"anyOf":[{"type":"string"},{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"number"},{"type":"boolean"},{"type":"null"}]},"type":"object"},{"type":"null"}],"title":"Content"}},"type":"object","required":["format"],"title":"CustomSecretSettingsDTO"},"DictOperator":{"type":"string","enum":["has","has_not"],"title":"DictOperator"},"DiscoverRequest":{"properties":{"email":{"type":"string","format":"email","title":"Email"}},"type":"object","required":["email"],"title":"DiscoverRequest"},"DiscoverResponse":{"properties":{"exists":{"type":"boolean","title":"Exists"},"methods":{"additionalProperties":{"anyOf":[{"type":"boolean"},{"$ref":"#/components/schemas/SSOProviders"}]},"type":"object","title":"Methods"}},"type":"object","required":["exists","methods"],"title":"DiscoverResponse"},"DiscoveredAlternative":{"properties":{"integration":{"type":"string","title":"Integration"},"action":{"type":"string","title":"Action"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"provider_action":{"type":"string","title":"Provider Action"}},"type":"object","required":["integration","action","provider_action"],"title":"DiscoveredAlternative","description":"A companion/prerequisite tool the one-line request omitted (Agenta-shaped)."},"DiscoveredTool":{"properties":{"type":{"type":"string","const":"gateway","title":"Type","default":"gateway"},"provider":{"type":"string","title":"Provider","default":"composio"},"integration":{"type":"string","title":"Integration"},"action":{"type":"string","title":"Action"},"connection":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Connection"},"input_schema":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Input Schema"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"provider_action":{"type":"string","title":"Provider Action"}},"type":"object","required":["integration","action","provider_action"],"title":"DiscoveredTool","description":"A discovered tool, already shaped as a ``GatewayToolConfig`` plus the\nmodel-facing extras the setup agent needs. ``connection`` is filled only when\nthe integration's state is ``ready``; otherwise the agent resolves it first."},"DiscoveredTriggerAlternative":{"properties":{"integration":{"type":"string","title":"Integration"},"event_key":{"type":"string","title":"Event Key"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"provider_event":{"type":"string","title":"Provider Event"}},"type":"object","required":["integration","event_key","provider_event"],"title":"DiscoveredTriggerAlternative"},"DiscoveredTriggerEvent":{"properties":{"type":{"type":"string","const":"trigger","title":"Type","default":"trigger"},"provider":{"type":"string","title":"Provider","default":"composio"},"integration":{"type":"string","title":"Integration"},"event_key":{"type":"string","title":"Event Key"},"trigger_config":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Trigger Config"},"payload":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Payload"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"provider_event":{"type":"string","title":"Provider Event"}},"type":"object","required":["integration","event_key","provider_event"],"title":"DiscoveredTriggerEvent"},"DiscoveryResult":{"properties":{"status":{"$ref":"#/components/schemas/DiscoveryStatus"},"models":{"items":{"type":"string"},"type":"array","title":"Models"}},"type":"object","required":["status"],"title":"DiscoveryResult"},"DiscoveryStatus":{"type":"string","enum":["fetched","unsupported","failed"],"title":"DiscoveryStatus","description":"Which model identifiers did the provider return?\n\n`unsupported` means the provider offers no model-list endpoint; `failed` means one\nexists but this attempt did not get an answer. Either way the caller keeps the\nshipped catalog rather than narrowing the user's model choice."},"EntityRef":{"properties":{"ref":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ref"},"id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email"}},"type":"object","title":"EntityRef","description":"Polymorphic reference that can point to a request-local key, an\nexisting persisted ID, a stable slug, or an email address.\nExactly one field must be set."},"Environment":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"Environment"},"EnvironmentCreate":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"EnvironmentCreate"},"EnvironmentCreateRequest":{"properties":{"environment":{"$ref":"#/components/schemas/EnvironmentCreate"}},"type":"object","required":["environment"],"title":"EnvironmentCreateRequest"},"EnvironmentEdit":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"EnvironmentEdit"},"EnvironmentEditRequest":{"properties":{"environment":{"$ref":"#/components/schemas/EnvironmentEdit"}},"type":"object","required":["environment"],"title":"EnvironmentEditRequest"},"EnvironmentFlags":{"properties":{"is_guarded":{"type":"boolean","title":"Is Guarded","default":false}},"type":"object","title":"EnvironmentFlags"},"EnvironmentQueryFlags":{"properties":{"is_guarded":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Guarded"}},"type":"object","title":"EnvironmentQueryFlags"},"EnvironmentResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"environment":{"anyOf":[{"$ref":"#/components/schemas/Environment"},{"type":"null"}]}},"type":"object","title":"EnvironmentResponse"},"EnvironmentRevision-Input":{"properties":{"environment_variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Environment Variant Slug"},"variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Variant Slug"},"environment_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Environment Slug"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"environment_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"environment_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"author":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Author"},"date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Date"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentRevisionData"},{"type":"null"}]}},"type":"object","title":"EnvironmentRevision"},"EnvironmentRevision-Output":{"properties":{"environment_variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Environment Variant Slug"},"variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Variant Slug"},"environment_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Environment Slug"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"environment_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"environment_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"author":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Author"},"date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Date"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentRevisionData"},{"type":"null"}]}},"type":"object","title":"EnvironmentRevision"},"EnvironmentRevisionCommit":{"properties":{"environment_revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Revision Id"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"},"environment_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"environment_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"data":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentRevisionData"},{"type":"null"}]},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"delta":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentRevisionDelta"},{"type":"null"}]}},"type":"object","title":"EnvironmentRevisionCommit"},"EnvironmentRevisionCommitRequest":{"properties":{"environment_revision":{"$ref":"#/components/schemas/EnvironmentRevisionCommit"}},"type":"object","required":["environment_revision"],"title":"EnvironmentRevisionCommitRequest"},"EnvironmentRevisionCreate":{"properties":{"environment_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"environment_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"EnvironmentRevisionCreate"},"EnvironmentRevisionCreateRequest":{"properties":{"environment_revision":{"$ref":"#/components/schemas/EnvironmentRevisionCreate"}},"type":"object","required":["environment_revision"],"title":"EnvironmentRevisionCreateRequest"},"EnvironmentRevisionData":{"properties":{"references":{"anyOf":[{"additionalProperties":{"additionalProperties":{"$ref":"#/components/schemas/Reference"},"type":"object"},"type":"object"},{"type":"null"}],"title":"References"}},"additionalProperties":false,"type":"object","title":"EnvironmentRevisionData","description":"Per-app references for environment revision data.\n\nKeys are app-scoped identifiers (e.g., ``\"pre.revision\"``).\nValues are dicts of entity-type → Reference, providing full traceability::\n\n {\n \"pre.revision\": {\n \"application\": Reference(id=..., slug=..., version=...),\n \"application_variant\": Reference(id=..., slug=..., version=...),\n \"application_revision\": Reference(id=..., slug=..., version=...),\n },\n ...\n }"},"EnvironmentRevisionDelta":{"properties":{"set":{"anyOf":[{"additionalProperties":{"additionalProperties":{"$ref":"#/components/schemas/Reference"},"type":"object"},"type":"object"},{"type":"null"}],"title":"Set"},"remove":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Remove"}},"type":"object","title":"EnvironmentRevisionDelta","description":"Delta operations on environment revision references.\n\n- ``set``: references to add or update (key → dict of entity → Reference).\n- ``remove``: reference keys to remove."},"EnvironmentRevisionEdit":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"EnvironmentRevisionEdit"},"EnvironmentRevisionEditRequest":{"properties":{"environment_revision":{"$ref":"#/components/schemas/EnvironmentRevisionEdit"}},"type":"object","required":["environment_revision"],"title":"EnvironmentRevisionEditRequest"},"EnvironmentRevisionResolveRequest":{"properties":{"environment_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"environment_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"environment_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"environment_revision":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentRevision-Input"},{"type":"null"}],"description":"Resolve the references embedded in this revision payload directly, without fetching it first. Only `data` is used; id and metadata are ignored."},"max_depth":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Depth","default":10},"max_embeds":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Embeds","default":100},"error_policy":{"anyOf":[{"$ref":"#/components/schemas/ErrorPolicy"},{"type":"null"}],"default":"exception"}},"type":"object","title":"EnvironmentRevisionResolveRequest"},"EnvironmentRevisionResolveResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"environment_revision":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentRevision-Output"},{"type":"null"}]},"resolution_info":{"anyOf":[{"$ref":"#/components/schemas/ResolutionInfo"},{"type":"null"}]},"retrieval_info":{"anyOf":[{"$ref":"#/components/schemas/RetrievalInfo"},{"type":"null"}]}},"type":"object","title":"EnvironmentRevisionResolveResponse"},"EnvironmentRevisionResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"environment_revision":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentRevision-Output"},{"type":"null"}]},"resolution_info":{"anyOf":[{"$ref":"#/components/schemas/ResolutionInfo"},{"type":"null"}]},"retrieval_info":{"anyOf":[{"$ref":"#/components/schemas/RetrievalInfo"},{"type":"null"}]}},"type":"object","title":"EnvironmentRevisionResponse"},"EnvironmentRevisionRetrieveRequest":{"properties":{"environment_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Environment artifact to look up. Identifies the artifact by `id` or `slug` (both project-unique). When no variant_ref or revision_ref is provided, returns the latest revision of an arbitrary variant of this environment."},"environment_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Environment variant to look up. Identifies the variant by `id` or `slug` (both project-unique). When no revision_ref is provided, returns the latest revision of this variant."},"environment_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Environment revision to look up. `id` alone identifies a revision (project-unique). `slug` alone identifies a revision (project-unique). `version` alone is a per-variant sequence number and is **not** sufficient on its own; it must be combined with an `environment_variant_ref`. Sending only `version` without a variant ref returns HTTP 400."},"resolve":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Resolve"}},"type":"object","title":"EnvironmentRevisionRetrieveRequest"},"EnvironmentRevisionsLog":{"properties":{"environment_revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Revision Id"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"},"environment_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"environment_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"depth":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Depth"}},"type":"object","title":"EnvironmentRevisionsLog"},"EnvironmentRevisionsLogRequest":{"properties":{"environment_revisions":{"$ref":"#/components/schemas/EnvironmentRevisionsLog"}},"type":"object","required":["environment_revisions"],"title":"EnvironmentRevisionsLogRequest"},"EnvironmentRevisionsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"environment_revisions":{"items":{"$ref":"#/components/schemas/EnvironmentRevision-Output"},"type":"array","title":"Environment Revisions","default":[]}},"type":"object","title":"EnvironmentRevisionsResponse"},"EnvironmentVariant":{"properties":{"environment_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Environment Slug"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"environment_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"EnvironmentVariant"},"EnvironmentVariantCreate":{"properties":{"environment_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Environment Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"EnvironmentVariantCreate"},"EnvironmentVariantCreateRequest":{"properties":{"environment_variant":{"$ref":"#/components/schemas/EnvironmentVariantCreate"}},"type":"object","required":["environment_variant"],"title":"EnvironmentVariantCreateRequest"},"EnvironmentVariantEdit":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"EnvironmentVariantEdit"},"EnvironmentVariantEditRequest":{"properties":{"environment_variant":{"$ref":"#/components/schemas/EnvironmentVariantEdit"}},"type":"object","required":["environment_variant"],"title":"EnvironmentVariantEditRequest"},"EnvironmentVariantFork":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"EnvironmentVariantFork"},"EnvironmentVariantForkRequest":{"properties":{"environment_variant":{"$ref":"#/components/schemas/EnvironmentVariantFork","description":"Config for the new variant (slug, name, description, flags)."},"environment_variant_ref":{"$ref":"#/components/schemas/Reference","description":"Source variant to fork from."},"environment_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Pin the fork to this revision; defaults to the source variant's head."}},"type":"object","required":["environment_variant","environment_variant_ref"],"title":"EnvironmentVariantForkRequest"},"EnvironmentVariantResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"environment_variant":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentVariant"},{"type":"null"}]}},"type":"object","title":"EnvironmentVariantResponse"},"EnvironmentVariantsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"environment_variants":{"items":{"$ref":"#/components/schemas/EnvironmentVariant"},"type":"array","title":"Environment Variants","default":[]}},"type":"object","title":"EnvironmentVariantsResponse"},"EnvironmentsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"environments":{"items":{"$ref":"#/components/schemas/Environment"},"type":"array","title":"Environments","default":[]}},"type":"object","title":"EnvironmentsResponse"},"ErrorPolicy":{"type":"string","enum":["exception","placeholder","keep"],"title":"ErrorPolicy"},"EvaluationMetrics":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}],"default":"pending"},"data":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Data"},"interval":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Timestamp"},"scenario_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Scenario Id"},"run_id":{"type":"string","format":"uuid","title":"Run Id"}},"type":"object","required":["run_id"],"title":"EvaluationMetrics"},"EvaluationMetricsCreate":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"version":{"type":"string","title":"Version","default":"2025-07-14"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}]},"data":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Data"},"interval":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Timestamp"},"scenario_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Scenario Id"},"run_id":{"type":"string","format":"uuid","title":"Run Id"}},"type":"object","required":["run_id"],"title":"EvaluationMetricsCreate"},"EvaluationMetricsIdsRequest":{"properties":{"metrics_ids":{"items":{"type":"string","format":"uuid"},"type":"array","title":"Metrics Ids"}},"type":"object","required":["metrics_ids"],"title":"EvaluationMetricsIdsRequest"},"EvaluationMetricsIdsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"metrics_ids":{"items":{"type":"string","format":"uuid"},"type":"array","title":"Metrics Ids","default":[]}},"type":"object","title":"EvaluationMetricsIdsResponse"},"EvaluationMetricsQuery":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}]},"statuses":{"anyOf":[{"items":{"$ref":"#/components/schemas/EvaluationStatus"},"type":"array"},{"type":"null"}],"title":"Statuses"},"interval":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"},"intervals":{"anyOf":[{"items":{"type":"integer"},"type":"array"},{"type":"null"}],"title":"Intervals"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Timestamp"},"timestamps":{"anyOf":[{"items":{"type":"string","format":"date-time"},"type":"array"},{"type":"boolean"},{"type":"null"}],"title":"Timestamps"},"scenario_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Scenario Id"},"scenario_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"boolean"},{"type":"null"}],"title":"Scenario Ids"},"run_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Run Id"},"run_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Run Ids"},"ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Ids"}},"type":"object","title":"EvaluationMetricsQuery"},"EvaluationMetricsQueryRequest":{"properties":{"metrics":{"anyOf":[{"$ref":"#/components/schemas/EvaluationMetricsQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"EvaluationMetricsQueryRequest"},"EvaluationMetricsRefresh":{"properties":{"interval":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Timestamp"},"timestamps":{"anyOf":[{"items":{"type":"string","format":"date-time"},"type":"array"},{"type":"null"}],"title":"Timestamps"},"scenario_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Scenario Id"},"scenario_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Scenario Ids"},"run_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Run Id"},"run_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Run Ids"}},"type":"object","title":"EvaluationMetricsRefresh"},"EvaluationMetricsRefreshRequest":{"properties":{"metrics":{"$ref":"#/components/schemas/EvaluationMetricsRefresh"}},"type":"object","required":["metrics"],"title":"EvaluationMetricsRefreshRequest"},"EvaluationMetricsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"metrics":{"items":{"$ref":"#/components/schemas/EvaluationMetrics"},"type":"array","title":"Metrics","default":[]}},"type":"object","title":"EvaluationMetricsResponse"},"EvaluationMetricsSetRequest":{"properties":{"metrics":{"items":{"$ref":"#/components/schemas/EvaluationMetricsCreate"},"type":"array","title":"Metrics"}},"type":"object","required":["metrics"],"title":"EvaluationMetricsSetRequest"},"EvaluationQueue":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluationQueueFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}],"default":"pending"},"data":{"anyOf":[{"$ref":"#/components/schemas/EvaluationQueueData"},{"type":"null"}]},"run_id":{"type":"string","format":"uuid","title":"Run Id"}},"type":"object","required":["run_id"],"title":"EvaluationQueue"},"EvaluationQueueCreate":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluationQueueFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"version":{"type":"string","title":"Version","default":"2025-07-14"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}],"default":"pending"},"data":{"anyOf":[{"$ref":"#/components/schemas/EvaluationQueueData"},{"type":"null"}]},"run_id":{"type":"string","format":"uuid","title":"Run Id"}},"type":"object","required":["run_id"],"title":"EvaluationQueueCreate"},"EvaluationQueueData":{"properties":{"user_ids":{"anyOf":[{"items":{"items":{"type":"string","format":"uuid"},"type":"array"},"type":"array"},{"type":"null"}],"title":"User Ids"},"scenario_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Scenario Ids"},"step_keys":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Step Keys"},"batch_size":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Batch Size"},"batch_offset":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Batch Offset"}},"type":"object","title":"EvaluationQueueData"},"EvaluationQueueEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluationQueueFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"version":{"type":"string","title":"Version","default":"2025-07-14"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}]},"data":{"anyOf":[{"$ref":"#/components/schemas/EvaluationQueueData"},{"type":"null"}]}},"type":"object","title":"EvaluationQueueEdit"},"EvaluationQueueEditRequest":{"properties":{"queue":{"$ref":"#/components/schemas/EvaluationQueueEdit"}},"type":"object","required":["queue"],"title":"EvaluationQueueEditRequest"},"EvaluationQueueFlags":{"properties":{"is_sequential":{"type":"boolean","title":"Is Sequential","default":false},"is_default":{"type":"boolean","title":"Is Default","default":false}},"type":"object","title":"EvaluationQueueFlags"},"EvaluationQueueIdResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"queue_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Queue Id"}},"type":"object","title":"EvaluationQueueIdResponse"},"EvaluationQueueIdsRequest":{"properties":{"queue_ids":{"items":{"type":"string","format":"uuid"},"type":"array","title":"Queue Ids"}},"type":"object","required":["queue_ids"],"title":"EvaluationQueueIdsRequest"},"EvaluationQueueIdsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"queue_ids":{"items":{"type":"string","format":"uuid"},"type":"array","title":"Queue Ids","default":[]}},"type":"object","title":"EvaluationQueueIdsResponse"},"EvaluationQueueQuery":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluationQueueQueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"},"user_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"User Id"},"user_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"User Ids"},"run_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Run Id"},"run_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Run Ids"},"ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Ids"}},"type":"object","title":"EvaluationQueueQuery"},"EvaluationQueueQueryFlags":{"properties":{"is_sequential":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Sequential"},"is_default":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Default"}},"type":"object","title":"EvaluationQueueQueryFlags"},"EvaluationQueueQueryRequest":{"properties":{"queue":{"anyOf":[{"$ref":"#/components/schemas/EvaluationQueueQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"EvaluationQueueQueryRequest"},"EvaluationQueueResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"queue":{"anyOf":[{"$ref":"#/components/schemas/EvaluationQueue"},{"type":"null"}]}},"type":"object","title":"EvaluationQueueResponse"},"EvaluationQueueScenariosQuery":{"properties":{"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"user_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"User Id"},"user_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"User Ids"}},"type":"object","title":"EvaluationQueueScenariosQuery"},"EvaluationQueueScenariosQueryRequest":{"properties":{"queue":{"anyOf":[{"$ref":"#/components/schemas/EvaluationQueueScenariosQuery"},{"type":"null"}]},"scenario":{"anyOf":[{"$ref":"#/components/schemas/EvaluationScenarioQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"EvaluationQueueScenariosQueryRequest"},"EvaluationQueuesCreateRequest":{"properties":{"queues":{"items":{"$ref":"#/components/schemas/EvaluationQueueCreate"},"type":"array","title":"Queues"}},"type":"object","required":["queues"],"title":"EvaluationQueuesCreateRequest"},"EvaluationQueuesEditRequest":{"properties":{"queues":{"items":{"$ref":"#/components/schemas/EvaluationQueueEdit"},"type":"array","title":"Queues"}},"type":"object","required":["queues"],"title":"EvaluationQueuesEditRequest"},"EvaluationQueuesResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"queues":{"items":{"$ref":"#/components/schemas/EvaluationQueue"},"type":"array","title":"Queues","default":[]}},"type":"object","title":"EvaluationQueuesResponse"},"EvaluationResult":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"hash_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Hash Id"},"trace_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Trace Id"},"testcase_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testcase Id"},"error":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Error"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}],"default":"pending"},"interval":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Timestamp"},"repeat_idx":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Repeat Idx","default":0},"step_key":{"type":"string","title":"Step Key"},"scenario_id":{"type":"string","format":"uuid","title":"Scenario Id"},"run_id":{"type":"string","format":"uuid","title":"Run Id"}},"type":"object","required":["step_key","scenario_id","run_id"],"title":"EvaluationResult"},"EvaluationResultCreate":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"version":{"type":"string","title":"Version","default":"2025-07-14"},"hash_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Hash Id"},"trace_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Trace Id"},"testcase_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testcase Id"},"error":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Error"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}]},"interval":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Timestamp"},"repeat_idx":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Repeat Idx","default":0},"step_key":{"type":"string","title":"Step Key"},"scenario_id":{"type":"string","format":"uuid","title":"Scenario Id"},"run_id":{"type":"string","format":"uuid","title":"Run Id"}},"type":"object","required":["step_key","scenario_id","run_id"],"title":"EvaluationResultCreate"},"EvaluationResultIdResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"result_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Result Id"}},"type":"object","title":"EvaluationResultIdResponse"},"EvaluationResultIdsRequest":{"properties":{"result_ids":{"items":{"type":"string","format":"uuid"},"type":"array","title":"Result Ids"}},"type":"object","required":["result_ids"],"title":"EvaluationResultIdsRequest"},"EvaluationResultIdsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"result_ids":{"items":{"type":"string","format":"uuid"},"type":"array","title":"Result Ids","default":[]}},"type":"object","title":"EvaluationResultIdsResponse"},"EvaluationResultQuery":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}]},"statuses":{"anyOf":[{"items":{"$ref":"#/components/schemas/EvaluationStatus"},"type":"array"},{"type":"null"}],"title":"Statuses"},"interval":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"},"intervals":{"anyOf":[{"items":{"type":"integer"},"type":"array"},{"type":"null"}],"title":"Intervals"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Timestamp"},"timestamps":{"anyOf":[{"items":{"type":"string","format":"date-time"},"type":"array"},{"type":"null"}],"title":"Timestamps"},"repeat_idx":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Repeat Idx"},"repeat_idxs":{"anyOf":[{"items":{"type":"integer"},"type":"array"},{"type":"null"}],"title":"Repeat Idxs"},"step_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Step Key"},"step_keys":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Step Keys"},"scenario_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Scenario Id"},"scenario_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Scenario Ids"},"run_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Run Id"},"run_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Run Ids"},"ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Ids"}},"type":"object","title":"EvaluationResultQuery"},"EvaluationResultQueryRequest":{"properties":{"result":{"anyOf":[{"$ref":"#/components/schemas/EvaluationResultQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"EvaluationResultQueryRequest"},"EvaluationResultResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"result":{"anyOf":[{"$ref":"#/components/schemas/EvaluationResult"},{"type":"null"}]}},"type":"object","title":"EvaluationResultResponse"},"EvaluationResultsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"results":{"items":{"$ref":"#/components/schemas/EvaluationResult"},"type":"array","title":"Results","default":[]}},"type":"object","title":"EvaluationResultsResponse"},"EvaluationResultsSetRequest":{"properties":{"results":{"items":{"$ref":"#/components/schemas/EvaluationResultCreate"},"type":"array","title":"Results"}},"type":"object","required":["results"],"title":"EvaluationResultsSetRequest"},"EvaluationRun":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}],"default":"pending"},"data":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunData-Output"},{"type":"null"}]}},"type":"object","title":"EvaluationRun"},"EvaluationRunCreate":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"version":{"type":"string","title":"Version","default":"2025-07-14"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}]},"data":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunData-Input"},{"type":"null"}]}},"type":"object","title":"EvaluationRunCreate"},"EvaluationRunData-Input":{"properties":{"steps":{"anyOf":[{"items":{"$ref":"#/components/schemas/EvaluationRunDataStep-Input"},"type":"array"},{"type":"null"}],"title":"Steps"},"repeats":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Repeats","default":1},"concurrency":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunDataConcurrency"},{"type":"null"}]},"mappings":{"anyOf":[{"items":{"$ref":"#/components/schemas/EvaluationRunDataMapping"},"type":"array"},{"type":"null"}],"title":"Mappings"}},"type":"object","title":"EvaluationRunData"},"EvaluationRunData-Output":{"properties":{"steps":{"anyOf":[{"items":{"$ref":"#/components/schemas/EvaluationRunDataStep-Output"},"type":"array"},{"type":"null"}],"title":"Steps"},"repeats":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Repeats","default":1},"concurrency":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunDataConcurrency"},{"type":"null"}]},"mappings":{"anyOf":[{"items":{"$ref":"#/components/schemas/EvaluationRunDataMapping"},"type":"array"},{"type":"null"}],"title":"Mappings"}},"type":"object","title":"EvaluationRunData"},"EvaluationRunDataConcurrency":{"properties":{"batch_size":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Batch Size"},"max_retries":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Retries"},"retry_delay":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Retry Delay"}},"type":"object","title":"EvaluationRunDataConcurrency"},"EvaluationRunDataMapping":{"properties":{"column":{"$ref":"#/components/schemas/EvaluationRunDataMappingColumn"},"step":{"$ref":"#/components/schemas/EvaluationRunDataMappingStep"}},"type":"object","required":["column","step"],"title":"EvaluationRunDataMapping"},"EvaluationRunDataMappingColumn":{"properties":{"kind":{"type":"string","title":"Kind"},"name":{"type":"string","title":"Name"}},"type":"object","required":["kind","name"],"title":"EvaluationRunDataMappingColumn"},"EvaluationRunDataMappingStep":{"properties":{"key":{"type":"string","title":"Key"},"path":{"type":"string","title":"Path"}},"type":"object","required":["key","path"],"title":"EvaluationRunDataMappingStep"},"EvaluationRunDataStep-Input":{"properties":{"key":{"type":"string","title":"Key"},"type":{"type":"string","enum":["input","invocation","annotation"],"title":"Type"},"origin":{"type":"string","enum":["custom","human","auto"],"title":"Origin"},"references":{"additionalProperties":{"$ref":"#/components/schemas/Reference"},"type":"object","title":"References"},"inputs":{"anyOf":[{"items":{"$ref":"#/components/schemas/EvaluationRunDataStepInputKey"},"type":"array"},{"type":"null"}],"title":"Inputs"},"schemas":{"anyOf":[{"$ref":"#/components/schemas/JsonSchemas-Input"},{"type":"null"}]}},"type":"object","required":["key","type","origin","references"],"title":"EvaluationRunDataStep"},"EvaluationRunDataStep-Output":{"properties":{"key":{"type":"string","title":"Key"},"type":{"type":"string","enum":["input","invocation","annotation"],"title":"Type"},"origin":{"type":"string","enum":["custom","human","auto"],"title":"Origin"},"references":{"additionalProperties":{"$ref":"#/components/schemas/Reference"},"type":"object","title":"References"},"inputs":{"anyOf":[{"items":{"$ref":"#/components/schemas/EvaluationRunDataStepInputKey"},"type":"array"},{"type":"null"}],"title":"Inputs"},"schemas":{"anyOf":[{"$ref":"#/components/schemas/JsonSchemas-Output"},{"type":"null"}]}},"type":"object","required":["key","type","origin","references"],"title":"EvaluationRunDataStep"},"EvaluationRunDataStepInputKey":{"properties":{"key":{"type":"string","title":"Key"}},"type":"object","required":["key"],"title":"EvaluationRunDataStepInputKey"},"EvaluationRunEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"version":{"type":"string","title":"Version","default":"2025-07-14"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}]},"data":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunData-Input"},{"type":"null"}]}},"type":"object","title":"EvaluationRunEdit"},"EvaluationRunEditRequest":{"properties":{"run":{"$ref":"#/components/schemas/EvaluationRunEdit"}},"type":"object","required":["run"],"title":"EvaluationRunEditRequest"},"EvaluationRunFlags":{"properties":{"is_live":{"type":"boolean","title":"Is Live","default":false},"is_active":{"type":"boolean","title":"Is Active","default":false},"is_closed":{"type":"boolean","title":"Is Closed","default":false},"is_queue":{"type":"boolean","title":"Is Queue","default":false},"is_cached":{"type":"boolean","title":"Is Cached","default":false},"is_split":{"type":"boolean","title":"Is Split","default":false},"has_queries":{"type":"boolean","title":"Has Queries","default":false},"has_testsets":{"type":"boolean","title":"Has Testsets","default":false},"has_traces":{"type":"boolean","title":"Has Traces","default":false},"has_testcases":{"type":"boolean","title":"Has Testcases","default":false},"has_evaluators":{"type":"boolean","title":"Has Evaluators","default":false},"has_custom":{"type":"boolean","title":"Has Custom","default":false},"has_human":{"type":"boolean","title":"Has Human","default":false},"has_auto":{"type":"boolean","title":"Has Auto","default":false}},"type":"object","title":"EvaluationRunFlags"},"EvaluationRunIdResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"run_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Run Id"}},"type":"object","title":"EvaluationRunIdResponse"},"EvaluationRunIdsRequest":{"properties":{"run_ids":{"items":{"type":"string","format":"uuid"},"type":"array","title":"Run Ids"}},"type":"object","required":["run_ids"],"title":"EvaluationRunIdsRequest"},"EvaluationRunIdsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"run_ids":{"items":{"type":"string","format":"uuid"},"type":"array","title":"Run Ids","default":[]}},"type":"object","title":"EvaluationRunIdsResponse"},"EvaluationRunQuery":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunQueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}]},"statuses":{"anyOf":[{"items":{"$ref":"#/components/schemas/EvaluationStatus"},"type":"array"},{"type":"null"}],"title":"Statuses"},"references":{"anyOf":[{"items":{"additionalProperties":{"$ref":"#/components/schemas/Reference"},"type":"object"},"type":"array"},{"type":"null"}],"title":"References"},"ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Ids"}},"type":"object","title":"EvaluationRunQuery"},"EvaluationRunQueryFlags":{"properties":{"is_live":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Live"},"is_active":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Active"},"is_closed":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Closed"},"is_queue":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Queue"},"is_cached":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Cached"},"is_split":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Split"},"has_queries":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Queries"},"has_testsets":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Testsets"},"has_traces":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Traces"},"has_testcases":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Testcases"},"has_evaluators":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Evaluators"},"has_custom":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Custom"},"has_human":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Human"},"has_auto":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Auto"}},"type":"object","title":"EvaluationRunQueryFlags"},"EvaluationRunQueryRequest":{"properties":{"run":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"EvaluationRunQueryRequest"},"EvaluationRunResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"run":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRun"},{"type":"null"}]}},"type":"object","title":"EvaluationRunResponse"},"EvaluationRunsCreateRequest":{"properties":{"runs":{"items":{"$ref":"#/components/schemas/EvaluationRunCreate"},"type":"array","title":"Runs"}},"type":"object","required":["runs"],"title":"EvaluationRunsCreateRequest"},"EvaluationRunsEditRequest":{"properties":{"runs":{"items":{"$ref":"#/components/schemas/EvaluationRunEdit"},"type":"array","title":"Runs"}},"type":"object","required":["runs"],"title":"EvaluationRunsEditRequest"},"EvaluationRunsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"runs":{"items":{"$ref":"#/components/schemas/EvaluationRun"},"type":"array","title":"Runs","default":[]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"EvaluationRunsResponse"},"EvaluationScenario":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}],"default":"pending"},"interval":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Timestamp"},"run_id":{"type":"string","format":"uuid","title":"Run Id"}},"type":"object","required":["run_id"],"title":"EvaluationScenario"},"EvaluationScenarioCreate":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"version":{"type":"string","title":"Version","default":"2025-07-14"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}]},"interval":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Timestamp"},"run_id":{"type":"string","format":"uuid","title":"Run Id"}},"type":"object","required":["run_id"],"title":"EvaluationScenarioCreate"},"EvaluationScenarioEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"version":{"type":"string","title":"Version","default":"2025-07-14"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}]},"interval":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Timestamp"}},"type":"object","title":"EvaluationScenarioEdit"},"EvaluationScenarioEditRequest":{"properties":{"scenario":{"$ref":"#/components/schemas/EvaluationScenarioEdit"}},"type":"object","required":["scenario"],"title":"EvaluationScenarioEditRequest"},"EvaluationScenarioIdResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"scenario_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Scenario Id"}},"type":"object","title":"EvaluationScenarioIdResponse"},"EvaluationScenarioIdsRequest":{"properties":{"scenario_ids":{"items":{"type":"string","format":"uuid"},"type":"array","title":"Scenario Ids"}},"type":"object","required":["scenario_ids"],"title":"EvaluationScenarioIdsRequest"},"EvaluationScenarioIdsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"scenario_ids":{"items":{"type":"string","format":"uuid"},"type":"array","title":"Scenario Ids","default":[]}},"type":"object","title":"EvaluationScenarioIdsResponse"},"EvaluationScenarioQuery":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}]},"statuses":{"anyOf":[{"items":{"$ref":"#/components/schemas/EvaluationStatus"},"type":"array"},{"type":"null"}],"title":"Statuses"},"interval":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"},"intervals":{"anyOf":[{"items":{"type":"integer"},"type":"array"},{"type":"null"}],"title":"Intervals"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Timestamp"},"timestamps":{"anyOf":[{"items":{"type":"string","format":"date-time"},"type":"array"},{"type":"null"}],"title":"Timestamps"},"run_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Run Id"},"run_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Run Ids"},"ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Ids"}},"type":"object","title":"EvaluationScenarioQuery"},"EvaluationScenarioQueryRequest":{"properties":{"scenario":{"anyOf":[{"$ref":"#/components/schemas/EvaluationScenarioQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"EvaluationScenarioQueryRequest"},"EvaluationScenarioResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"scenario":{"anyOf":[{"$ref":"#/components/schemas/EvaluationScenario"},{"type":"null"}]}},"type":"object","title":"EvaluationScenarioResponse"},"EvaluationScenariosCreateRequest":{"properties":{"scenarios":{"items":{"$ref":"#/components/schemas/EvaluationScenarioCreate"},"type":"array","title":"Scenarios"}},"type":"object","required":["scenarios"],"title":"EvaluationScenariosCreateRequest"},"EvaluationScenariosEditRequest":{"properties":{"scenarios":{"items":{"$ref":"#/components/schemas/EvaluationScenarioEdit"},"type":"array","title":"Scenarios"}},"type":"object","required":["scenarios"],"title":"EvaluationScenariosEditRequest"},"EvaluationScenariosResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"scenarios":{"items":{"$ref":"#/components/schemas/EvaluationScenario"},"type":"array","title":"Scenarios","default":[]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"EvaluationScenariosResponse"},"EvaluationStatus":{"type":"string","enum":["pending","queued","running","success","failure","errors","cancelled"],"title":"EvaluationStatus"},"Evaluator":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorArtifactFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"Evaluator"},"EvaluatorArtifactFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false}},"type":"object","title":"EvaluatorArtifactFlags"},"EvaluatorArtifactQueryFlags":{"properties":{"is_application":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Application"},"is_evaluator":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Evaluator"},"is_snippet":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Snippet"}},"type":"object","title":"EvaluatorArtifactQueryFlags"},"EvaluatorCatalogPreset":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"key":{"type":"string","title":"Key"},"categories":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Categories"},"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowCatalogFlags"},{"type":"null"}]},"data":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevisionData-Output"},{"type":"null"}]}},"type":"object","required":["key"],"title":"EvaluatorCatalogPreset"},"EvaluatorCatalogPresetResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"1 when a preset is returned, 0 otherwise.","default":0},"preset":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorCatalogPreset"},{"type":"null"}],"description":"The catalog preset, or null when none matched."}},"type":"object","title":"EvaluatorCatalogPresetResponse","description":"Envelope for a single catalog preset."},"EvaluatorCatalogPresetsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of presets in `presets`.","default":0},"presets":{"items":{"$ref":"#/components/schemas/EvaluatorCatalogPreset"},"type":"array","title":"Presets","description":"Named parameter presets defined against a template."}},"type":"object","title":"EvaluatorCatalogPresetsResponse","description":"Envelope for a list of catalog presets."},"EvaluatorCatalogTemplate":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"key":{"type":"string","title":"Key"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"categories":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Categories"},"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowCatalogFlags"},{"type":"null"}]},"data":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevisionData-Output"},{"type":"null"}]}},"type":"object","required":["key"],"title":"EvaluatorCatalogTemplate"},"EvaluatorCatalogTemplateResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"1 when a template is returned, 0 otherwise.","default":0},"template":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorCatalogTemplate"},{"type":"null"}],"description":"The catalog template, or null when none matched."}},"type":"object","title":"EvaluatorCatalogTemplateResponse","description":"Envelope for a single catalog template."},"EvaluatorCatalogTemplatesResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of templates in `templates`.","default":0},"templates":{"items":{"$ref":"#/components/schemas/EvaluatorCatalogTemplate"},"type":"array","title":"Templates","description":"Evaluator catalog templates (blueprints for creating evaluators)."}},"type":"object","title":"EvaluatorCatalogTemplatesResponse","description":"Envelope for a list of catalog templates."},"EvaluatorCatalogType":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"key":{"type":"string","title":"Key"},"json_schema":{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object","title":"Json Schema"}},"type":"object","required":["key","json_schema"],"title":"EvaluatorCatalogType"},"EvaluatorCatalogTypesResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of types in `types`.","default":0},"types":{"items":{"$ref":"#/components/schemas/EvaluatorCatalogType"},"type":"array","title":"Types","description":"JSON schema types the evaluator catalog understands."}},"type":"object","title":"EvaluatorCatalogTypesResponse","description":"Envelope for a list of catalog types."},"EvaluatorCreate":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"EvaluatorCreate"},"EvaluatorCreateRequest":{"properties":{"evaluator":{"$ref":"#/components/schemas/EvaluatorCreate","description":"Evaluator payload (slug, name, flags, data). Slug is required and scoped to the project."}},"type":"object","required":["evaluator"],"title":"EvaluatorCreateRequest","description":"Body for creating an evaluator artifact.\n\nCreating an evaluator also provisions its first variant and its initial\nrevision. The evaluator shares the artifact / variant / revision model\nused across versioned resources — see the Versioning guide."},"EvaluatorEdit":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"EvaluatorEdit"},"EvaluatorEditRequest":{"properties":{"evaluator":{"$ref":"#/components/schemas/EvaluatorEdit","description":"Evaluator edit payload. Requires the evaluator `id`. Renaming is temporarily disabled."}},"type":"object","required":["evaluator"],"title":"EvaluatorEditRequest","description":"Body for editing the metadata of an existing evaluator artifact."},"EvaluatorFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false},"is_managed":{"type":"boolean","title":"Is Managed","default":false},"is_custom":{"type":"boolean","title":"Is Custom","default":false},"is_llm":{"type":"boolean","title":"Is Llm","default":false},"is_hook":{"type":"boolean","title":"Is Hook","default":false},"is_code":{"type":"boolean","title":"Is Code","default":false},"is_match":{"type":"boolean","title":"Is Match","default":false},"is_feedback":{"type":"boolean","title":"Is Feedback","default":false},"is_agent":{"type":"boolean","title":"Is Agent","default":false},"is_skill":{"type":"boolean","title":"Is Skill","default":false},"is_chat":{"type":"boolean","title":"Is Chat","default":false},"has_url":{"type":"boolean","title":"Has Url","default":false},"has_script":{"type":"boolean","title":"Has Script","default":false},"has_handler":{"type":"boolean","title":"Has Handler","default":false},"is_static":{"type":"boolean","title":"Is Static","default":false}},"type":"object","title":"EvaluatorFlags","description":"Legacy full evaluator flag set."},"EvaluatorQuery":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorArtifactQueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"slugs":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Slugs"}},"type":"object","title":"EvaluatorQuery"},"EvaluatorQueryRequest":{"properties":{"evaluator":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorQuery"},{"type":"null"}],"description":"Filter on evaluator attributes (flags, tags, meta)."},"evaluator_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Evaluator Refs","description":"Restrict the query to these evaluators. Accepts `id` or `slug` per reference."},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived","description":"When true, include soft-deleted evaluators in the response."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor-based pagination controls (limit, order, next, newest, oldest)."}},"type":"object","title":"EvaluatorQueryRequest","description":"Body for filtering evaluators. See the Query Pattern guide for field semantics."},"EvaluatorResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"1 when an evaluator is returned, 0 otherwise.","default":0},"evaluator":{"anyOf":[{"$ref":"#/components/schemas/Evaluator"},{"type":"null"}],"description":"The evaluator artifact, or null when none matched."}},"type":"object","title":"EvaluatorResponse","description":"Envelope for a single evaluator response."},"EvaluatorRevision-Input":{"properties":{"evaluator_variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Evaluator Variant Slug"},"workflow_variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Variant Slug"},"evaluator_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Evaluator Slug"},"workflow_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Slug"},"evaluator_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Variant Id"},"workflow_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"},"evaluator_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Variant Slug"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"author":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Author"},"date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Date"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorRevisionFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorRevisionData-Input"},{"type":"null"}]}},"type":"object","title":"EvaluatorRevision"},"EvaluatorRevision-Output":{"properties":{"evaluator_variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Evaluator Variant Slug"},"workflow_variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Variant Slug"},"evaluator_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Evaluator Slug"},"workflow_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Slug"},"evaluator_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Variant Id"},"workflow_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"},"evaluator_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Variant Slug"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"author":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Author"},"date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Date"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorRevisionFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorRevisionData-Output"},{"type":"null"}]}},"type":"object","title":"EvaluatorRevision"},"EvaluatorRevisionCommit":{"properties":{"evaluator_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Variant Id"},"workflow_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"},"evaluator_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"data":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorRevisionData-Input"},{"type":"null"}]},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"},"delta":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevisionDelta"},{"type":"null"}]},"base_revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Base Revision Id","description":"The revision this change was built on. Omit it to keep today's last-write-wins behavior. Send it on a legacy delta and the commit is refused with `409` when the variant's head has moved since: sending the field is how a caller asks for that check. An ordered delta requires it."}},"type":"object","title":"EvaluatorRevisionCommit"},"EvaluatorRevisionCommitRequest":{"properties":{"evaluator_revision":{"$ref":"#/components/schemas/EvaluatorRevisionCommit","description":"Commit payload carrying the `evaluator_variant_id`, optional commit `message`, and the revision `data`."}},"type":"object","required":["evaluator_revision"],"title":"EvaluatorRevisionCommitRequest","description":"Body for committing a new revision on a variant."},"EvaluatorRevisionCreate":{"properties":{"evaluator_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Variant Id"},"workflow_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"},"evaluator_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"EvaluatorRevisionCreate"},"EvaluatorRevisionCreateRequest":{"properties":{"evaluator_revision":{"$ref":"#/components/schemas/EvaluatorRevisionCreate","description":"Revision payload. Requires the parent `evaluator_variant_id` and a `data` object."}},"type":"object","required":["evaluator_revision"],"title":"EvaluatorRevisionCreateRequest","description":"Body for creating a new revision (commit) on an evaluator variant."},"EvaluatorRevisionData-Input":{"properties":{"uri":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Uri"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"headers":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/Reference"}]},"type":"object"},{"type":"null"}],"title":"Headers"},"runtime":{"anyOf":[{"type":"string","enum":["python","typescript","javascript"]},{"type":"null"}],"title":"Runtime"},"script":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Script"},"schemas":{"anyOf":[{"$ref":"#/components/schemas/JsonSchemas-Input"},{"type":"null"}]},"parameters":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Parameters"}},"additionalProperties":false,"type":"object","title":"EvaluatorRevisionData"},"EvaluatorRevisionData-Output":{"properties":{"uri":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Uri"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"headers":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/Reference"}]},"type":"object"},{"type":"null"}],"title":"Headers"},"runtime":{"anyOf":[{"type":"string","enum":["python","typescript","javascript"]},{"type":"null"}],"title":"Runtime"},"script":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Script"},"schemas":{"anyOf":[{"$ref":"#/components/schemas/JsonSchemas-Output"},{"type":"null"}]},"parameters":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Parameters"}},"additionalProperties":false,"type":"object","title":"EvaluatorRevisionData"},"EvaluatorRevisionDeployRequest":{"properties":{"evaluator_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Evaluator to deploy (latest revision)."},"evaluator_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Variant to deploy (latest revision on this variant)."},"evaluator_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Specific revision to deploy."},"environment_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Target environment."},"environment_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Target environment variant."},"environment_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Target environment revision."},"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key","description":"Named key under which the revision is pinned. Defaults to `.revision`."},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message","description":"Commit message stored on the environment revision that records the deployment."}},"type":"object","title":"EvaluatorRevisionDeployRequest","description":"Body for pinning an evaluator revision into an environment revision under a key."},"EvaluatorRevisionEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"EvaluatorRevisionEdit"},"EvaluatorRevisionEditRequest":{"properties":{"evaluator_revision":{"$ref":"#/components/schemas/EvaluatorRevisionEdit","description":"Revision edit payload. Requires the revision `id`."}},"type":"object","required":["evaluator_revision"],"title":"EvaluatorRevisionEditRequest","description":"Body for editing a revision's mutable fields (currently limited; payload data is immutable)."},"EvaluatorRevisionFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false},"is_managed":{"type":"boolean","title":"Is Managed","default":false},"is_custom":{"type":"boolean","title":"Is Custom","default":false},"is_llm":{"type":"boolean","title":"Is Llm","default":false},"is_hook":{"type":"boolean","title":"Is Hook","default":false},"is_code":{"type":"boolean","title":"Is Code","default":false},"is_match":{"type":"boolean","title":"Is Match","default":false},"is_feedback":{"type":"boolean","title":"Is Feedback","default":false},"is_agent":{"type":"boolean","title":"Is Agent","default":false},"is_skill":{"type":"boolean","title":"Is Skill","default":false},"is_chat":{"type":"boolean","title":"Is Chat","default":false},"has_url":{"type":"boolean","title":"Has Url","default":false},"has_script":{"type":"boolean","title":"Has Script","default":false},"has_handler":{"type":"boolean","title":"Has Handler","default":false},"is_static":{"type":"boolean","title":"Is Static","default":false}},"type":"object","title":"EvaluatorRevisionFlags"},"EvaluatorRevisionQuery":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorRevisionQueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"slugs":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Slugs"},"author":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Author"},"authors":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Authors"},"date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Date"},"dates":{"anyOf":[{"items":{"type":"string","format":"date-time"},"type":"array"},{"type":"null"}],"title":"Dates"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"}},"type":"object","title":"EvaluatorRevisionQuery"},"EvaluatorRevisionQueryFlags":{"properties":{"is_application":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Application"},"is_evaluator":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Evaluator"},"is_snippet":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Snippet"},"is_managed":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Managed"},"is_custom":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Custom"},"is_llm":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Llm"},"is_hook":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Hook"},"is_code":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Code"},"is_match":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Match"},"is_feedback":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Feedback"},"is_agent":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Agent"},"is_skill":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Skill"},"is_chat":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Chat"},"has_url":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Url"},"has_script":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Script"},"has_handler":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Handler"},"is_static":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Static"}},"type":"object","title":"EvaluatorRevisionQueryFlags"},"EvaluatorRevisionQueryRequest":{"properties":{"evaluator_revision":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorRevisionQuery"},{"type":"null"}],"description":"Filter on revision attributes."},"evaluator_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Evaluator Refs","description":"Restrict to revisions under these evaluators."},"evaluator_variant_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Evaluator Variant Refs","description":"Restrict to revisions under these variants."},"evaluator_revision_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Evaluator Revision Refs","description":"Restrict to these specific revisions."},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived","description":"When true, include soft-deleted revisions."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor-based pagination controls."}},"type":"object","title":"EvaluatorRevisionQueryRequest","description":"Body for filtering evaluator revisions. Supports scoping to evaluators, variants, or specific revisions."},"EvaluatorRevisionResolveRequest":{"properties":{"evaluator_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Resolve the latest revision of this evaluator."},"evaluator_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Resolve the latest revision on this variant."},"evaluator_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Resolve this specific revision."},"evaluator_revision":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorRevision-Input"},{"type":"null"}],"description":"Resolve the references embedded in this revision payload directly, without fetching it first. Only `data` is used; id and metadata are ignored."},"max_depth":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Depth","description":"Maximum recursion depth when following embedded references. Defaults to 10.","default":10},"max_embeds":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Embeds","description":"Maximum number of embeds to resolve. Defaults to 100.","default":100},"error_policy":{"anyOf":[{"$ref":"#/components/schemas/ErrorPolicy"},{"type":"null"}],"description":"How to handle embed-resolution errors (`exception` or `fallback`).","default":"exception"}},"type":"object","title":"EvaluatorRevisionResolveRequest","description":"Body for resolving embedded references on an evaluator revision's `data`."},"EvaluatorRevisionResolveResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"1 when a revision was resolved, 0 otherwise.","default":0},"evaluator_revision":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorRevision-Output"},{"type":"null"}],"description":"The resolved revision."},"resolution_info":{"anyOf":[{"$ref":"#/components/schemas/ResolutionInfo"},{"type":"null"}],"description":"Diagnostic information about the resolution pass (depth, embed count, errors)."},"retrieval_info":{"anyOf":[{"$ref":"#/components/schemas/RetrievalInfo"},{"type":"null"}],"description":"References (artifact / variant / revision) actually used to retrieve this revision."}},"type":"object","title":"EvaluatorRevisionResolveResponse","description":"Envelope for a resolved evaluator revision."},"EvaluatorRevisionResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"1 when a revision is returned, 0 otherwise.","default":0},"evaluator_revision":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorRevision-Output"},{"type":"null"}],"description":"The evaluator revision, or null when none matched."},"resolution_info":{"anyOf":[{"$ref":"#/components/schemas/ResolutionInfo"},{"type":"null"}],"description":"Embed-resolution metadata. Populated when `resolve=true` was requested."},"retrieval_info":{"anyOf":[{"$ref":"#/components/schemas/RetrievalInfo"},{"type":"null"}],"description":"References used to retrieve the top-level revision."}},"type":"object","title":"EvaluatorRevisionResponse","description":"Envelope for a single evaluator revision."},"EvaluatorRevisionRetrieveRequest":{"properties":{"evaluator_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Evaluator artifact to look up. Identifies the artifact by `id` or `slug` (both project-unique). When no variant_ref or revision_ref is provided, returns the latest revision of the evaluator's default variant."},"evaluator_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Evaluator variant to look up. Identifies the variant by `id` or `slug` (both project-unique). When no revision_ref is provided, returns the latest revision of this variant."},"evaluator_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Evaluator revision to look up. `id` alone identifies a revision (project-unique). `slug` alone identifies a revision (project-unique). `version` alone is a per-variant sequence number and is **not** sufficient on its own; it must be combined with an `evaluator_variant_ref`. Sending only `version` without a variant ref returns HTTP 400."},"environment_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Environment to resolve through. Requires `key`."},"environment_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Environment variant to resolve through. Requires `key`."},"environment_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Specific environment revision to resolve through. Requires `key`."},"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key","description":"Named deployment key inside the environment revision. Required with environment refs."},"resolve":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Resolve","description":"When true, resolve embedded references on the returned revision's `data`."}},"type":"object","title":"EvaluatorRevisionRetrieveRequest","description":"Body for retrieving one revision, either by direct reference or through an environment key.\n\nProvide an evaluator / variant / revision reference, an environment\nreference (with `key` derived from the evaluator slug by default), or a\ncombination of both. Every reference supplied must agree with the\nresolved revision; contradictions return HTTP 400."},"EvaluatorRevisionsLog":{"properties":{"evaluator_revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Revision Id"},"workflow_revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Revision Id"},"evaluator_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Variant Id"},"workflow_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"},"evaluator_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"depth":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Depth"}},"type":"object","title":"EvaluatorRevisionsLog"},"EvaluatorRevisionsLogRequest":{"properties":{"evaluator_revisions":{"$ref":"#/components/schemas/EvaluatorRevisionsLog","description":"Log request scoped to an evaluator / variant / revision by id, slug, or version."}},"type":"object","required":["evaluator_revisions"],"title":"EvaluatorRevisionsLogRequest","description":"Body for listing the revision log of an evaluator variant."},"EvaluatorRevisionsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of revisions in `evaluator_revisions`.","default":0},"evaluator_revisions":{"items":{"$ref":"#/components/schemas/EvaluatorRevision-Output"},"type":"array","title":"Evaluator Revisions","description":"Matching evaluator revisions."}},"type":"object","title":"EvaluatorRevisionsResponse","description":"Envelope for a list of evaluator revisions."},"EvaluatorTemplate":{"properties":{"name":{"type":"string","title":"Name","description":"Human-readable template name."},"key":{"type":"string","title":"Key","description":"Stable template identifier, used to create evaluators from the template."},"direct_use":{"type":"boolean","title":"Direct Use","description":"Whether the template can be used without further configuration."},"settings_presets":{"anyOf":[{"items":{"additionalProperties":true,"type":"object"},"type":"array"},{"type":"null"}],"title":"Settings Presets","description":"Preset parameter configurations shipped with the template."},"settings_template":{"additionalProperties":true,"type":"object","title":"Settings Template","description":"JSON Schema describing the template's configurable parameters."},"outputs_schema":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Outputs Schema","description":"JSON Schema describing the template's evaluator output shape."},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","description":"Template description."},"oss":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Oss","description":"True when the template is available in OSS builds.","default":false},"requires_llm_api_keys":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Requires Llm Api Keys","description":"True when the template calls an LLM provider and requires an API key.","default":false},"tags":{"items":{"type":"string"},"type":"array","title":"Tags","description":"Tags for grouping templates."},"archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Archived","description":"True when the template is deprecated. Hidden unless `include_archived=true`.","default":false}},"type":"object","required":["name","key","direct_use","settings_template"],"title":"EvaluatorTemplate","description":"Static evaluator template definition (built-in evaluator types).\n\nTemplates are shipped with the product and describe the available\nevaluator types. They are read-only and separate from user-owned\nevaluator artifacts."},"EvaluatorTemplatesResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of templates in `templates`.","default":0},"templates":{"items":{"$ref":"#/components/schemas/EvaluatorTemplate"},"type":"array","title":"Templates","description":"Built-in evaluator templates."}},"type":"object","title":"EvaluatorTemplatesResponse","description":"Envelope for a list of evaluator templates."},"EvaluatorVariant":{"properties":{"evaluator_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Evaluator Slug"},"workflow_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Slug"},"evaluator_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorVariantFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"EvaluatorVariant"},"EvaluatorVariantCreate":{"properties":{"evaluator_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluator Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"EvaluatorVariantCreate"},"EvaluatorVariantCreateRequest":{"properties":{"evaluator_variant":{"$ref":"#/components/schemas/EvaluatorVariantCreate","description":"Variant payload. Requires the parent `evaluator_id`."}},"type":"object","required":["evaluator_variant"],"title":"EvaluatorVariantCreateRequest","description":"Body for creating a new variant on an existing evaluator."},"EvaluatorVariantEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"EvaluatorVariantEdit"},"EvaluatorVariantEditRequest":{"properties":{"evaluator_variant":{"$ref":"#/components/schemas/EvaluatorVariantEdit","description":"Variant edit payload. Requires the variant `id`."}},"type":"object","required":["evaluator_variant"],"title":"EvaluatorVariantEditRequest","description":"Body for editing a variant's metadata."},"EvaluatorVariantFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false}},"type":"object","title":"EvaluatorVariantFlags"},"EvaluatorVariantFork":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorVariantFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"EvaluatorVariantFork"},"EvaluatorVariantForkRequest":{"properties":{"evaluator_variant":{"$ref":"#/components/schemas/EvaluatorVariantFork","description":"Config for the new variant (slug, name, description, flags)."},"evaluator_variant_ref":{"$ref":"#/components/schemas/Reference","description":"Source variant to fork from."},"evaluator_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Pin the fork to this revision; defaults to the source variant's head."}},"type":"object","required":["evaluator_variant","evaluator_variant_ref"],"title":"EvaluatorVariantForkRequest"},"EvaluatorVariantResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"1 when a variant is returned, 0 otherwise.","default":0},"evaluator_variant":{"anyOf":[{"$ref":"#/components/schemas/EvaluatorVariant"},{"type":"null"}],"description":"The evaluator variant, or null when none matched."}},"type":"object","title":"EvaluatorVariantResponse","description":"Envelope for a single evaluator variant."},"EvaluatorVariantsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of variants in `evaluator_variants`.","default":0},"evaluator_variants":{"items":{"$ref":"#/components/schemas/EvaluatorVariant"},"type":"array","title":"Evaluator Variants","description":"Matching evaluator variants."}},"type":"object","title":"EvaluatorVariantsResponse","description":"Envelope for a list of evaluator variants."},"EvaluatorsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of evaluators in `evaluators`.","default":0},"evaluators":{"items":{"$ref":"#/components/schemas/Evaluator"},"type":"array","title":"Evaluators","description":"Matching evaluator artifacts."}},"type":"object","title":"EvaluatorsResponse","description":"Envelope for a list of evaluators."},"Event":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"event_id":{"type":"string","format":"uuid","title":"Event Id"},"request_id":{"type":"string","format":"uuid","title":"Request Id"},"request_type":{"$ref":"#/components/schemas/RequestType"},"event_type":{"$ref":"#/components/schemas/EventType"},"timestamp":{"type":"string","format":"date-time","title":"Timestamp"},"status_code":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status Code"},"status_message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status Message"},"attributes":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Attributes"}},"type":"object","required":["event_id","request_id","request_type","event_type","timestamp"],"title":"Event"},"EventQuery":{"properties":{"request_type":{"anyOf":[{"$ref":"#/components/schemas/RequestType"},{"type":"null"}]},"request_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Request Id"},"event_type":{"anyOf":[{"$ref":"#/components/schemas/EventType"},{"type":"null"}]},"event_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Event Id"}},"type":"object","title":"EventQuery"},"EventQueryRequest":{"properties":{"event":{"anyOf":[{"$ref":"#/components/schemas/EventQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"EventQueryRequest"},"EventType":{"type":"string","enum":["unknown","webhooks.subscriptions.tested","traces.fetched","traces.queried","queries.revisions.retrieved","queries.revisions.fetched","queries.revisions.queried","queries.revisions.logged","queries.revisions.committed","testcases.fetched","testcases.queried","testsets.revisions.retrieved","testsets.revisions.fetched","testsets.revisions.queried","testsets.revisions.logged","testsets.revisions.committed","workflows.revisions.retrieved","workflows.revisions.fetched","workflows.revisions.queried","workflows.revisions.logged","workflows.revisions.committed","environments.revisions.retrieved","environments.revisions.fetched","environments.revisions.queried","environments.revisions.logged","environments.revisions.committed"],"title":"EventType"},"EventsQueryResponse":{"properties":{"count":{"type":"integer","title":"Count"},"events":{"items":{"$ref":"#/components/schemas/Event"},"type":"array","title":"Events"}},"type":"object","required":["count","events"],"title":"EventsQueryResponse"},"ExistenceOperator":{"type":"string","enum":["exists","not_exists"],"title":"ExistenceOperator"},"Filtering-Input":{"properties":{"operator":{"$ref":"#/components/schemas/LogicalOperator","default":"and"},"conditions":{"items":{"anyOf":[{"$ref":"#/components/schemas/Condition"},{"$ref":"#/components/schemas/Filtering-Input"}]},"type":"array","title":"Conditions","default":[]}},"type":"object","title":"Filtering"},"Filtering-Output":{"properties":{"operator":{"$ref":"#/components/schemas/LogicalOperator","default":"and"},"conditions":{"items":{"anyOf":[{"$ref":"#/components/schemas/Condition"},{"$ref":"#/components/schemas/Filtering-Output"}]},"type":"array","title":"Conditions","default":[]}},"type":"object","title":"Filtering"},"Focus":{"type":"string","enum":["trace","span"],"title":"Focus"},"Folder":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"kind":{"anyOf":[{"$ref":"#/components/schemas/FolderKind"},{"type":"null"}],"description":"Resource family this folder organizes. Only `applications` is defined today, and it also covers workflows, evaluators, and testsets (they share the artifact table)."},"path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path","description":"Dot-separated materialized path built from the folder's slug and its ancestors' slugs. Read-only; derived by the server."},"parent_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Parent Id","description":"Id of the parent folder, or `null` for a root folder."}},"type":"object","title":"Folder"},"FolderCreate":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"kind":{"anyOf":[{"$ref":"#/components/schemas/FolderKind"},{"type":"null"}],"description":"Resource family the folder organizes. Defaults to `applications` when omitted."},"parent_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Parent Id","description":"Id of the parent folder. Omit or set to `null` to create a root folder."}},"type":"object","title":"FolderCreate"},"FolderCreateRequest":{"properties":{"folder":{"$ref":"#/components/schemas/FolderCreate","description":"Folder to create. `slug` is required; `parent_id` nests the new folder under an existing one."}},"type":"object","required":["folder"],"title":"FolderCreateRequest"},"FolderEdit":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"kind":{"anyOf":[{"$ref":"#/components/schemas/FolderKind"},{"type":"null"}],"description":"Resource family. Must match the current folder's kind; defaults to `applications`."},"parent_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Parent Id","description":"New parent folder id. Include the key with a `null` value to move the folder to the root; omit the key to keep the existing parent."}},"type":"object","title":"FolderEdit"},"FolderEditRequest":{"properties":{"folder":{"$ref":"#/components/schemas/FolderEdit","description":"Folder edit payload. `id` must match the path parameter. Only fields present in the payload are changed."}},"type":"object","required":["folder"],"title":"FolderEditRequest"},"FolderIdResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` if a folder was deleted, `0` if no folder matched.","default":0},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id","description":"Id of the deleted folder. Omitted when nothing was deleted."}},"type":"object","title":"FolderIdResponse"},"FolderKind":{"type":"string","enum":["applications"],"title":"FolderKind"},"FolderQuery":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id","description":"Match a single folder id."},"ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Ids","description":"Match any of the given folder ids."},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug","description":"Match a folder by slug, regardless of its position in the tree."},"slugs":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Slugs","description":"Match folders whose slug is in the given list."},"kind":{"anyOf":[{"$ref":"#/components/schemas/FolderKind"},{"type":"null"}],"description":"Match folders of a single resource family."},"kinds":{"anyOf":[{"type":"boolean"},{"items":{"$ref":"#/components/schemas/FolderKind"},"type":"array"},{"type":"null"}],"title":"Kinds","description":"Filter by presence of a kind. `false` returns folders with no kind, `true` returns folders where `kind` is set, and an array restricts to the given kinds."},"parent_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Parent Id","description":"Match folders whose parent is this id. Send `null` to return only root folders."},"parent_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Parent Ids","description":"Match folders whose parent is any of the given ids."},"path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path","description":"Exact match on the materialized `path` (e.g. `support.prod`)."},"paths":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Paths","description":"Exact match on any of the given paths."},"prefix":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Prefix","description":"Subtree lookup: returns the folder at this path and every descendant."},"prefixes":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Prefixes","description":"Subtree lookup across multiple prefixes, OR-ed together."}},"type":"object","title":"FolderQuery"},"FolderQueryRequest":{"properties":{"folder":{"$ref":"#/components/schemas/FolderQuery","description":"Filter object. Any combination of `id`/`ids`, `slug`/`slugs`, `kind`/`kinds`, `parent_id`/`parent_ids`, `path`/`paths`, and `prefix`/`prefixes` narrows the result."}},"type":"object","required":["folder"],"title":"FolderQueryRequest"},"FolderResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of folders returned (`0` or `1`).","default":0},"folder":{"anyOf":[{"$ref":"#/components/schemas/Folder"},{"type":"null"}],"description":"The folder, when found. Omitted when `count` is `0`."}},"type":"object","title":"FolderResponse"},"FoldersResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of folders in `folders`.","default":0},"folders":{"items":{"$ref":"#/components/schemas/Folder"},"type":"array","title":"Folders","description":"Matching folders for the query. Ordering is not guaranteed."}},"type":"object","title":"FoldersResponse"},"Format":{"type":"string","enum":["agenta","opentelemetry"],"title":"Format"},"Formatting":{"properties":{"focus":{"anyOf":[{"$ref":"#/components/schemas/Focus"},{"type":"null"}]},"format":{"anyOf":[{"$ref":"#/components/schemas/Format"},{"type":"null"}]}},"type":"object","title":"Formatting"},"FullJson-Input":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"number"},{"type":"boolean"},{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"items":{"$ref":"#/components/schemas/FullJson-Input"},"type":"array"},{"type":"null"}]},"FullJson-Output":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"number"},{"type":"boolean"},{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"items":{"$ref":"#/components/schemas/FullJson-Output"},"type":"array"},{"type":"null"}]},"GatewayConnectionPolicy":{"properties":{"permissions":{"$ref":"#/components/schemas/GatewayPermissions"}},"additionalProperties":false,"type":"object","required":["permissions"],"title":"GatewayConnectionPolicy","description":"The ``policy`` node of the saved entry. It mirrors the saved nesting and holds one\nfield on purpose, so a later policy of a different kind has a place to go."},"GatewayConnectionRef":{"properties":{"provider":{"type":"string","const":"composio","title":"Provider","default":"composio"},"integration":{"type":"string","minLength":1,"title":"Integration"},"slug":{"type":"string","minLength":1,"title":"Slug"}},"additionalProperties":false,"type":"object","required":["integration","slug"],"title":"GatewayConnectionRef","description":"The shared project connection a gateway entry points at.\n\nA resource reference, never a credential: the project owns the connection and several\nagents can reuse it, each with its own policy."},"GatewayConnectionToolConfig":{"properties":{"type":{"type":"string","const":"gateway_connection","title":"Type","default":"gateway_connection"},"connection":{"$ref":"#/components/schemas/GatewayConnectionRef"},"policy":{"$ref":"#/components/schemas/GatewayConnectionPolicy"}},"additionalProperties":false,"type":"object","required":["connection","policy"],"title":"GatewayConnectionToolConfig","description":"One whole integration, with a policy the SDK compiles into per-tool decisions.\n\nReplaces the one-entry-per-tool :class:`GatewayToolConfig`, which stays readable while\nsaved revisions migrate. The entry carries no credentials, provider account IDs, tool\nschemas, or read-only hints: those are resolved data, not authored configuration.\n\nIt does not extend :class:`ToolConfigBase`. That base carries a per-tool ``render`` and\n``permission``, and an entry that covers a whole integration has no single tool to apply\neither to. Every permission here lives in ``policy``, so a top-level one is refused\ninstead of accepted and then ignored, which would let an author believe a `deny` applies\nwhen nothing reads it. The deleted legacy permission spellings are refused here for the\nsame reason, rather than dropped in silence as they are on the other arms."},"GatewayPermissions":{"properties":{"default":{"type":"string","enum":["inherit","allow","ask","deny"],"title":"Default"},"tools":{"additionalProperties":{"type":"string","enum":["inherit","allow","ask","deny"]},"propertyNames":{"minLength":1},"type":"object","title":"Tools"}},"additionalProperties":false,"type":"object","required":["default"],"title":"GatewayPermissions","description":"What the agent may do through one connection, per tool key."},"GatewayToolConfig":{"properties":{"render":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Render"},"permission":{"anyOf":[{"type":"string","enum":["allow","ask","deny"]},{"type":"null"}],"title":"Permission"},"type":{"type":"string","const":"gateway","title":"Type","default":"gateway"},"provider":{"type":"string","minLength":1,"title":"Provider","default":"composio"},"integration":{"type":"string","minLength":1,"title":"Integration"},"action":{"type":"string","minLength":1,"title":"Action"},"connection":{"type":"string","minLength":1,"title":"Connection"},"name":{"anyOf":[{"type":"string","minLength":1},{"type":"null"}],"title":"Name"}},"additionalProperties":false,"type":"object","required":["integration","action","connection"],"title":"GatewayToolConfig"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"HarnessKind":{"type":"string","enum":["pi_core","claude","codex"],"title":"HarnessKind","description":"The coding agent program a run drives. A backend declares which it supports.\n\n``pi_core`` is Pi; ``claude`` drives Claude Code; ``codex`` drives Codex."},"Header":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},"type":"object","title":"Header"},"InviteRequest":{"properties":{"email":{"type":"string","title":"Email"},"roles":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Roles"}},"type":"object","required":["email"],"title":"InviteRequest"},"InviteToken":{"properties":{"token":{"type":"string","title":"Token"},"email":{"type":"string","title":"Email"}},"type":"object","required":["token","email"],"title":"InviteToken"},"Invocation":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"span_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Id"},"trace_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Trace Id"},"attributes":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Attributes"},"origin":{"$ref":"#/components/schemas/SimpleTraceOrigin","default":"custom"},"kind":{"$ref":"#/components/schemas/SimpleTraceKind","default":"adhoc"},"channel":{"$ref":"#/components/schemas/SimpleTraceChannel","default":"api"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"data":{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object","title":"Data"},"references":{"$ref":"#/components/schemas/SimpleTraceReferences"},"links":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/OTelLink-Output"},"type":"object"},{"items":{"$ref":"#/components/schemas/OTelLink-Output"},"type":"array"},{"type":"null"}],"title":"Links"}},"type":"object","required":["data","references","links"],"title":"Invocation"},"InvocationCreate":{"properties":{"origin":{"$ref":"#/components/schemas/SimpleTraceOrigin","default":"custom"},"kind":{"$ref":"#/components/schemas/SimpleTraceKind","default":"adhoc"},"channel":{"$ref":"#/components/schemas/SimpleTraceChannel","default":"api"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"data":{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object","title":"Data"},"references":{"$ref":"#/components/schemas/SimpleTraceReferences"},"links":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"object"},{"items":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"array"},{"type":"null"}],"title":"Links"}},"type":"object","required":["data","references","links"],"title":"InvocationCreate"},"InvocationCreateRequest":{"properties":{"invocation":{"$ref":"#/components/schemas/InvocationCreate"}},"type":"object","required":["invocation"],"title":"InvocationCreateRequest"},"InvocationEdit":{"properties":{"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"data":{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object","title":"Data"},"references":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceReferences"},{"type":"null"}]},"links":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"object"},{"items":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"array"},{"type":"null"}],"title":"Links"}},"type":"object","required":["data"],"title":"InvocationEdit"},"InvocationEditRequest":{"properties":{"invocation":{"$ref":"#/components/schemas/InvocationEdit"}},"type":"object","required":["invocation"],"title":"InvocationEditRequest"},"InvocationLinkResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"invocation_link":{"anyOf":[{"$ref":"#/components/schemas/OTelLink-Output"},{"type":"null"}]}},"type":"object","title":"InvocationLinkResponse"},"InvocationQuery":{"properties":{"origin":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceOrigin"},{"type":"null"}]},"kind":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceKind"},{"type":"null"}]},"channel":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceChannel"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"references":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceReferences"},{"type":"null"}]},"links":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"object"},{"items":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"array"},{"type":"null"}],"title":"Links"}},"type":"object","title":"InvocationQuery"},"InvocationQueryRequest":{"properties":{"invocation":{"anyOf":[{"$ref":"#/components/schemas/InvocationQuery"},{"type":"null"}]},"invocation_links":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"array"},{"type":"null"}],"title":"Invocation Links"},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"InvocationQueryRequest"},"InvocationResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"invocation":{"anyOf":[{"$ref":"#/components/schemas/Invocation"},{"type":"null"}]}},"type":"object","title":"InvocationResponse"},"InvocationsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"invocations":{"items":{"$ref":"#/components/schemas/Invocation"},"type":"array","title":"Invocations","default":[]}},"type":"object","title":"InvocationsResponse"},"JsonSchemas-Input":{"properties":{"parameters":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Parameters"},"inputs":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Inputs"},"outputs":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Outputs"}},"type":"object","title":"JsonSchemas"},"JsonSchemas-Output":{"properties":{"parameters":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Parameters"},"inputs":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Inputs"},"outputs":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Outputs"}},"type":"object","title":"JsonSchemas"},"LabelJson-Input":{"anyOf":[{"type":"boolean"},{"type":"string"},{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"}]},"LabelJson-Output":{"anyOf":[{"type":"boolean"},{"type":"string"},{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"}]},"LegacyLifecycleDTO":{"properties":{"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Updated At"},"updated_by_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Updated By Id"},"updated_by":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Updated By"}},"type":"object","title":"LegacyLifecycleDTO"},"ListAPIKeysResponse":{"properties":{"prefix":{"type":"string","title":"Prefix"},"created_at":{"type":"string","title":"Created At"},"last_used_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Last Used At"},"expiration_date":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Expiration Date"}},"type":"object","required":["prefix","created_at"],"title":"ListAPIKeysResponse"},"ListOperator":{"type":"string","enum":["in","not_in"],"title":"ListOperator"},"ListOptions":{"properties":{"all":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"All","default":false}},"type":"object","title":"ListOptions"},"LogicalOperator":{"type":"string","enum":["and","or","not","nand","nor"],"title":"LogicalOperator"},"MetricSpec":{"properties":{"type":{"$ref":"#/components/schemas/MetricType","default":"none"},"path":{"type":"string","title":"Path","default":"*"},"bins":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Bins"},"vmin":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Vmin"},"vmax":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Vmax"},"edge":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Edge"}},"type":"object","title":"MetricSpec"},"MetricType":{"type":"string","enum":["numeric/continuous","numeric/discrete","binary","categorical/single","categorical/multiple","string","json","none","*"],"title":"MetricType"},"MetricsBucket":{"properties":{"timestamp":{"type":"string","format":"date-time","title":"Timestamp"},"interval":{"type":"integer","title":"Interval"},"metrics":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Metrics"}},"type":"object","required":["timestamp","interval"],"title":"MetricsBucket"},"Mount":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"project_id":{"type":"string","format":"uuid","title":"Project Id"},"session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Session Id"},"agent_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Id"},"purpose":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Purpose"},"data":{"$ref":"#/components/schemas/MountData"},"flags":{"$ref":"#/components/schemas/MountFlags"},"tags":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta"}},"type":"object","required":["project_id"],"title":"Mount"},"MountArchiveRequest":{"properties":{"mounts":{"items":{"$ref":"#/components/schemas/ArchiveMount"},"type":"array","title":"Mounts"},"filename":{"type":"string","title":"Filename","default":"files.zip"}},"type":"object","title":"MountArchiveRequest","description":"Zip several mounts into ONE archive (the drive folds cwd + agent-files into one tree)."},"MountCreateRequest":{"properties":{"mount":{"$ref":"#/components/schemas/PublicMountCreate"}},"type":"object","required":["mount"],"title":"MountCreateRequest"},"MountCredentials":{"properties":{"endpoint":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Endpoint"},"region":{"type":"string","title":"Region","default":"us-east-1"},"bucket":{"type":"string","title":"Bucket"},"prefix":{"type":"string","title":"Prefix"},"access_key":{"type":"string","title":"Access Key"},"secret_key":{"type":"string","title":"Secret Key"},"session_token":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Session Token"},"expires_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Expires At"}},"type":"object","required":["bucket","prefix","access_key","secret_key"],"title":"MountCredentials","description":"Short-lived, prefix-scoped credentials for a single mount.\n\nSigned API-side from the store's STS endpoint; the master key never leaves the\nAPI. Scoped to `///*` and expires within minutes,\nso a leak grants only this mount's prefix for a short window."},"MountCredentialsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"mount":{"anyOf":[{"$ref":"#/components/schemas/Mount"},{"type":"null"}]},"credentials":{"anyOf":[{"$ref":"#/components/schemas/MountCredentials"},{"type":"null"}]}},"type":"object","title":"MountCredentialsResponse"},"MountData":{"properties":{},"type":"object","title":"MountData"},"MountEdit":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"flags":{"$ref":"#/components/schemas/MountFlags"},"tags":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta"}},"type":"object","title":"MountEdit"},"MountEditRequest":{"properties":{"mount":{"$ref":"#/components/schemas/MountEdit"}},"type":"object","required":["mount"],"title":"MountEditRequest"},"MountFileDeletedResponse":{"properties":{"deleted":{"type":"string","title":"Deleted"},"count":{"type":"integer","title":"Count","default":0}},"type":"object","required":["deleted"],"title":"MountFileDeletedResponse"},"MountFileWrittenResponse":{"properties":{"path":{"type":"string","title":"Path"},"size":{"type":"integer","title":"Size","default":0}},"type":"object","required":["path"],"title":"MountFileWrittenResponse"},"MountFlags":{"properties":{},"type":"object","title":"MountFlags"},"MountFolderCreatedResponse":{"properties":{"path":{"type":"string","title":"Path"}},"type":"object","required":["path"],"title":"MountFolderCreatedResponse"},"MountQuery":{"properties":{"session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Session Id"},"agent_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Id"},"include_archived":{"type":"boolean","title":"Include Archived","default":false}},"type":"object","title":"MountQuery"},"MountQueryRequest":{"properties":{"mount":{"anyOf":[{"$ref":"#/components/schemas/MountQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"MountQueryRequest"},"MountResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"mount":{"anyOf":[{"$ref":"#/components/schemas/Mount"},{"type":"null"}]}},"type":"object","title":"MountResponse"},"MountsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"mounts":{"items":{"$ref":"#/components/schemas/Mount"},"type":"array","title":"Mounts"}},"type":"object","title":"MountsResponse"},"NumericOperator":{"type":"string","enum":["eq","neq","gt","lt","gte","lte","btwn"],"title":"NumericOperator"},"OTelEvent-Input":{"properties":{"name":{"type":"string","title":"Name"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"integer"}],"title":"Timestamp"},"attributes":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Attributes"}},"type":"object","required":["name","timestamp"],"title":"OTelEvent"},"OTelEvent-Output":{"properties":{"name":{"type":"string","title":"Name"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"integer"}],"title":"Timestamp"},"attributes":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Attributes"}},"type":"object","required":["name","timestamp"],"title":"OTelEvent"},"OTelHash-Input":{"properties":{"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"attributes":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Attributes"}},"type":"object","title":"OTelHash"},"OTelHash-Output":{"properties":{"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"attributes":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Attributes"}},"type":"object","title":"OTelHash"},"OTelLink-Input":{"properties":{"span_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Id"},"trace_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Trace Id"},"attributes":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Attributes"}},"type":"object","title":"OTelLink"},"OTelLink-Output":{"properties":{"span_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Id"},"trace_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Trace Id"},"attributes":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Attributes"}},"type":"object","title":"OTelLink"},"OTelLinksResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of spans that were accepted and published to the ingest stream. Compare against the number of spans you sent to detect partial failures.","default":0},"links":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelLink-Output"},"type":"array"},{"type":"null"}],"title":"Links","description":"List of `(trace_id, span_id)` pairs for the accepted spans, in submission order."}},"type":"object","title":"OTelLinksResponse","description":"Response from span ingestion.\n\n`count` reflects how many spans were successfully parsed and published\nto the ingest stream. If you submitted N spans and see `count < N`,\nsome spans failed server-side validation and were not persisted (check\nserver logs for details). See [Tracing — Async write\ncontract](/reference/api-guide/tracing#async-write-contract-202) for\nthe full semantics of the `202 Accepted` response."},"OTelReference-Input":{"properties":{"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"attributes":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Attributes"}},"type":"object","title":"OTelReference"},"OTelReference-Output":{"properties":{"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"attributes":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Attributes"}},"type":"object","title":"OTelReference"},"OTelSpanKind":{"type":"string","enum":["SPAN_KIND_UNSPECIFIED","SPAN_KIND_INTERNAL","SPAN_KIND_SERVER","SPAN_KIND_CLIENT","SPAN_KIND_PRODUCER","SPAN_KIND_CONSUMER"],"title":"OTelSpanKind"},"OTelStatusCode":{"type":"string","enum":["STATUS_CODE_UNSET","STATUS_CODE_OK","STATUS_CODE_ERROR"],"title":"OTelStatusCode"},"OTelTracingRequest":{"properties":{"spans":{"anyOf":[{"items":{"$ref":"#/components/schemas/Span-Input"},"type":"array"},{"type":"null"}],"title":"Spans","description":"Flat list of spans. Use this when you already have a flat list and parent/child relationships are expressed via each span's `parent_id`."},"traces":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/SpansTree-Input"},"type":"object"},{"type":"null"}],"title":"Traces","description":"Nested tree of spans keyed by `trace_id` → span name, with children under each node's `spans` field. This matches the shape returned by `POST /tracing/spans/query` with `focus=\"trace\"`."}},"type":"object","title":"OTelTracingRequest","description":"Ingest or query payload for OpenTelemetry-style spans.\n\nExactly one of `spans` or `traces` should be provided. Use `spans`\nfor a flat list (parent/child linked via `parent_id`); use `traces`\nfor a nested tree (keyed by `trace_id` then by span name, children\nhanging off each node's `spans` field). The two shapes are\ninterchangeable and the query endpoint returns the `traces` shape by\ndefault.\n\nSee [Tracing](/reference/api-guide/tracing) for the full attribute\nnamespace and the async ingest contract."},"OTelTracingResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Total number of matching traces or spans in the window.","default":0},"spans":{"anyOf":[{"items":{"$ref":"#/components/schemas/Span-Output"},"type":"array"},{"type":"null"}],"title":"Spans","description":"Flat list of spans, populated when the query was run with `focus=\"span\"`."},"traces":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/SpansTree-Output"},"type":"object"},{"type":"null"}],"title":"Traces","description":"Nested tree of spans keyed by `trace_id` → span name, populated when the query was run with `focus=\"trace\"` (default)."}},"type":"object","title":"OTelTracingResponse","description":"Response from span/trace queries.\n\nExactly one of `spans` or `traces` is populated, controlled by the\n`focus` field in the request (`\"span\"` for flat lists, `\"trace\"` for\nnested trees). The shapes here match what the ingest endpoint accepts,\nso you can round-trip data between environments."},"OldAnalyticsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of time buckets returned.","default":0},"buckets":{"items":{"$ref":"#/components/schemas/Bucket"},"type":"array","title":"Buckets","description":"Time-bucketed aggregates with fixed fields (`total`, `errors`) holding `count`, `duration`, `costs`, and `tokens`, ordered oldest to newest.","default":[]}},"type":"object","title":"OldAnalyticsResponse","description":"Legacy analytics response with a fixed metric schema."},"Organization":{"properties":{"id":{"type":"string","title":"Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"flags":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta"},"owner_id":{"type":"string","title":"Owner Id"},"members":{"items":{"type":"string"},"type":"array","title":"Members"},"invitations":{"items":{},"type":"array","title":"Invitations"},"workspaces":{"items":{"type":"string"},"type":"array","title":"Workspaces"}},"type":"object","required":["id","owner_id"],"title":"Organization"},"OrganizationDetails":{"properties":{"id":{"type":"string","title":"Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"flags":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta"},"owner_id":{"type":"string","title":"Owner Id"},"members":{"items":{"type":"string"},"type":"array","title":"Members"},"invitations":{"items":{},"type":"array","title":"Invitations"},"workspaces":{"items":{"type":"string"},"type":"array","title":"Workspaces"},"default_workspace":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Default Workspace"}},"type":"object","required":["id","owner_id"],"title":"OrganizationDetails"},"OrganizationDomainCreate":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"domain":{"type":"string","title":"Domain"}},"type":"object","required":["domain"],"title":"OrganizationDomainCreate","description":"Request model for creating a domain."},"OrganizationDomainResponse":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"slug":{"type":"string","title":"Slug"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"flags":{"additionalProperties":true,"type":"object","title":"Flags"},"token":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Token"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"organization_id":{"type":"string","format":"uuid","title":"Organization Id"}},"type":"object","required":["id","slug","name","description","flags","token","created_at","updated_at","organization_id"],"title":"OrganizationDomainResponse","description":"Response model for a domain."},"OrganizationDomainVerify":{"properties":{"domain_id":{"type":"string","title":"Domain Id"}},"type":"object","required":["domain_id"],"title":"OrganizationDomainVerify","description":"Request model for verifying a domain."},"OrganizationProviderCreate":{"properties":{"slug":{"type":"string","pattern":"^[a-z-]+$","title":"Slug"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"flags":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Flags"},"settings":{"additionalProperties":true,"type":"object","title":"Settings"}},"type":"object","required":["slug","settings"],"title":"OrganizationProviderCreate","description":"Request model for creating an SSO provider."},"OrganizationProviderResponse":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"slug":{"type":"string","title":"Slug"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"flags":{"additionalProperties":true,"type":"object","title":"Flags"},"settings":{"additionalProperties":true,"type":"object","title":"Settings"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"organization_id":{"type":"string","format":"uuid","title":"Organization Id"}},"type":"object","required":["id","slug","name","description","flags","settings","created_at","updated_at","organization_id"],"title":"OrganizationProviderResponse","description":"Response model for an SSO provider."},"OrganizationProviderUpdate":{"properties":{"slug":{"anyOf":[{"type":"string","pattern":"^[a-z-]+$"},{"type":"null"}],"title":"Slug"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"flags":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Flags"},"settings":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Settings"}},"type":"object","title":"OrganizationProviderUpdate","description":"Request model for updating an SSO provider."},"OrganizationUpdate":{"properties":{"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"flags":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Flags"},"updated_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Updated At"}},"type":"object","title":"OrganizationUpdate"},"Permission":{"type":"string","enum":["view_applications","edit_application","run_service","view_webhooks","edit_webhooks","view_secret","edit_secret","view_spans","edit_spans","view_folders","edit_folders","view_api_keys","edit_api_keys","view_workspace","edit_workspace","create_workspace","delete_workspace","modify_user_roles","add_new_user_to_workspace","edit_organization","delete_organization","add_new_user_to_organization","reset_password","view_billing","edit_billing","view_workflows","edit_workflows","run_workflows","view_evaluators","edit_evaluators","view_environments","edit_environments","deploy_environments","view_queries","edit_queries","view_testsets","edit_testsets","view_annotations","edit_annotations","view_invocations","edit_invocations","view_evaluation_runs","edit_evaluation_runs","view_evaluation_scenarios","edit_evaluation_scenarios","view_evaluation_results","edit_evaluation_results","view_evaluation_metrics","edit_evaluation_metrics","view_evaluation_queues","edit_evaluation_queues","view_events","view_tools","edit_tools","run_tools","view_triggers","edit_triggers","run_triggers","view_sessions","edit_sessions","run_sessions","view_mounts","edit_mounts","use_mounts"],"title":"Permission"},"PlaygroundBuildKitContext":{"properties":{"agent_template_overlay":{"anyOf":[{"$ref":"#/components/schemas/AgentTemplateOverlay"},{"type":"null"}],"description":"Partial `parameters.agent` overlay applied by the playground only."}},"type":"object","title":"PlaygroundBuildKitContext","description":"Read-only playground build-kit context for one inspect/fetch response."},"PopulateSliceRequest":{"properties":{"results":{"items":{"$ref":"#/components/schemas/EvaluationResultCreate"},"type":"array","title":"Results"}},"type":"object","required":["results"],"title":"PopulateSliceRequest"},"ProbeProviderRequest":{"properties":{"kind":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Kind","description":"Provider kind, e.g. 'openai', 'azure', 'custom'. Optional when `secret_id` is given: the stored kind is used unless this overrides it."},"provider":{"$ref":"#/components/schemas/ProviderCredentials"},"secret_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Secret Id","description":"Test the credential stored under this secret, in the caller's project. Fields sent in `provider` override the stored ones."}},"type":"object","title":"ProbeProviderRequest","description":"The credential to test. It is spent on one read and never persisted.\n\n`kind` is a StandardProviderKind or CustomProviderKind value; `provider` carries the\nsame field vocabulary the vault stores, so a card can probe what it is about to save\nwithout reshaping it.\n\n`secret_id` names a connection already stored in the caller's project, and is how a\nwrite-only connection is testable at all: its value never comes back to the browser,\nso there is nothing for the card to send. The stored kind and credentials are the\nbase; anything typed in this request replaces the stored value for that field, which\nis what lets a card test an edit — a new base URL, say — before saving it."},"ProbeProviderResponse":{"properties":{"credential":{"$ref":"#/components/schemas/CredentialResult"},"discovery":{"$ref":"#/components/schemas/DiscoveryResult"},"fetched_at":{"type":"string","format":"date-time","title":"Fetched At"}},"type":"object","required":["credential","discovery","fetched_at"],"title":"ProbeProviderResponse"},"ProbeSliceRequest":{"properties":{"scenario_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Scenario Ids"},"step_keys":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Step Keys"},"repeat_idxs":{"anyOf":[{"items":{"type":"integer"},"type":"array"},{"type":"null"}],"title":"Repeat Idxs"}},"type":"object","title":"ProbeSliceRequest"},"ProcessSliceRequest":{"properties":{"scenario_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Scenario Ids"},"step_keys":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Step Keys"},"repeat_idxs":{"anyOf":[{"items":{"type":"integer"},"type":"array"},{"type":"null"}],"title":"Repeat Idxs"},"overwrite":{"type":"boolean","title":"Overwrite","default":false}},"type":"object","title":"ProcessSliceRequest"},"ProjectsResponse":{"properties":{"organization_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Organization Id"},"organization_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Organization Name"},"workspace_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workspace Id"},"workspace_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workspace Name"},"project_id":{"type":"string","format":"uuid","title":"Project Id"},"project_name":{"type":"string","title":"Project Name"},"is_default_project":{"type":"boolean","title":"Is Default Project","default":false},"user_role":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"User Role"},"is_demo":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Demo"}},"type":"object","required":["project_id","project_name"],"title":"ProjectsResponse"},"ProviderCredentials":{"properties":{"key":{"anyOf":[{"type":"string","format":"password","writeOnly":true},{"type":"null"}],"title":"Key"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"extras":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Extras"}},"type":"object","title":"ProviderCredentials","description":"Credentials in transit only. Never persisted here, never logged, never echoed.\n\n`key` is a `SecretStr` and `extras` is kept out of `repr`, so an accidental log line\nor traceback that carries this object cannot print the credential. Unwrap the key with\n`.get_secret_value()` at the point it is put on the wire, never earlier."},"PruneSliceRequest":{"properties":{"scenario_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Scenario Ids"},"step_keys":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Step Keys"},"repeat_idxs":{"anyOf":[{"items":{"type":"integer"},"type":"array"},{"type":"null"}],"title":"Repeat Idxs"}},"type":"object","title":"PruneSliceRequest"},"PublicMountCreate":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Session Id"},"agent_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Id"},"flags":{"$ref":"#/components/schemas/MountFlags"},"tags":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta"}},"additionalProperties":false,"type":"object","title":"PublicMountCreate"},"PublicSecretManagementDTO":{"properties":{"policy":{"$ref":"#/components/schemas/SecretManagementPolicy"}},"additionalProperties":false,"type":"object","required":["policy"],"title":"PublicSecretManagementDTO"},"PublicSecretResponseDTO":{"properties":{"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"kind":{"$ref":"#/components/schemas/SecretKind"},"data":{"anyOf":[{"$ref":"#/components/schemas/StandardProviderDTO"},{"$ref":"#/components/schemas/CustomProviderDTO"},{"$ref":"#/components/schemas/SSOProviderDTO"},{"$ref":"#/components/schemas/WebhookProviderDTO"},{"$ref":"#/components/schemas/CustomSecretDTO"}],"title":"Data"},"header":{"$ref":"#/components/schemas/Header"},"lifecycle":{"anyOf":[{"$ref":"#/components/schemas/LegacyLifecycleDTO"},{"type":"null"}]},"write_only":{"type":"boolean","title":"Write Only","default":false},"management":{"anyOf":[{"$ref":"#/components/schemas/PublicSecretManagementDTO"},{"type":"null"}]},"value_status":{"$ref":"#/components/schemas/SecretValueStatus"}},"type":"object","required":["kind","data","header","value_status"],"title":"PublicSecretResponseDTO","description":"Caller-facing representation after grant-aware value projection."},"QueriesResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"queries":{"items":{"$ref":"#/components/schemas/Query"},"type":"array","title":"Queries","default":[]}},"type":"object","title":"QueriesResponse"},"Query":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/QueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"Query"},"QueryCreate":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/QueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"QueryCreate"},"QueryCreateRequest":{"properties":{"query":{"$ref":"#/components/schemas/QueryCreate"}},"type":"object","required":["query"],"title":"QueryCreateRequest"},"QueryEdit":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/QueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"QueryEdit"},"QueryEditRequest":{"properties":{"query":{"$ref":"#/components/schemas/QueryEdit"}},"type":"object","required":["query"],"title":"QueryEditRequest"},"QueryFlags":{"properties":{},"type":"object","title":"QueryFlags"},"QueryQueryFlags":{"properties":{},"type":"object","title":"QueryQueryFlags"},"QueryResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"query":{"anyOf":[{"$ref":"#/components/schemas/Query"},{"type":"null"}]}},"type":"object","title":"QueryResponse"},"QueryRevision":{"properties":{"query_variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Query Variant Slug"},"variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Variant Slug"},"query_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Query Slug"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"query_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Query Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"query_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Query Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"author":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Author"},"date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Date"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/QueryRevisionData-Output"},{"type":"null"}]}},"type":"object","title":"QueryRevision"},"QueryRevisionCommit":{"properties":{"query_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Query Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"query_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Query Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"data":{"anyOf":[{"$ref":"#/components/schemas/QueryRevisionData-Input"},{"type":"null"}]},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"}},"type":"object","title":"QueryRevisionCommit"},"QueryRevisionCommitRequest":{"properties":{"query_revision":{"$ref":"#/components/schemas/QueryRevisionCommit"}},"type":"object","required":["query_revision"],"title":"QueryRevisionCommitRequest"},"QueryRevisionCreate":{"properties":{"query_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Query Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"query_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Query Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"QueryRevisionCreate"},"QueryRevisionCreateRequest":{"properties":{"query_revision":{"$ref":"#/components/schemas/QueryRevisionCreate"}},"type":"object","required":["query_revision"],"title":"QueryRevisionCreateRequest"},"QueryRevisionData-Input":{"properties":{"formatting":{"anyOf":[{"$ref":"#/components/schemas/Formatting"},{"type":"null"}]},"filtering":{"anyOf":[{"$ref":"#/components/schemas/Filtering-Input"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]},"trace_ids":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Trace Ids"},"traces":{"anyOf":[{"items":{"$ref":"#/components/schemas/Trace-Input"},"type":"array"},{"type":"null"}],"title":"Traces"}},"additionalProperties":false,"type":"object","title":"QueryRevisionData"},"QueryRevisionData-Output":{"properties":{"formatting":{"anyOf":[{"$ref":"#/components/schemas/Formatting"},{"type":"null"}]},"filtering":{"anyOf":[{"$ref":"#/components/schemas/Filtering-Output"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]},"trace_ids":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Trace Ids"},"traces":{"anyOf":[{"items":{"$ref":"#/components/schemas/Trace-Output"},"type":"array"},{"type":"null"}],"title":"Traces"}},"additionalProperties":false,"type":"object","title":"QueryRevisionData"},"QueryRevisionEdit":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"QueryRevisionEdit"},"QueryRevisionEditRequest":{"properties":{"query_revision":{"$ref":"#/components/schemas/QueryRevisionEdit"}},"type":"object","required":["query_revision"],"title":"QueryRevisionEditRequest"},"QueryRevisionQuery":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"slugs":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Slugs"},"author":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Author"},"authors":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Authors"},"date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Date"},"dates":{"anyOf":[{"items":{"type":"string","format":"date-time"},"type":"array"},{"type":"null"}],"title":"Dates"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"}},"type":"object","title":"QueryRevisionQuery"},"QueryRevisionQueryRequest":{"properties":{"query_revision":{"anyOf":[{"$ref":"#/components/schemas/QueryRevisionQuery"},{"type":"null"}]},"query_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Query Refs"},"query_variant_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Query Variant Refs"},"query_revision_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Query Revision Refs"},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"QueryRevisionQueryRequest"},"QueryRevisionResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"query_revision":{"anyOf":[{"$ref":"#/components/schemas/QueryRevision"},{"type":"null"}]},"retrieval_info":{"anyOf":[{"$ref":"#/components/schemas/RetrievalInfo"},{"type":"null"}]}},"type":"object","title":"QueryRevisionResponse"},"QueryRevisionRetrieveRequest":{"properties":{"query_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Query artifact to look up. Identifies the artifact by `id` or `slug` (both project-unique). When no variant_ref or revision_ref is provided, returns the latest revision of an arbitrary variant of this query."},"query_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Query variant to look up. Identifies the variant by `id` or `slug` (both project-unique). When no revision_ref is provided, returns the latest revision of this variant."},"query_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Query revision to look up. `id` alone identifies a revision (project-unique). `slug` alone identifies a revision (project-unique). `version` alone is a per-variant sequence number and is **not** sufficient on its own; it must be combined with a `query_variant_ref`. Sending only `version` without a variant ref returns HTTP 400."},"include_trace_ids":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Trace Ids"},"include_traces":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Traces"},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"QueryRevisionRetrieveRequest"},"QueryRevisionsLog":{"properties":{"query_revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Query Revision Id"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"},"query_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Query Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"query_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Query Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"depth":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Depth"}},"type":"object","title":"QueryRevisionsLog"},"QueryRevisionsLogRequest":{"properties":{"query_revisions":{"$ref":"#/components/schemas/QueryRevisionsLog"}},"type":"object","required":["query_revisions"],"title":"QueryRevisionsLogRequest"},"QueryRevisionsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"query_revisions":{"items":{"$ref":"#/components/schemas/QueryRevision"},"type":"array","title":"Query Revisions","default":[]}},"type":"object","title":"QueryRevisionsResponse"},"QueryVariant":{"properties":{"query_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Query Slug"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"query_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Query Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"QueryVariant"},"QueryVariantCreate":{"properties":{"query_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Query Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"QueryVariantCreate"},"QueryVariantCreateRequest":{"properties":{"query_variant":{"$ref":"#/components/schemas/QueryVariantCreate"}},"type":"object","required":["query_variant"],"title":"QueryVariantCreateRequest"},"QueryVariantEdit":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"QueryVariantEdit"},"QueryVariantEditRequest":{"properties":{"query_variant":{"$ref":"#/components/schemas/QueryVariantEdit"}},"type":"object","required":["query_variant"],"title":"QueryVariantEditRequest"},"QueryVariantFork":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"QueryVariantFork"},"QueryVariantForkRequest":{"properties":{"query_variant":{"$ref":"#/components/schemas/QueryVariantFork","description":"Config for the new variant (slug, name, description, flags)."},"query_variant_ref":{"$ref":"#/components/schemas/Reference","description":"Source variant to fork from."},"query_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Pin the fork to this revision; defaults to the source variant's head."}},"type":"object","required":["query_variant","query_variant_ref"],"title":"QueryVariantForkRequest"},"QueryVariantQuery":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"slugs":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Slugs"}},"type":"object","title":"QueryVariantQuery"},"QueryVariantQueryRequest":{"properties":{"query_variant":{"anyOf":[{"$ref":"#/components/schemas/QueryVariantQuery"},{"type":"null"}]},"query_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Query Refs"},"query_variant_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Query Variant Refs"},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"QueryVariantQueryRequest"},"QueryVariantResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"query_variant":{"anyOf":[{"$ref":"#/components/schemas/QueryVariant"},{"type":"null"}]}},"type":"object","title":"QueryVariantResponse"},"QueryVariantsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"query_variants":{"items":{"$ref":"#/components/schemas/QueryVariant"},"type":"array","title":"Query Variants","default":[]}},"type":"object","title":"QueryVariantsResponse"},"Reference":{"properties":{"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"Reference"},"ReferenceRequestModel-Input":{"properties":{"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"commit_message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Commit Message"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"ReferenceRequestModel"},"ReferenceRequestModel-Output":{"properties":{"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"version":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Version"},"commit_message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Commit Message"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"ReferenceRequestModel"},"RefreshSliceRequest":{"properties":{"scenario_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Scenario Ids"},"step_keys":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Step Keys"},"repeat_idxs":{"anyOf":[{"items":{"type":"integer"},"type":"array"},{"type":"null"}],"title":"Repeat Idxs"}},"type":"object","title":"RefreshSliceRequest"},"RemoveScenariosRequest":{"properties":{"scenario_ids":{"items":{"type":"string","format":"uuid"},"type":"array","title":"Scenario Ids"}},"type":"object","required":["scenario_ids"],"title":"RemoveScenariosRequest"},"RemoveStepsRequest":{"properties":{"step_keys":{"items":{"type":"string"},"type":"array","title":"Step Keys"}},"type":"object","required":["step_keys"],"title":"RemoveStepsRequest"},"RequestType":{"type":"string","enum":["unknown","router","worker"],"title":"RequestType"},"ResendInviteRequest":{"properties":{"email":{"type":"string","title":"Email"}},"type":"object","required":["email"],"title":"ResendInviteRequest"},"ResolutionInfo":{"properties":{"references_used":{"items":{"additionalProperties":{"$ref":"#/components/schemas/Reference"},"type":"object"},"type":"array","title":"References Used"},"depth_reached":{"type":"integer","title":"Depth Reached"},"embeds_resolved":{"type":"integer","title":"Embeds Resolved"},"errors":{"items":{"type":"string"},"type":"array","title":"Errors","default":[]}},"type":"object","required":["references_used","depth_reached","embeds_resolved"],"title":"ResolutionInfo"},"ResolvedGatewayConnection":{"properties":{"provider":{"type":"string","title":"Provider"},"integration":{"type":"string","title":"Integration"},"connection":{"type":"string","title":"Connection"},"toolkit_version":{"type":"string","title":"Toolkit Version"},"tools":{"items":{"$ref":"#/components/schemas/ResolvedGatewayTool"},"type":"array","title":"Tools"}},"type":"object","required":["provider","integration","connection","toolkit_version"],"title":"ResolvedGatewayConnection","description":"The catalog slice for one validated connection entry (contracts section 3).\n\nThe whole integration is returned in one round trip, so the SDK compiles its\nper-tool policy without asking for each tool separately."},"ResolvedGatewayTool":{"properties":{"key":{"type":"string","title":"Key"},"read_only":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Read Only"}},"type":"object","required":["key"],"title":"ResolvedGatewayTool","description":"One catalog tool as the SDK permission compiler reads it (contracts section 2)."},"ResolvedTool":{"properties":{"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"input_schema":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Input Schema"},"call_ref":{"type":"string","title":"Call Ref"},"read_only":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Read Only"}},"type":"object","required":["name","call_ref"],"title":"ResolvedTool","description":"A runnable reference resolved into a model-ready tool spec.\n\n``call_ref`` is the ``tools.{provider}.{integration}.{action}.{connection}`` slug\nthe execution bridge sends back to ``POST /tools/call``."},"RetrievalInfo":{"properties":{"references":{"additionalProperties":{"$ref":"#/components/schemas/Reference"},"type":"object","title":"References"},"selector":{"anyOf":[{"additionalProperties":{"type":"string"},"type":"object"},{"type":"null"}],"title":"Selector"}},"type":"object","title":"RetrievalInfo","description":"References actually used to retrieve a revision.\n\nFor direct retrievals, `references` carries the artifact / variant / revision\nthat was fetched. For environment-backed retrievals, it additionally carries\nthe environment + environment_variant + environment_revision used to look\nthe target up, and `selector` is {the key: path} map inside the environment's\nreferences map that selected the target."},"SSOProviderDTO":{"properties":{"provider":{"$ref":"#/components/schemas/SSOProviderSettingsDTO"}},"type":"object","required":["provider"],"title":"SSOProviderDTO"},"SSOProviderInfo":{"properties":{"id":{"type":"string","title":"Id"},"slug":{"type":"string","title":"Slug"},"third_party_id":{"type":"string","title":"Third Party Id"}},"type":"object","required":["id","slug","third_party_id"],"title":"SSOProviderInfo"},"SSOProviderSettingsDTO":{"properties":{"client_id":{"type":"string","title":"Client Id"},"client_secret":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Client Secret"},"issuer_url":{"type":"string","title":"Issuer Url"},"scopes":{"items":{"type":"string"},"type":"array","title":"Scopes"},"extra":{"additionalProperties":true,"type":"object","title":"Extra"}},"type":"object","required":["client_id","issuer_url","scopes"],"title":"SSOProviderSettingsDTO"},"SSOProviders":{"properties":{"providers":{"items":{"$ref":"#/components/schemas/SSOProviderInfo"},"type":"array","title":"Providers"}},"type":"object","required":["providers"],"title":"SSOProviders"},"SecretDTO":{"properties":{"kind":{"$ref":"#/components/schemas/SecretKind"},"data":{"anyOf":[{"$ref":"#/components/schemas/StandardProviderDTO"},{"$ref":"#/components/schemas/CustomProviderDTO"},{"$ref":"#/components/schemas/SSOProviderDTO"},{"$ref":"#/components/schemas/WebhookProviderDTO"},{"$ref":"#/components/schemas/CustomSecretDTO"}],"title":"Data"}},"type":"object","required":["kind","data"],"title":"SecretDTO","description":"Create-time secret payload. Required credential fields must be present."},"SecretKind":{"type":"string","enum":["provider_key","custom_provider","sso_provider","webhook_provider","custom_secret"],"title":"SecretKind"},"SecretManagementPolicy":{"type":"string","enum":["manager_only"],"title":"SecretManagementPolicy"},"SecretValueStatus":{"properties":{"configured":{"type":"boolean","title":"Configured"},"preview":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Preview"}},"type":"object","required":["configured"],"title":"SecretValueStatus"},"Selector":{"properties":{"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key"},"path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path"}},"type":"object","title":"Selector","description":"Selector for extracting specific data from entities.\n\nPlaced alongside Reference for data extraction from referenced entities.\n\nFields:\n- **key**: For environment revisions only. Navigates to data.references.,\n follows the entity pointer found there (e.g. workflow_revision), fetches that\n entity, then applies path against its data.\n- **path**: Dot notation path into the resolved entity's data.\n If key is set, path applies to the secondary entity's data.\n If key is not set, path applies directly to the referenced entity's data."},"SessionAttachment":{"properties":{"attachment_id":{"type":"string","format":"uuid","title":"Attachment Id"},"filename":{"type":"string","title":"Filename"},"media_type":{"type":"string","title":"Media Type"},"size":{"type":"integer","title":"Size"},"created_at":{"type":"string","format":"date-time","title":"Created At"}},"type":"object","required":["attachment_id","filename","media_type","size","created_at"],"title":"SessionAttachment"},"SessionAttachmentReferenceRequest":{"properties":{"session_id":{"type":"string","title":"Session Id"},"attachment_ids":{"items":{"type":"string","format":"uuid"},"type":"array","maxItems":100,"title":"Attachment Ids"}},"type":"object","required":["session_id","attachment_ids"],"title":"SessionAttachmentReferenceRequest"},"SessionAttachmentResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"attachment":{"$ref":"#/components/schemas/SessionAttachment"}},"type":"object","required":["attachment"],"title":"SessionAttachmentResponse"},"SessionAttachmentsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"attachments":{"items":{"$ref":"#/components/schemas/SessionAttachment"},"type":"array","title":"Attachments"}},"type":"object","title":"SessionAttachmentsResponse"},"SessionDelivery":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"}},"type":"object","required":["id"],"title":"SessionDelivery"},"SessionDetachRequest":{"properties":{"session_id":{"type":"string","title":"Session Id"},"watcher_id":{"type":"string","title":"Watcher Id"}},"type":"object","required":["session_id","watcher_id"],"title":"SessionDetachRequest"},"SessionExcludeRequest":{"properties":{"origins":{"anyOf":[{"items":{"$ref":"#/components/schemas/SessionOrigin"},"type":"array"},{"type":"null"}],"title":"Origins"},"session_ids":{"anyOf":[{"items":{"type":"string","maxLength":128,"minLength":1,"pattern":"^[a-zA-Z0-9_\\-]+$"},"type":"array","maxItems":500},{"type":"null"}],"title":"Session Ids"}},"additionalProperties":false,"type":"object","title":"SessionExcludeRequest"},"SessionExpansion":{"type":"string","enum":["last_message","trigger"],"title":"SessionExpansion"},"SessionHeartbeatRequest":{"properties":{"session_id":{"type":"string","title":"Session Id"},"replica_id":{"type":"string","minLength":1,"title":"Replica Id"},"turn_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Turn Id"},"is_running":{"type":"boolean","title":"Is Running","default":true},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"references":{"anyOf":[{"items":{"$ref":"#/components/schemas/SessionReference"},"type":"array"},{"type":"null"}],"title":"References"}},"type":"object","required":["session_id","replica_id"],"title":"SessionHeartbeatRequest","description":"A beat, plus what this run knows about the session that nothing else records.\n\n``name`` and ``references`` are PROPOSALS, not edits: the service writes each only\nonto a NULL column (see `SessionStreamsService.heartbeat`). The runner is the only\ncomponent present on every execution path — browser, headless invoke, scheduled\ntrigger — so it is the only one that can title and attribute a session that no\nbrowser will ever render."},"SessionHeartbeatResult":{"properties":{"stream":{"anyOf":[{"$ref":"#/components/schemas/SessionStream"},{"type":"null"}]},"replica_id":{"type":"string","title":"Replica Id"},"is_current_turn":{"type":"boolean","title":"Is Current Turn","default":true}},"type":"object","required":["replica_id"],"title":"SessionHeartbeatResult","description":"A heartbeat's outcome: the reconciled stream plus the session's actual owner replica.\n\n`replica_id` is the replica that currently holds the affinity key after the claim\n(this caller if it won or already held it, another replica otherwise). The runner reads\nit to refuse serving a local sandbox session it does not own.\n\n`stream` is None when a losing replica heartbeats a session that has no row yet: it may\nnot create or stamp one, since that row belongs to the owner.\n\n`is_current_turn` (W7.4) is False when this turn_id's alive/running lock was gone or\nreassigned at the moment of this beat — i.e. a cancel/steer/kill interrupted this turn\nsince the last heartbeat. The runner's watchdog reads this to abort the in-flight run;\nwithout it a cancel that raced a heartbeat's nx=True re-acquire would silently re-arm the\nSAME lock under the SAME turn_id and the interruption would never surface."},"SessionIdentitiesUpdate":{"properties":{"session_identities":{"items":{"type":"string"},"type":"array","title":"Session Identities"}},"type":"object","required":["session_identities"],"title":"SessionIdentitiesUpdate"},"SessionIdsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of session IDs in this page.","default":0},"session_ids":{"items":{"type":"string"},"type":"array","title":"Session Ids","description":"Distinct values of `ag.session.id` in this page.","default":[]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor for the next page. Pass verbatim as `windowing.next`."}},"type":"object","title":"SessionIdsResponse"},"SessionInteraction":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"project_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Project Id"},"session_id":{"type":"string","title":"Session Id"},"turn_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Turn Id"},"token":{"type":"string","title":"Token"},"kind":{"$ref":"#/components/schemas/SessionInteractionKind"},"status":{"anyOf":[{"$ref":"#/components/schemas/SessionInteractionStatus"},{"type":"null"}]},"data":{"anyOf":[{"$ref":"#/components/schemas/SessionInteractionData"},{"type":"null"}]},"flags":{"$ref":"#/components/schemas/SessionInteractionFlags","default":{"delivered_in_band":false,"delivered_webhook":false}},"tags":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta"}},"type":"object","required":["session_id","token","kind"],"title":"SessionInteraction"},"SessionInteractionCancelStaleRequest":{"properties":{"session_id":{"type":"string","title":"Session Id"},"turn_id":{"type":"string","title":"Turn Id"},"tokens":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Tokens"}},"type":"object","required":["session_id","turn_id"],"title":"SessionInteractionCancelStaleRequest"},"SessionInteractionCreateRequest":{"properties":{"session_id":{"type":"string","title":"Session Id"},"turn_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Turn Id"},"token":{"type":"string","title":"Token"},"kind":{"$ref":"#/components/schemas/SessionInteractionKind"},"data":{"anyOf":[{"$ref":"#/components/schemas/SessionInteractionData"},{"type":"null"}]},"flags":{"$ref":"#/components/schemas/SessionInteractionFlags","default":{"delivered_in_band":false,"delivered_webhook":false}},"tags":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta"}},"type":"object","required":["session_id","token","kind"],"title":"SessionInteractionCreateRequest"},"SessionInteractionData":{"properties":{"request":{"anyOf":[{"$ref":"#/components/schemas/SessionInteractionRequest"},{"type":"null"}]},"references":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/Reference"},"type":"object"},{"type":"null"}],"title":"References"},"selector":{"anyOf":[{"$ref":"#/components/schemas/Selector"},{"type":"null"}]},"resolution":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Resolution"},"parameters":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Parameters"}},"type":"object","title":"SessionInteractionData"},"SessionInteractionFlags":{"properties":{"delivered_in_band":{"type":"boolean","title":"Delivered In Band","default":false},"delivered_webhook":{"type":"boolean","title":"Delivered Webhook","default":false}},"type":"object","title":"SessionInteractionFlags"},"SessionInteractionKind":{"type":"string","enum":["user_approval","user_input","client_tool"],"title":"SessionInteractionKind"},"SessionInteractionQuery":{"properties":{"session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Session Id"},"turn_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Turn Id"},"kind":{"anyOf":[{"$ref":"#/components/schemas/SessionInteractionKind"},{"type":"null"}]},"status":{"anyOf":[{"$ref":"#/components/schemas/SessionInteractionStatus"},{"type":"null"}]},"flags":{"anyOf":[{"$ref":"#/components/schemas/SessionInteractionQueryFlags"},{"type":"null"}]},"actionable_only":{"type":"boolean","title":"Actionable Only","default":false}},"type":"object","title":"SessionInteractionQuery"},"SessionInteractionQueryFlags":{"properties":{"delivered_in_band":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Delivered In Band"},"delivered_webhook":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Delivered Webhook"}},"type":"object","title":"SessionInteractionQueryFlags"},"SessionInteractionQueryRequest":{"properties":{"query":{"anyOf":[{"$ref":"#/components/schemas/SessionInteractionQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"SessionInteractionQueryRequest"},"SessionInteractionRequest":{"properties":{"tool":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tool"},"args":{"anyOf":[{},{"type":"null"}],"title":"Args"},"tool_call_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tool Call Id"}},"additionalProperties":true,"type":"object","title":"SessionInteractionRequest"},"SessionInteractionRespondRequest":{"properties":{"answer":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Answer"}},"type":"object","title":"SessionInteractionRespondRequest"},"SessionInteractionResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"interaction":{"anyOf":[{"$ref":"#/components/schemas/SessionInteraction"},{"type":"null"}]}},"type":"object","title":"SessionInteractionResponse"},"SessionInteractionStatus":{"type":"string","enum":["pending","responded","resolved","cancelled"],"title":"SessionInteractionStatus"},"SessionInteractionTransitionRequest":{"properties":{"session_id":{"type":"string","title":"Session Id"},"token":{"type":"string","title":"Token"},"status":{"$ref":"#/components/schemas/SessionInteractionStatus"},"resolution":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Resolution"}},"type":"object","required":["session_id","token","status"],"title":"SessionInteractionTransitionRequest"},"SessionInteractionsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"interactions":{"items":{"$ref":"#/components/schemas/SessionInteraction"},"type":"array","title":"Interactions"}},"type":"object","title":"SessionInteractionsResponse"},"SessionListItem":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"project_id":{"type":"string","format":"uuid","title":"Project Id"},"session_id":{"type":"string","title":"Session Id"},"flags":{"$ref":"#/components/schemas/SessionStreamFlags","default":{"is_alive":false,"is_running":false,"is_attached":false}},"tags":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta"},"turn_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Turn Id"},"references":{"anyOf":[{"items":{"$ref":"#/components/schemas/SessionReference"},"type":"array"},{"type":"null"}],"title":"References"},"archived_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Archived At"},"origin":{"anyOf":[{"$ref":"#/components/schemas/SessionOrigin"},{"type":"null"}]},"trigger":{"anyOf":[{"$ref":"#/components/schemas/SessionTrigger"},{"type":"null"}]},"delivery":{"anyOf":[{"$ref":"#/components/schemas/SessionDelivery"},{"type":"null"}]},"last_message":{"anyOf":[{"$ref":"#/components/schemas/SessionMessagePreview"},{"type":"null"}]}},"type":"object","required":["project_id","session_id"],"title":"SessionListItem","description":"A `/sessions/query` row, enriched at READ time with the session's last message.\n\n`references` prefers the stream row's own (filled once at run time) and falls back to\nthe HIGHEST `turn_index` turn's — the agent/workflow that produced the latest turn.\nThe fallback is what keeps rows written before the stream column existed openable.\nBoth enrichments are batch lookups keyed on the whole page; never one call per row.\n\nHydrated by `SessionsService.query_sessions`."},"SessionMessagePreview":{"properties":{"text":{"type":"string","maxLength":240,"title":"Text"},"source":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Source"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Timestamp"}},"type":"object","required":["text"],"title":"SessionMessagePreview","description":"The last thing said in a session, for a list row.\n\nA session row carried a title and a timestamp, so deciding whether a session was worth\nreopening meant opening it. Only `message` records are considered: `done`/`usage` are\nbookkeeping, `thought` is not addressed to anyone, and a `tool_call` says what the agent\nreached for rather than what it concluded."},"SessionMount":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"project_id":{"type":"string","format":"uuid","title":"Project Id"},"session_id":{"type":"string","title":"Session Id"},"agent_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Id"},"purpose":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Purpose"},"data":{"$ref":"#/components/schemas/MountData"},"flags":{"$ref":"#/components/schemas/MountFlags"},"tags":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta"}},"type":"object","required":["project_id","session_id"],"title":"SessionMount"},"SessionMountQuery":{"properties":{"session_id":{"type":"string","title":"Session Id"},"agent_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Id"},"include_archived":{"type":"boolean","title":"Include Archived","default":false}},"type":"object","required":["session_id"],"title":"SessionMountQuery"},"SessionMountQueryRequest":{"properties":{"mount":{"anyOf":[{"$ref":"#/components/schemas/SessionMountQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"SessionMountQueryRequest"},"SessionMountsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"mounts":{"items":{"$ref":"#/components/schemas/SessionMount"},"type":"array","title":"Mounts"}},"type":"object","title":"SessionMountsResponse"},"SessionOrigin":{"type":"string","enum":["manual","trigger"],"title":"SessionOrigin"},"SessionPredicatesRequest":{"properties":{"search":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Search"},"liveness":{"anyOf":[{"$ref":"#/components/schemas/SessionStreamQueryFlags"},{"type":"null"}]},"origins":{"anyOf":[{"items":{"$ref":"#/components/schemas/SessionOrigin"},"type":"array"},{"type":"null"}],"title":"Origins"}},"additionalProperties":false,"type":"object","title":"SessionPredicatesRequest"},"SessionQueryRequest":{"properties":{"session":{"anyOf":[{"$ref":"#/components/schemas/SessionPredicatesRequest"},{"type":"null"}]},"session_ids":{"anyOf":[{"items":{"type":"string","maxLength":128,"minLength":1,"pattern":"^[a-zA-Z0-9_\\-]+$"},"type":"array","maxItems":500},{"type":"null"}],"title":"Session Ids"},"exclude":{"anyOf":[{"$ref":"#/components/schemas/SessionExcludeRequest"},{"type":"null"}]},"turn_references":{"anyOf":[{"items":{"$ref":"#/components/schemas/SessionReference"},"type":"array"},{"type":"null"}],"title":"Turn References"},"include_ended":{"type":"boolean","title":"Include Ended","default":false},"include_archived":{"type":"boolean","title":"Include Archived","default":false},"archived_only":{"type":"boolean","title":"Archived Only","default":false},"include_total":{"type":"boolean","title":"Include Total","default":false},"expand":{"items":{"$ref":"#/components/schemas/SessionExpansion"},"type":"array","title":"Expand"},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]},"references":{"anyOf":[{"items":{"$ref":"#/components/schemas/SessionReference"},"type":"array"},{"type":"null"}],"title":"References"},"search":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Search"},"flags":{"anyOf":[{"$ref":"#/components/schemas/SessionStreamQueryFlags"},{"type":"null"}]},"exclude_session_ids":{"anyOf":[{"items":{"type":"string","maxLength":128,"minLength":1,"pattern":"^[a-zA-Z0-9_\\-]+$"},"type":"array","maxItems":500},{"type":"null"}],"title":"Exclude Session Ids"},"origin":{"anyOf":[{"$ref":"#/components/schemas/SessionOrigin"},{"type":"null"}]},"exclude_origin":{"anyOf":[{"$ref":"#/components/schemas/SessionOrigin"},{"type":"null"}]}},"additionalProperties":false,"type":"object","title":"SessionQueryRequest"},"SessionRecord":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"record_id":{"type":"string","format":"uuid","title":"Record Id"},"session_id":{"type":"string","title":"Session Id"},"project_id":{"type":"string","format":"uuid","title":"Project Id"},"record_index":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Record Index"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Timestamp"},"record_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Record Type"},"record_source":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Record Source"},"attributes":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Attributes"},"turn_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Turn Id"},"span_id":{"anyOf":[{"type":"string","pattern":"^[0-9a-fA-F]{16}$"},{"type":"null"}],"title":"Span Id"}},"type":"object","required":["record_id","session_id","project_id"],"title":"SessionRecord"},"SessionRecordIngestRequest":{"properties":{"session_id":{"type":"string","title":"Session Id"},"record_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Record Id"},"record_index":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Record Index"},"timestamp":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Timestamp"},"record_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Record Type"},"record_source":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Record Source"},"attributes":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Attributes"},"turn_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Turn Id"},"span_id":{"anyOf":[{"type":"string","pattern":"^[0-9a-fA-F]{16}$"},{"type":"null"}],"title":"Span Id"}},"type":"object","required":["session_id"],"title":"SessionRecordIngestRequest"},"SessionRecordQueryRequest":{"properties":{"session_id":{"type":"string","title":"Session Id"}},"type":"object","required":["session_id"],"title":"SessionRecordQueryRequest"},"SessionRecordResponse":{"properties":{"record":{"anyOf":[{"$ref":"#/components/schemas/SessionRecord"},{"type":"null"}]}},"type":"object","title":"SessionRecordResponse"},"SessionRecordsQueryResponse":{"properties":{"count":{"type":"integer","title":"Count"},"records":{"items":{"$ref":"#/components/schemas/SessionRecord"},"type":"array","title":"Records"}},"type":"object","required":["count","records"],"title":"SessionRecordsQueryResponse"},"SessionReference":{"properties":{"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key"}},"type":"object","title":"SessionReference","description":"A reference element that carries its own family.\n\nSessions persist references as a flat list (`session_turns.references`,\n`session_streams.references`), so the map key that named the family upstream is\ngone by the time a reader sees the row — leaving \"first UUID in the list\" as the\nonly way to guess which element is the workflow.\n\n``key`` is the name `evaluation_runs.references` already uses for the same flat-list\ndiscriminator (`dbs/postgres/evaluations/utils.py`), and the same one tracing carries\nin `OTelReference.attributes[\"key\"]`.\n\nIt is a plain string rather than ``ReferenceKey`` on purpose: a turn append is\nfire-and-forget, so rejecting an unrecognized family would drop the whole turn, which\nis the failure this field exists to prevent. Producers inside the API use\n``ReferenceKey``; readers treat anything else as untyped."},"SessionResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"session":{"anyOf":[{"$ref":"#/components/schemas/SessionStream"},{"type":"null"}]}},"type":"object","title":"SessionResponse"},"SessionStream":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"project_id":{"type":"string","format":"uuid","title":"Project Id"},"session_id":{"type":"string","title":"Session Id"},"flags":{"$ref":"#/components/schemas/SessionStreamFlags","default":{"is_alive":false,"is_running":false,"is_attached":false}},"tags":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta"},"turn_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Turn Id"},"references":{"anyOf":[{"items":{"$ref":"#/components/schemas/SessionReference"},"type":"array"},{"type":"null"}],"title":"References"},"archived_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Archived At"},"origin":{"anyOf":[{"$ref":"#/components/schemas/SessionOrigin"},{"type":"null"}]},"trigger":{"anyOf":[{"$ref":"#/components/schemas/SessionTrigger"},{"type":"null"}]},"delivery":{"anyOf":[{"$ref":"#/components/schemas/SessionDelivery"},{"type":"null"}]}},"type":"object","required":["project_id","session_id"],"title":"SessionStream"},"SessionStreamCommandRequest":{"properties":{"session_id":{"type":"string","title":"Session Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRequestData"},{"type":"null"}]},"force":{"type":"boolean","title":"Force","default":false},"detached":{"type":"boolean","title":"Detached","default":false}},"type":"object","required":["session_id"],"title":"SessionStreamCommandRequest","description":"The set_session_stream edit: a state mutation over the lock/row nest.\n\nRuns nothing itself — the runner (execution plane) is the only thing that runs.\n`data` mirrors the workflow-invoke shape (`WorkflowServiceRequestData`, keyed on\n`.inputs`) so the discriminator aligns with `WorkflowInvokeRequest.data.inputs`\nrather than a bespoke `prompt` string."},"SessionStreamCommandResponse":{"properties":{"mode":{"$ref":"#/components/schemas/CommandMode"},"session_id":{"type":"string","title":"Session Id"},"turn_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Turn Id"},"watcher_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Watcher Id"},"detached":{"type":"boolean","title":"Detached","default":false}},"type":"object","required":["mode","session_id"],"title":"SessionStreamCommandResponse"},"SessionStreamFlags":{"properties":{"is_alive":{"type":"boolean","title":"Is Alive","default":false},"is_running":{"type":"boolean","title":"Is Running","default":false},"is_attached":{"type":"boolean","title":"Is Attached","default":false}},"type":"object","title":"SessionStreamFlags","description":"The nest as primitive bools (alive ⊇ running ⊇ attached).\n\nresumable (alive & !running) and reattachable (running & !attached) are\nderived client-side, never stored."},"SessionStreamHeaderEdit":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},"type":"object","title":"SessionStreamHeaderEdit","description":"The rename edit: a full-PUT of the header fields only.\n\nDistinct from SessionStreamEdit (used by the flag-mirror/heartbeat paths) so the\nliveness-only writes can never carry name/description, and vice versa. The one\nother header writer is the heartbeat's fill-once proposal, which goes through the\nDAO's NULL-guarded `fill_missing` and so cannot overwrite this edit.\n\n``name`` may be omitted/``None`` (no change) or an empty string (the explicit\nclear-title action the chat rail's rename path uses), but a NON-empty name must\ncontain a non-whitespace character: storing ``\" \"`` clears the visible title\nwhile the row still holds a value, a state no caller ever means. The LLM-facing\n``rename_session`` schema already rejects both; this closes the direct-API hole."},"SessionStreamQueryFlags":{"properties":{"is_alive":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Alive"},"is_running":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Running"},"is_attached":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Attached"}},"type":"object","title":"SessionStreamQueryFlags"},"SessionStreamQueryRequest":{"properties":{"session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Session Id"},"is_alive":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Alive"},"is_running":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Running"}},"type":"object","title":"SessionStreamQueryRequest"},"SessionStreamResponse":{"properties":{"stream":{"anyOf":[{"$ref":"#/components/schemas/SessionStream"},{"type":"null"}]}},"type":"object","title":"SessionStreamResponse"},"SessionStreamsResponse":{"properties":{"count":{"type":"integer","title":"Count"},"streams":{"items":{"$ref":"#/components/schemas/SessionStream"},"type":"array","title":"Streams"}},"type":"object","required":["count","streams"],"title":"SessionStreamsResponse"},"SessionTrigger":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"kind":{"$ref":"#/components/schemas/SessionTriggerKind"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"}},"type":"object","required":["id","kind"],"title":"SessionTrigger"},"SessionTriggerKind":{"type":"string","enum":["schedule","subscription"],"title":"SessionTriggerKind"},"SessionTurn":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"project_id":{"type":"string","format":"uuid","title":"Project Id"},"session_id":{"type":"string","title":"Session Id"},"turn_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Turn Id"},"stream_id":{"type":"string","format":"uuid","title":"Stream Id"},"turn_index":{"type":"integer","title":"Turn Index"},"harness_kind":{"$ref":"#/components/schemas/HarnessKind"},"agent_session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Session Id"},"sandbox_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sandbox Id"},"references":{"anyOf":[{"items":{"$ref":"#/components/schemas/SessionReference"},"type":"array"},{"type":"null"}],"title":"References"},"trace_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Trace Id"},"span_id":{"anyOf":[{"type":"string","pattern":"^[0-9a-fA-F]{16}$"},{"type":"null"}],"title":"Span Id"},"start_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Start Time"},"end_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"End Time"}},"type":"object","required":["project_id","session_id","stream_id","turn_index","harness_kind"],"title":"SessionTurn"},"SessionTurnAppendRequest":{"properties":{"session_id":{"type":"string","title":"Session Id"},"turn_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Turn Id"},"stream_id":{"type":"string","format":"uuid","title":"Stream Id"},"turn_index":{"type":"integer","title":"Turn Index"},"harness_kind":{"$ref":"#/components/schemas/HarnessKind"},"agent_session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Session Id"},"sandbox_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sandbox Id"},"references":{"anyOf":[{"items":{"$ref":"#/components/schemas/SessionReference"},"type":"array"},{"type":"null"}],"title":"References"},"trace_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Trace Id"},"span_id":{"anyOf":[{"type":"string","pattern":"^[0-9a-fA-F]{16}$"},{"type":"null"}],"title":"Span Id"},"start_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Start Time"},"end_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"End Time"}},"type":"object","required":["session_id","stream_id","turn_index","harness_kind"],"title":"SessionTurnAppendRequest"},"SessionTurnCompleteRequest":{"properties":{"session_id":{"type":"string","title":"Session Id"},"turn_index":{"type":"integer","title":"Turn Index"},"agent_session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Session Id"},"end_time":{"type":"string","format":"date-time","title":"End Time"}},"type":"object","required":["session_id","turn_index","end_time"],"title":"SessionTurnCompleteRequest"},"SessionTurnQuery":{"properties":{"session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Session Id"},"stream_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Stream Id"},"harness_kind":{"anyOf":[{"$ref":"#/components/schemas/HarnessKind"},{"type":"null"}]},"references":{"anyOf":[{"items":{"$ref":"#/components/schemas/SessionReference"},"type":"array"},{"type":"null"}],"title":"References"}},"type":"object","title":"SessionTurnQuery"},"SessionTurnQueryRequest":{"properties":{"query":{"anyOf":[{"$ref":"#/components/schemas/SessionTurnQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"SessionTurnQueryRequest"},"SessionTurnResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"turn":{"anyOf":[{"$ref":"#/components/schemas/SessionTurn"},{"type":"null"}]}},"type":"object","title":"SessionTurnResponse"},"SessionTurnsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"turns":{"items":{"$ref":"#/components/schemas/SessionTurn"},"type":"array","title":"Turns"}},"type":"object","title":"SessionTurnsResponse"},"SessionsQueryRequest":{"properties":{"realtime":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Realtime","description":"When `true`, paginate by `last_active` (reflects ongoing activity but can shift between pages). When `false` or unset, paginate by the stable `first_active` cursor."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor pagination and time range. Pass the returned `windowing.next` on subsequent calls to continue iteration."}},"type":"object","title":"SessionsQueryRequest","description":"Request body for `POST /tracing/sessions/query`."},"SessionsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"total":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Total"},"sessions":{"items":{"$ref":"#/components/schemas/SessionListItem"},"type":"array","title":"Sessions"},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"SessionsResponse"},"SetRepeatsRequest":{"properties":{"repeats":{"type":"integer","title":"Repeats"}},"type":"object","required":["repeats"],"title":"SetRepeatsRequest"},"SimpleApplication":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/SimpleApplicationFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/SimpleApplicationData-Output"},{"type":"null"}]},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"}},"type":"object","title":"SimpleApplication"},"SimpleApplicationAdditionalContext":{"properties":{"playground_build_kit":{"anyOf":[{"$ref":"#/components/schemas/PlaygroundBuildKitContext"},{"type":"null"}],"description":"Playground-only build kit data that is never persisted on the app."}},"type":"object","title":"SimpleApplicationAdditionalContext","description":"Platform-supplied read-only context for a simple-application response."},"SimpleApplicationCreate":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/SimpleApplicationFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"data":{"anyOf":[{"$ref":"#/components/schemas/SimpleApplicationData-Input"},{"type":"null"}]}},"type":"object","title":"SimpleApplicationCreate"},"SimpleApplicationCreateRequest":{"properties":{"application":{"$ref":"#/components/schemas/SimpleApplicationCreate","description":"Application fields plus `data` for the first revision. `data.uri` selects the template (for example `agenta:builtin:completion:v0`); `data.parameters` carries the prompt and model config."}},"type":"object","required":["application"],"title":"SimpleApplicationCreateRequest","description":"Request body for `POST /simple/applications/`.\n\nCreates the application artifact, a default variant, and a first committed\nrevision whose `data` comes from the request. Use this for the common case\nof \"spin up a new application from a template\".\nSee [Simple Endpoints](/reference/api-guide/simple-endpoints)."},"SimpleApplicationData-Input":{"properties":{"uri":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Uri"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"headers":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/Reference"}]},"type":"object"},{"type":"null"}],"title":"Headers"},"runtime":{"anyOf":[{"type":"string","enum":["python","typescript","javascript"]},{"type":"null"}],"title":"Runtime"},"script":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Script"},"schemas":{"anyOf":[{"$ref":"#/components/schemas/JsonSchemas-Input"},{"type":"null"}]},"parameters":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Parameters"}},"additionalProperties":false,"type":"object","title":"SimpleApplicationData"},"SimpleApplicationData-Output":{"properties":{"uri":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Uri"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"headers":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/Reference"}]},"type":"object"},{"type":"null"}],"title":"Headers"},"runtime":{"anyOf":[{"type":"string","enum":["python","typescript","javascript"]},{"type":"null"}],"title":"Runtime"},"script":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Script"},"schemas":{"anyOf":[{"$ref":"#/components/schemas/JsonSchemas-Output"},{"type":"null"}]},"parameters":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Parameters"}},"additionalProperties":false,"type":"object","title":"SimpleApplicationData"},"SimpleApplicationEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/SimpleApplicationFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/SimpleApplicationData-Input"},{"type":"null"}]}},"type":"object","title":"SimpleApplicationEdit"},"SimpleApplicationEditRequest":{"properties":{"application":{"$ref":"#/components/schemas/SimpleApplicationEdit","description":"Fields to change. `id` must match the path. Supplying `data` commits a new revision with that configuration; supplying `flags`/`tags`/`meta` commits a revision with the updated header but the existing `data`."}},"type":"object","required":["application"],"title":"SimpleApplicationEditRequest","description":"Request body for `PUT /simple/applications/{application_id}`.\n\nCommits a new revision on the application's variant whenever fields other\nthan `id` are present. If only `id` is sent, the current state is returned\nwithout committing."},"SimpleApplicationFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false},"is_managed":{"type":"boolean","title":"Is Managed","default":false},"is_custom":{"type":"boolean","title":"Is Custom","default":false},"is_llm":{"type":"boolean","title":"Is Llm","default":false},"is_hook":{"type":"boolean","title":"Is Hook","default":false},"is_code":{"type":"boolean","title":"Is Code","default":false},"is_match":{"type":"boolean","title":"Is Match","default":false},"is_feedback":{"type":"boolean","title":"Is Feedback","default":false},"is_agent":{"type":"boolean","title":"Is Agent","default":false},"is_skill":{"type":"boolean","title":"Is Skill","default":false},"is_chat":{"type":"boolean","title":"Is Chat","default":false},"has_url":{"type":"boolean","title":"Has Url","default":false},"has_script":{"type":"boolean","title":"Has Script","default":false},"has_handler":{"type":"boolean","title":"Has Handler","default":false},"is_static":{"type":"boolean","title":"Is Static","default":false}},"type":"object","title":"SimpleApplicationFlags"},"SimpleApplicationQuery":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/SimpleApplicationQueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"slugs":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Slugs"}},"type":"object","title":"SimpleApplicationQuery"},"SimpleApplicationQueryFlags":{"properties":{"is_application":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Application"},"is_evaluator":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Evaluator"},"is_snippet":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Snippet"},"is_managed":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Managed"},"is_custom":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Custom"},"is_llm":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Llm"},"is_hook":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Hook"},"is_code":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Code"},"is_match":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Match"},"is_feedback":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Feedback"},"is_agent":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Agent"},"is_skill":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Skill"},"is_chat":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Chat"},"has_url":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Url"},"has_script":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Script"},"has_handler":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Handler"},"is_static":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Static"}},"type":"object","title":"SimpleApplicationQueryFlags"},"SimpleApplicationQueryRequest":{"properties":{"application":{"anyOf":[{"$ref":"#/components/schemas/SimpleApplicationQuery"},{"type":"null"}],"description":"Attribute filter. Supports `slug`, `slugs`, `flags`, and `meta`. `flags` filter both artifact flags (`is_application`, etc.) and revision flags (`is_chat`, `has_url`, etc.)."},"application_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Application Refs","description":"Restrict to specific applications by `id` or `slug`."},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived","description":"When `true`, include archived applications. Defaults to `false`.","default":false},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor pagination and time-range controls."}},"type":"object","title":"SimpleApplicationQueryRequest","description":"Request body for `POST /simple/applications/query`.\n\nReturns one row per application with the currently resolved variant,\nrevision, and `data` merged in — the shape most clients want when listing\napplications for a dashboard or invocation picker."},"SimpleApplicationResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when the application was found, `0` otherwise.","default":0},"application":{"anyOf":[{"$ref":"#/components/schemas/SimpleApplication"},{"type":"null"}],"description":"The application with `variant_id`, `revision_id`, and the revision's `data` merged. `data.url` is the invocation URL."},"additional_context":{"anyOf":[{"$ref":"#/components/schemas/SimpleApplicationAdditionalContext"},{"type":"null"}],"description":"Read-only platform context derived for this response."}},"type":"object","title":"SimpleApplicationResponse","description":"Simple-application single-row response envelope."},"SimpleApplicationsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of applications in this page.","default":0},"applications":{"items":{"$ref":"#/components/schemas/SimpleApplication"},"type":"array","title":"Applications","description":"Applications with their current variant, revision, and `data` merged in."}},"type":"object","title":"SimpleApplicationsResponse","description":"Paginated list of simple-application rows."},"SimpleEnvironment":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentRevisionData"},{"type":"null"}]},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"}},"type":"object","title":"SimpleEnvironment"},"SimpleEnvironmentCreate":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"data":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentRevisionData"},{"type":"null"}]}},"type":"object","title":"SimpleEnvironmentCreate"},"SimpleEnvironmentCreateRequest":{"properties":{"environment":{"$ref":"#/components/schemas/SimpleEnvironmentCreate"}},"type":"object","required":["environment"],"title":"SimpleEnvironmentCreateRequest"},"SimpleEnvironmentEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentRevisionData"},{"type":"null"}]}},"type":"object","title":"SimpleEnvironmentEdit"},"SimpleEnvironmentEditRequest":{"properties":{"environment":{"$ref":"#/components/schemas/SimpleEnvironmentEdit"}},"type":"object","required":["environment"],"title":"SimpleEnvironmentEditRequest"},"SimpleEnvironmentQuery":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EnvironmentQueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"slugs":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Slugs"}},"type":"object","title":"SimpleEnvironmentQuery"},"SimpleEnvironmentQueryRequest":{"properties":{"environment":{"anyOf":[{"$ref":"#/components/schemas/SimpleEnvironmentQuery"},{"type":"null"}]},"environment_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Environment Refs"},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived","default":false},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"SimpleEnvironmentQueryRequest"},"SimpleEnvironmentResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"environment":{"anyOf":[{"$ref":"#/components/schemas/SimpleEnvironment"},{"type":"null"}]}},"type":"object","title":"SimpleEnvironmentResponse"},"SimpleEnvironmentsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"environments":{"items":{"$ref":"#/components/schemas/SimpleEnvironment"},"type":"array","title":"Environments","default":[]}},"type":"object","title":"SimpleEnvironmentsResponse"},"SimpleEvaluation":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"data":{"anyOf":[{"$ref":"#/components/schemas/SimpleEvaluationData"},{"type":"null"}]}},"type":"object","title":"SimpleEvaluation"},"SimpleEvaluationCreate":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"version":{"type":"string","title":"Version","default":"2025-07-14"},"data":{"anyOf":[{"$ref":"#/components/schemas/SimpleEvaluationData"},{"type":"null"}]}},"type":"object","title":"SimpleEvaluationCreate"},"SimpleEvaluationCreateRequest":{"properties":{"evaluation":{"$ref":"#/components/schemas/SimpleEvaluationCreate"}},"type":"object","required":["evaluation"],"title":"SimpleEvaluationCreateRequest"},"SimpleEvaluationData":{"properties":{"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}]},"query_steps":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"additionalProperties":{"type":"string","enum":["custom","human","auto"]},"propertyNames":{"format":"uuid"},"type":"object"},{"type":"null"}],"title":"Query Steps"},"testset_steps":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"additionalProperties":{"type":"string","enum":["custom","human","auto"]},"propertyNames":{"format":"uuid"},"type":"object"},{"type":"null"}],"title":"Testset Steps"},"application_steps":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"additionalProperties":{"type":"string","enum":["custom","human","auto"]},"propertyNames":{"format":"uuid"},"type":"object"},{"type":"null"}],"title":"Application Steps"},"evaluator_steps":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"additionalProperties":{"type":"string","enum":["custom","human","auto"]},"propertyNames":{"format":"uuid"},"type":"object"},{"type":"null"}],"title":"Evaluator Steps"},"repeats":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Repeats"},"concurrency":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunDataConcurrency"},{"type":"null"}]}},"type":"object","title":"SimpleEvaluationData"},"SimpleEvaluationEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"version":{"type":"string","title":"Version","default":"2025-07-14"},"data":{"anyOf":[{"$ref":"#/components/schemas/SimpleEvaluationData"},{"type":"null"}]}},"type":"object","title":"SimpleEvaluationEdit"},"SimpleEvaluationEditRequest":{"properties":{"evaluation":{"$ref":"#/components/schemas/SimpleEvaluationEdit"}},"type":"object","required":["evaluation"],"title":"SimpleEvaluationEditRequest"},"SimpleEvaluationIdResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"evaluation_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Evaluation Id"}},"type":"object","title":"SimpleEvaluationIdResponse"},"SimpleEvaluationQuery":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/EvaluationRunQueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Ids"}},"type":"object","title":"SimpleEvaluationQuery"},"SimpleEvaluationQueryRequest":{"properties":{"evaluation":{"anyOf":[{"$ref":"#/components/schemas/SimpleEvaluationQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"SimpleEvaluationQueryRequest"},"SimpleEvaluationResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"evaluation":{"anyOf":[{"$ref":"#/components/schemas/SimpleEvaluation"},{"type":"null"}]}},"type":"object","title":"SimpleEvaluationResponse"},"SimpleEvaluationsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"evaluations":{"items":{"$ref":"#/components/schemas/SimpleEvaluation"},"type":"array","title":"Evaluations","default":[]}},"type":"object","title":"SimpleEvaluationsResponse"},"SimpleEvaluator":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/SimpleEvaluatorFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/SimpleEvaluatorData-Output"},{"type":"null"}]},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"}},"type":"object","title":"SimpleEvaluator"},"SimpleEvaluatorCreate":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/SimpleEvaluatorFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"data":{"anyOf":[{"$ref":"#/components/schemas/SimpleEvaluatorData-Input"},{"type":"null"}]}},"type":"object","title":"SimpleEvaluatorCreate"},"SimpleEvaluatorCreateRequest":{"properties":{"evaluator":{"$ref":"#/components/schemas/SimpleEvaluatorCreate","description":"Simple evaluator payload (slug, name, flags, and `data` with `uri` + `parameters`)."}},"type":"object","required":["evaluator"],"title":"SimpleEvaluatorCreateRequest","description":"Body for creating an evaluator via the simple surface.\n\nCollapses artifact, variant, and first revision into one call. The\nresponse returns the same flat shape that `/simple/evaluators/query`\nexposes."},"SimpleEvaluatorData-Input":{"properties":{"uri":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Uri"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"headers":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/Reference"}]},"type":"object"},{"type":"null"}],"title":"Headers"},"runtime":{"anyOf":[{"type":"string","enum":["python","typescript","javascript"]},{"type":"null"}],"title":"Runtime"},"script":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Script"},"schemas":{"anyOf":[{"$ref":"#/components/schemas/JsonSchemas-Input"},{"type":"null"}]},"parameters":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Parameters"}},"additionalProperties":false,"type":"object","title":"SimpleEvaluatorData"},"SimpleEvaluatorData-Output":{"properties":{"uri":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Uri"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"headers":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/Reference"}]},"type":"object"},{"type":"null"}],"title":"Headers"},"runtime":{"anyOf":[{"type":"string","enum":["python","typescript","javascript"]},{"type":"null"}],"title":"Runtime"},"script":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Script"},"schemas":{"anyOf":[{"$ref":"#/components/schemas/JsonSchemas-Output"},{"type":"null"}]},"parameters":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Parameters"}},"additionalProperties":false,"type":"object","title":"SimpleEvaluatorData"},"SimpleEvaluatorEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/SimpleEvaluatorFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/SimpleEvaluatorData-Input"},{"type":"null"}]}},"type":"object","title":"SimpleEvaluatorEdit"},"SimpleEvaluatorEditRequest":{"properties":{"evaluator":{"$ref":"#/components/schemas/SimpleEvaluatorEdit","description":"Simple evaluator edit payload. Requires the evaluator `id`. Renaming is temporarily disabled."}},"type":"object","required":["evaluator"],"title":"SimpleEvaluatorEditRequest","description":"Body for editing an evaluator via the simple surface."},"SimpleEvaluatorFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false},"is_managed":{"type":"boolean","title":"Is Managed","default":false},"is_custom":{"type":"boolean","title":"Is Custom","default":false},"is_llm":{"type":"boolean","title":"Is Llm","default":false},"is_hook":{"type":"boolean","title":"Is Hook","default":false},"is_code":{"type":"boolean","title":"Is Code","default":false},"is_match":{"type":"boolean","title":"Is Match","default":false},"is_feedback":{"type":"boolean","title":"Is Feedback","default":false},"is_agent":{"type":"boolean","title":"Is Agent","default":false},"is_skill":{"type":"boolean","title":"Is Skill","default":false},"is_chat":{"type":"boolean","title":"Is Chat","default":false},"has_url":{"type":"boolean","title":"Has Url","default":false},"has_script":{"type":"boolean","title":"Has Script","default":false},"has_handler":{"type":"boolean","title":"Has Handler","default":false},"is_static":{"type":"boolean","title":"Is Static","default":false}},"type":"object","title":"SimpleEvaluatorFlags"},"SimpleEvaluatorQuery":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/SimpleEvaluatorQueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"slugs":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Slugs"}},"type":"object","title":"SimpleEvaluatorQuery"},"SimpleEvaluatorQueryFlags":{"properties":{"is_application":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Application"},"is_evaluator":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Evaluator"},"is_snippet":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Snippet"},"is_managed":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Managed"},"is_custom":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Custom"},"is_llm":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Llm"},"is_hook":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Hook"},"is_code":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Code"},"is_match":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Match"},"is_feedback":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Feedback"},"is_agent":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Agent"},"is_skill":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Skill"},"is_chat":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Chat"},"has_url":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Url"},"has_script":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Script"},"has_handler":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Handler"},"is_static":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Static"}},"type":"object","title":"SimpleEvaluatorQueryFlags"},"SimpleEvaluatorQueryRequest":{"properties":{"evaluator":{"anyOf":[{"$ref":"#/components/schemas/SimpleEvaluatorQuery"},{"type":"null"}],"description":"Filter on evaluator attributes (slug, slugs, flags, meta)."},"evaluator_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Evaluator Refs","description":"Restrict to these evaluators."},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived","description":"When true, include soft-deleted evaluators.","default":false},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor-based pagination controls."}},"type":"object","title":"SimpleEvaluatorQueryRequest","description":"Body for filtering evaluators via the simple surface."},"SimpleEvaluatorResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"1 when an evaluator is returned, 0 otherwise.","default":0},"evaluator":{"anyOf":[{"$ref":"#/components/schemas/SimpleEvaluator"},{"type":"null"}],"description":"The flat evaluator record with latest variant and revision merged into `data`."}},"type":"object","title":"SimpleEvaluatorResponse","description":"Envelope for a single simple evaluator."},"SimpleEvaluatorsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of evaluators in `evaluators`.","default":0},"evaluators":{"items":{"$ref":"#/components/schemas/SimpleEvaluator"},"type":"array","title":"Evaluators","description":"Matching flat evaluator records."}},"type":"object","title":"SimpleEvaluatorsResponse","description":"Envelope for a list of simple evaluators."},"SimpleQueriesResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"queries":{"items":{"$ref":"#/components/schemas/SimpleQuery"},"type":"array","title":"Queries","default":[]}},"type":"object","title":"SimpleQueriesResponse"},"SimpleQuery":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/QueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"data":{"anyOf":[{"$ref":"#/components/schemas/QueryRevisionData-Output"},{"type":"null"}]},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"}},"type":"object","title":"SimpleQuery"},"SimpleQueryCreate":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"flags":{"anyOf":[{"$ref":"#/components/schemas/QueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"data":{"anyOf":[{"$ref":"#/components/schemas/QueryRevisionData-Input"},{"type":"null"}]}},"type":"object","title":"SimpleQueryCreate"},"SimpleQueryCreateRequest":{"properties":{"query":{"$ref":"#/components/schemas/SimpleQueryCreate"}},"type":"object","required":["query"],"title":"SimpleQueryCreateRequest"},"SimpleQueryEdit":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/QueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"data":{"anyOf":[{"$ref":"#/components/schemas/QueryRevisionData-Input"},{"type":"null"}]}},"type":"object","title":"SimpleQueryEdit"},"SimpleQueryEditRequest":{"properties":{"query":{"$ref":"#/components/schemas/SimpleQueryEdit"}},"type":"object","required":["query"],"title":"SimpleQueryEditRequest"},"SimpleQueryQuery":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/QueryQueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"}},"type":"object","title":"SimpleQueryQuery"},"SimpleQueryQueryRequest":{"properties":{"query":{"anyOf":[{"$ref":"#/components/schemas/SimpleQueryQuery"},{"type":"null"}]},"query_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Query Refs"},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived","default":false},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"SimpleQueryQueryRequest"},"SimpleQueryResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"query":{"anyOf":[{"$ref":"#/components/schemas/SimpleQuery"},{"type":"null"}]}},"type":"object","title":"SimpleQueryResponse"},"SimpleQueue":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}],"default":"pending"},"data":{"anyOf":[{"$ref":"#/components/schemas/SimpleQueueData"},{"type":"null"}]},"run_id":{"type":"string","format":"uuid","title":"Run Id"}},"type":"object","required":["run_id"],"title":"SimpleQueue"},"SimpleQueueCreate":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"status":{"anyOf":[{"$ref":"#/components/schemas/EvaluationStatus"},{"type":"null"}]},"data":{"anyOf":[{"$ref":"#/components/schemas/SimpleQueueData"},{"type":"null"}]}},"type":"object","title":"SimpleQueueCreate"},"SimpleQueueCreateRequest":{"properties":{"queue":{"$ref":"#/components/schemas/SimpleQueueCreate"}},"type":"object","required":["queue"],"title":"SimpleQueueCreateRequest"},"SimpleQueueData":{"properties":{"kind":{"anyOf":[{"$ref":"#/components/schemas/SimpleQueueKind"},{"type":"null"}]},"queries":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Queries"},"testsets":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Testsets"},"evaluators":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"additionalProperties":{"type":"string","enum":["custom","human","auto"]},"propertyNames":{"format":"uuid"},"type":"object"},{"type":"null"}],"title":"Evaluators"},"repeats":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Repeats"},"assignments":{"anyOf":[{"items":{"items":{"type":"string","format":"uuid"},"type":"array"},"type":"array"},{"type":"null"}],"title":"Assignments"},"step_keys":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Step Keys"},"settings":{"anyOf":[{"$ref":"#/components/schemas/SimpleQueueSettings"},{"type":"null"}]}},"type":"object","title":"SimpleQueueData"},"SimpleQueueIdResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"queue_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Queue Id"}},"type":"object","title":"SimpleQueueIdResponse"},"SimpleQueueIdsRequest":{"properties":{"queue_ids":{"items":{"type":"string","format":"uuid"},"type":"array","title":"Queue Ids"}},"type":"object","required":["queue_ids"],"title":"SimpleQueueIdsRequest"},"SimpleQueueIdsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"queue_ids":{"items":{"type":"string","format":"uuid"},"type":"array","title":"Queue Ids","default":[]}},"type":"object","title":"SimpleQueueIdsResponse"},"SimpleQueueKind":{"type":"string","enum":["queries","testsets","traces","testcases"],"title":"SimpleQueueKind"},"SimpleQueueQuery":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"kind":{"anyOf":[{"$ref":"#/components/schemas/SimpleQueueKind"},{"type":"null"}]},"user_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"User Id"},"user_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"User Ids"},"run_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Run Id"},"run_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Run Ids"},"queue_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Queue Ids"}},"type":"object","title":"SimpleQueueQuery"},"SimpleQueueQueryRequest":{"properties":{"queue":{"anyOf":[{"$ref":"#/components/schemas/SimpleQueueQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"SimpleQueueQueryRequest"},"SimpleQueueResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"queue":{"anyOf":[{"$ref":"#/components/schemas/SimpleQueue"},{"type":"null"}]}},"type":"object","title":"SimpleQueueResponse"},"SimpleQueueScenariosQuery":{"properties":{"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"user_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"User Id"},"user_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"User Ids"}},"type":"object","title":"SimpleQueueScenariosQuery"},"SimpleQueueScenariosQueryRequest":{"properties":{"queue":{"anyOf":[{"$ref":"#/components/schemas/SimpleQueueScenariosQuery"},{"type":"null"}]},"scenario":{"anyOf":[{"$ref":"#/components/schemas/EvaluationScenarioQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"SimpleQueueScenariosQueryRequest"},"SimpleQueueScenariosResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"scenarios":{"items":{"$ref":"#/components/schemas/EvaluationScenario"},"type":"array","title":"Scenarios","default":[]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"SimpleQueueScenariosResponse"},"SimpleQueueSettings":{"properties":{"batch_size":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Batch Size"},"batch_offset":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Batch Offset"}},"type":"object","title":"SimpleQueueSettings"},"SimpleQueueTestcasesCreateRequest":{"properties":{"testcase_ids":{"items":{"type":"string","format":"uuid"},"type":"array","title":"Testcase Ids"}},"type":"object","required":["testcase_ids"],"title":"SimpleQueueTestcasesCreateRequest"},"SimpleQueueTracesCreateRequest":{"properties":{"trace_ids":{"items":{"type":"string"},"type":"array","title":"Trace Ids"}},"type":"object","required":["trace_ids"],"title":"SimpleQueueTracesCreateRequest"},"SimpleQueuesResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"queues":{"items":{"$ref":"#/components/schemas/SimpleQueue"},"type":"array","title":"Queues","default":[]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"SimpleQueuesResponse"},"SimpleTestset":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/TestsetRevisionData-Output"},{"type":"null"}]},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"}},"type":"object","title":"SimpleTestset"},"SimpleTestsetCreate":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"data":{"anyOf":[{"$ref":"#/components/schemas/TestsetRevisionData-Input"},{"type":"null"}]}},"type":"object","title":"SimpleTestsetCreate"},"SimpleTestsetCreateRequest":{"properties":{"testset":{"$ref":"#/components/schemas/SimpleTestsetCreate","description":"Simple testset to create. `data.testcases` is committed as the first revision on a single variant in one call."}},"type":"object","required":["testset"],"title":"SimpleTestsetCreateRequest"},"SimpleTestsetEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/TestsetRevisionData-Input"},{"type":"null"}]}},"type":"object","title":"SimpleTestsetEdit"},"SimpleTestsetEditRequest":{"properties":{"testset":{"$ref":"#/components/schemas/SimpleTestsetEdit","description":"Simple testset fields to update. If `data.testcases` is provided, a new revision is committed with those testcases."}},"type":"object","required":["testset"],"title":"SimpleTestsetEditRequest"},"SimpleTestsetQuery":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},"type":"object","title":"SimpleTestsetQuery"},"SimpleTestsetQueryRequest":{"properties":{"testset":{"anyOf":[{"$ref":"#/components/schemas/SimpleTestsetQuery"},{"type":"null"}],"description":"Attribute filter on the testset (flags, tags, meta)."},"testset_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Testset Refs","description":"Restrict the query to specific testsets."},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived","description":"Include soft-deleted testsets."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor-based pagination. See the Query Pattern guide."}},"type":"object","title":"SimpleTestsetQueryRequest"},"SimpleTestsetResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"1 if a testset was returned, 0 otherwise.","default":0},"testset":{"anyOf":[{"$ref":"#/components/schemas/SimpleTestset"},{"type":"null"}],"description":"The testset with its latest revision testcases merged into `data.testcases`, and the revision ID on `revision_id`."}},"type":"object","title":"SimpleTestsetResponse"},"SimpleTestsetsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of simple testsets returned.","default":0},"testsets":{"items":{"$ref":"#/components/schemas/SimpleTestset"},"type":"array","title":"Testsets","description":"Simple testsets, each with its latest revision testcases merged in."}},"type":"object","title":"SimpleTestsetsResponse"},"SimpleTrace":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"span_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Id"},"trace_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Trace Id"},"attributes":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Attributes"},"origin":{"$ref":"#/components/schemas/SimpleTraceOrigin","default":"custom"},"kind":{"$ref":"#/components/schemas/SimpleTraceKind","default":"adhoc"},"channel":{"$ref":"#/components/schemas/SimpleTraceChannel","default":"api"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"data":{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object","title":"Data"},"references":{"$ref":"#/components/schemas/SimpleTraceReferences"},"links":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/OTelLink-Output"},"type":"object"},{"items":{"$ref":"#/components/schemas/OTelLink-Output"},"type":"array"}],"title":"Links"}},"type":"object","required":["data","references","links"],"title":"SimpleTrace"},"SimpleTraceChannel":{"type":"string","enum":["otlp","web","sdk","api"],"title":"SimpleTraceChannel"},"SimpleTraceCreate":{"properties":{"origin":{"$ref":"#/components/schemas/SimpleTraceOrigin","default":"custom"},"kind":{"$ref":"#/components/schemas/SimpleTraceKind","default":"adhoc"},"channel":{"$ref":"#/components/schemas/SimpleTraceChannel","default":"api"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"data":{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object","title":"Data"},"references":{"$ref":"#/components/schemas/SimpleTraceReferences"},"links":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"object"},{"items":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"array"}],"title":"Links"}},"type":"object","required":["data","references","links"],"title":"SimpleTraceCreate"},"SimpleTraceCreateRequest":{"properties":{"trace":{"$ref":"#/components/schemas/SimpleTraceCreate","description":"The trace to create. Must include `data` (the payload being recorded) and typically `origin`, `kind`, and `channel` to describe where it came from. Optional `references` link the trace to Agenta entities (app, variant, revision, evaluator, testset, etc.)."}},"type":"object","required":["trace"],"title":"SimpleTraceCreateRequest","description":"Request body for creating a single-span \"simple\" trace."},"SimpleTraceEdit":{"properties":{"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"data":{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object","title":"Data"},"references":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceReferences"},{"type":"null"}]},"links":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"object"},{"items":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"array"},{"type":"null"}],"title":"Links"}},"type":"object","required":["data"],"title":"SimpleTraceEdit"},"SimpleTraceEditRequest":{"properties":{"trace":{"$ref":"#/components/schemas/SimpleTraceEdit","description":"The fields to update. `data` is required. `tags`, `meta`, `references`, and `links` overwrite their current values when present."}},"type":"object","required":["trace"],"title":"SimpleTraceEditRequest","description":"Request body for editing an existing \"simple\" trace."},"SimpleTraceKind":{"type":"string","enum":["adhoc","eval","play"],"title":"SimpleTraceKind"},"SimpleTraceLinkResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` if a trace was removed, `0` otherwise.","default":0},"link":{"anyOf":[{"$ref":"#/components/schemas/OTelLink-Output"},{"type":"null"}],"description":"The `(trace_id, span_id)` pair that was removed."}},"type":"object","title":"SimpleTraceLinkResponse","description":"Response from `DELETE /simple/traces/{trace_id}`."},"SimpleTraceOrigin":{"type":"string","enum":["custom","human","auto"],"title":"SimpleTraceOrigin"},"SimpleTraceQuery":{"properties":{"origin":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceOrigin"},{"type":"null"}]},"kind":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceKind"},{"type":"null"}]},"channel":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceChannel"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"references":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceReferences"},{"type":"null"}]},"links":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"object"},{"items":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"array"},{"type":"null"}],"title":"Links"}},"type":"object","title":"SimpleTraceQuery"},"SimpleTraceQueryRequest":{"properties":{"trace":{"anyOf":[{"$ref":"#/components/schemas/SimpleTraceQuery"},{"type":"null"}],"description":"Filter fields on the trace itself — `origin`, `kind`, `channel`, `tags`, `meta`, `references`, and inbound `links`. Filtering by `trace.links.invocation` is the common pattern for finding annotations on a given span."},"links":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"array"},{"type":"null"}],"title":"Links","description":"Batch GET by the trace's own `(trace_id, span_id)`. Each entry matches the trace whose own identity equals the pair. Distinct from `trace.links`, which filters on inbound links."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor pagination and time range."}},"type":"object","title":"SimpleTraceQueryRequest","description":"Request body for `POST /simple/traces/query`."},"SimpleTraceReferences":{"properties":{"query":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"query_variant":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"query_revision":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"testset":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"testset_variant":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"testset_revision":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"application":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"application_variant":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"application_revision":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"evaluator":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"evaluator_variant":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"evaluator_revision":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"environment":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"environment_variant":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"environment_revision":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"testcase":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}]},"selector":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Selector"}},"type":"object","title":"SimpleTraceReferences"},"SimpleTraceResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` if the trace was returned, `0` otherwise.","default":0},"trace":{"anyOf":[{"$ref":"#/components/schemas/SimpleTrace"},{"type":"null"}],"description":"The created or fetched trace, including server-assigned `trace_id` and `span_id`."}},"type":"object","title":"SimpleTraceResponse","description":"Response from a single-trace create/fetch/edit."},"SimpleTracesResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of matching traces in this page.","default":0},"traces":{"items":{"$ref":"#/components/schemas/SimpleTrace"},"type":"array","title":"Traces","description":"The matching traces in the high-level `SimpleTrace` shape.","default":[]}},"type":"object","title":"SimpleTracesResponse","description":"Response from `POST /simple/traces/query`."},"SimpleWorkflow":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/SimpleWorkflowFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/SimpleWorkflowData-Output"},{"type":"null"}]},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"}},"type":"object","title":"SimpleWorkflow"},"SimpleWorkflowCreate":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/SimpleWorkflowFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"data":{"anyOf":[{"$ref":"#/components/schemas/SimpleWorkflowData-Input"},{"type":"null"}]}},"type":"object","title":"SimpleWorkflowCreate"},"SimpleWorkflowCreateRequest":{"properties":{"workflow":{"$ref":"#/components/schemas/SimpleWorkflowCreate","description":"Simple-workflow create payload. Creates the artifact, a default variant, and an initial revision in one call."}},"type":"object","required":["workflow"],"title":"SimpleWorkflowCreateRequest"},"SimpleWorkflowData-Input":{"properties":{"uri":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Uri"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"headers":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/Reference"}]},"type":"object"},{"type":"null"}],"title":"Headers"},"runtime":{"anyOf":[{"type":"string","enum":["python","typescript","javascript"]},{"type":"null"}],"title":"Runtime"},"script":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Script"},"schemas":{"anyOf":[{"$ref":"#/components/schemas/JsonSchemas-Input"},{"type":"null"}]},"parameters":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Parameters"}},"additionalProperties":false,"type":"object","title":"SimpleWorkflowData"},"SimpleWorkflowData-Output":{"properties":{"uri":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Uri"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"headers":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/Reference"}]},"type":"object"},{"type":"null"}],"title":"Headers"},"runtime":{"anyOf":[{"type":"string","enum":["python","typescript","javascript"]},{"type":"null"}],"title":"Runtime"},"script":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Script"},"schemas":{"anyOf":[{"$ref":"#/components/schemas/JsonSchemas-Output"},{"type":"null"}]},"parameters":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Parameters"}},"additionalProperties":false,"type":"object","title":"SimpleWorkflowData"},"SimpleWorkflowEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/SimpleWorkflowFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/SimpleWorkflowData-Input"},{"type":"null"}]}},"type":"object","title":"SimpleWorkflowEdit"},"SimpleWorkflowEditRequest":{"properties":{"workflow":{"$ref":"#/components/schemas/SimpleWorkflowEdit","description":"Simple-workflow edit payload. Updates artifact-level fields and commits a new revision when `data` changes."}},"type":"object","required":["workflow"],"title":"SimpleWorkflowEditRequest"},"SimpleWorkflowFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false},"is_managed":{"type":"boolean","title":"Is Managed","default":false},"is_custom":{"type":"boolean","title":"Is Custom","default":false},"is_llm":{"type":"boolean","title":"Is Llm","default":false},"is_hook":{"type":"boolean","title":"Is Hook","default":false},"is_code":{"type":"boolean","title":"Is Code","default":false},"is_match":{"type":"boolean","title":"Is Match","default":false},"is_feedback":{"type":"boolean","title":"Is Feedback","default":false},"is_agent":{"type":"boolean","title":"Is Agent","default":false},"is_skill":{"type":"boolean","title":"Is Skill","default":false},"is_chat":{"type":"boolean","title":"Is Chat","default":false},"has_url":{"type":"boolean","title":"Has Url","default":false},"has_script":{"type":"boolean","title":"Has Script","default":false},"has_handler":{"type":"boolean","title":"Has Handler","default":false},"is_static":{"type":"boolean","title":"Is Static","default":false}},"type":"object","title":"SimpleWorkflowFlags"},"SimpleWorkflowQuery":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/SimpleWorkflowQueryFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"slugs":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Slugs"}},"type":"object","title":"SimpleWorkflowQuery"},"SimpleWorkflowQueryFlags":{"properties":{"is_application":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Application"},"is_evaluator":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Evaluator"},"is_snippet":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Snippet"},"is_managed":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Managed"},"is_custom":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Custom"},"is_llm":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Llm"},"is_hook":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Hook"},"is_code":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Code"},"is_match":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Match"},"is_feedback":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Feedback"},"is_agent":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Agent"},"is_skill":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Skill"},"is_chat":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Chat"},"has_url":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Url"},"has_script":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Script"},"has_handler":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Handler"},"is_static":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Static"}},"type":"object","title":"SimpleWorkflowQueryFlags"},"SimpleWorkflowQueryRequest":{"properties":{"workflow":{"anyOf":[{"$ref":"#/components/schemas/SimpleWorkflowQuery"},{"type":"null"}],"description":"Attribute filter on simple workflows (slug, slugs, flags, tags, meta)."},"workflow_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Workflow Refs","description":"Restrict results to workflows matching these references."},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived","description":"When true, include archived workflows."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor-based pagination controls."}},"type":"object","title":"SimpleWorkflowQueryRequest"},"SimpleWorkflowResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when a simple workflow is returned, `0` when none matched.","default":0},"workflow":{"anyOf":[{"$ref":"#/components/schemas/SimpleWorkflow"},{"type":"null"}],"description":"Workflow artifact with its resolved variant and revision merged."}},"type":"object","title":"SimpleWorkflowResponse"},"SimpleWorkflowsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of workflows in the response.","default":0},"workflows":{"items":{"$ref":"#/components/schemas/SimpleWorkflow"},"type":"array","title":"Workflows","description":"Workflow artifacts each merged with their resolved variant and revision."}},"type":"object","title":"SimpleWorkflowsResponse"},"Span-Input":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"trace_id":{"type":"string","title":"Trace Id"},"span_id":{"type":"string","title":"Span Id"},"parent_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Parent Id"},"trace_type":{"anyOf":[{"$ref":"#/components/schemas/TraceType"},{"type":"null"}]},"span_type":{"anyOf":[{"$ref":"#/components/schemas/SpanType"},{"type":"null"}]},"span_kind":{"anyOf":[{"$ref":"#/components/schemas/OTelSpanKind"},{"type":"null"}]},"span_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Name"},"start_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"integer"},{"type":"null"}],"title":"Start Time"},"end_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"integer"},{"type":"null"}],"title":"End Time"},"status_code":{"anyOf":[{"$ref":"#/components/schemas/OTelStatusCode"},{"type":"null"}]},"status_message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status Message"},"session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Session Id"},"user_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"User Id"},"agent_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Id"},"attributes":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Attributes"},"references":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelReference-Input"},"type":"array"},{"type":"null"}],"title":"References"},"links":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"array"},{"type":"null"}],"title":"Links"},"hashes":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelHash-Input"},"type":"array"},{"type":"null"}],"title":"Hashes"},"exception":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Exception"},"events":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelEvent-Input"},"type":"array"},{"type":"null"}],"title":"Events"}},"type":"object","required":["trace_id","span_id"],"title":"Span"},"Span-Output":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"trace_id":{"type":"string","title":"Trace Id"},"span_id":{"type":"string","title":"Span Id"},"parent_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Parent Id"},"trace_type":{"anyOf":[{"$ref":"#/components/schemas/TraceType"},{"type":"null"}]},"span_type":{"anyOf":[{"$ref":"#/components/schemas/SpanType"},{"type":"null"}]},"span_kind":{"anyOf":[{"$ref":"#/components/schemas/OTelSpanKind"},{"type":"null"}]},"span_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Name"},"start_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"integer"},{"type":"null"}],"title":"Start Time"},"end_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"integer"},{"type":"null"}],"title":"End Time"},"status_code":{"anyOf":[{"$ref":"#/components/schemas/OTelStatusCode"},{"type":"null"}]},"status_message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status Message"},"session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Session Id"},"user_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"User Id"},"agent_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Id"},"attributes":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Attributes"},"references":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelReference-Output"},"type":"array"},{"type":"null"}],"title":"References"},"links":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelLink-Output"},"type":"array"},{"type":"null"}],"title":"Links"},"hashes":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelHash-Output"},"type":"array"},{"type":"null"}],"title":"Hashes"},"exception":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Exception"},"events":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelEvent-Output"},"type":"array"},{"type":"null"}],"title":"Events"}},"type":"object","required":["trace_id","span_id"],"title":"Span"},"SpanResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` if a span was returned, `0` otherwise.","default":0},"span":{"anyOf":[{"$ref":"#/components/schemas/Span-Output"},{"type":"null"}],"description":"The matching span, or `null` if not found."}},"type":"object","title":"SpanResponse"},"SpanType":{"type":"string","enum":["agent","chain","workflow","task","tool","embedding","query","llm","completion","chat","rerank","unknown"],"title":"SpanType"},"SpansNode-Input":{"properties":{"spans":{"anyOf":[{"additionalProperties":{"anyOf":[{"$ref":"#/components/schemas/SpansNode-Input"},{"items":{"$ref":"#/components/schemas/SpansNode-Input"},"type":"array"}]},"type":"object"},{"type":"null"}],"title":"Spans"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"trace_id":{"type":"string","title":"Trace Id"},"span_id":{"type":"string","title":"Span Id"},"parent_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Parent Id"},"trace_type":{"anyOf":[{"$ref":"#/components/schemas/TraceType"},{"type":"null"}]},"span_type":{"anyOf":[{"$ref":"#/components/schemas/SpanType"},{"type":"null"}]},"span_kind":{"anyOf":[{"$ref":"#/components/schemas/OTelSpanKind"},{"type":"null"}]},"span_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Name"},"start_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"integer"},{"type":"null"}],"title":"Start Time"},"end_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"integer"},{"type":"null"}],"title":"End Time"},"status_code":{"anyOf":[{"$ref":"#/components/schemas/OTelStatusCode"},{"type":"null"}]},"status_message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status Message"},"session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Session Id"},"user_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"User Id"},"agent_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Id"},"attributes":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Attributes"},"references":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelReference-Input"},"type":"array"},{"type":"null"}],"title":"References"},"links":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelLink-Input"},"type":"array"},{"type":"null"}],"title":"Links"},"hashes":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelHash-Input"},"type":"array"},{"type":"null"}],"title":"Hashes"},"exception":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Exception"},"events":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelEvent-Input"},"type":"array"},{"type":"null"}],"title":"Events"}},"type":"object","required":["trace_id","span_id"],"title":"SpansNode"},"SpansNode-Output":{"properties":{"spans":{"anyOf":[{"additionalProperties":{"anyOf":[{"$ref":"#/components/schemas/SpansNode-Output"},{"items":{"$ref":"#/components/schemas/SpansNode-Output"},"type":"array"}]},"type":"object"},{"type":"null"}],"title":"Spans"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"trace_id":{"type":"string","title":"Trace Id"},"span_id":{"type":"string","title":"Span Id"},"parent_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Parent Id"},"trace_type":{"anyOf":[{"$ref":"#/components/schemas/TraceType"},{"type":"null"}]},"span_type":{"anyOf":[{"$ref":"#/components/schemas/SpanType"},{"type":"null"}]},"span_kind":{"anyOf":[{"$ref":"#/components/schemas/OTelSpanKind"},{"type":"null"}]},"span_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Span Name"},"start_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"integer"},{"type":"null"}],"title":"Start Time"},"end_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"integer"},{"type":"null"}],"title":"End Time"},"status_code":{"anyOf":[{"$ref":"#/components/schemas/OTelStatusCode"},{"type":"null"}]},"status_message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status Message"},"session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Session Id"},"user_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"User Id"},"agent_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Agent Id"},"attributes":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Attributes"},"references":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelReference-Output"},"type":"array"},{"type":"null"}],"title":"References"},"links":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelLink-Output"},"type":"array"},{"type":"null"}],"title":"Links"},"hashes":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelHash-Output"},"type":"array"},{"type":"null"}],"title":"Hashes"},"exception":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Exception"},"events":{"anyOf":[{"items":{"$ref":"#/components/schemas/OTelEvent-Output"},"type":"array"},{"type":"null"}],"title":"Events"}},"type":"object","required":["trace_id","span_id"],"title":"SpansNode"},"SpansQueryRequest":{"properties":{"filtering":{"anyOf":[{"$ref":"#/components/schemas/Filtering-Input"},{"type":"null"}],"description":"Span-level conditions."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor pagination and time range."},"query_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Resolve filtering/windowing from a saved query."},"query_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Resolve from the latest revision of a specific query variant."},"query_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Resolve from a specific query revision. Returns `409` when the revision's stored `formatting.focus` is `trace`."}},"type":"object","title":"SpansQueryRequest","description":"Request body for `POST /spans/query`."},"SpansResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Total number of matching spans in the window.","default":0},"spans":{"anyOf":[{"items":{"$ref":"#/components/schemas/Span-Output"},"type":"array"},{"type":"null"}],"title":"Spans","description":"Flat list of matching spans."}},"type":"object","title":"SpansResponse"},"SpansTree-Input":{"properties":{"spans":{"anyOf":[{"additionalProperties":{"anyOf":[{"$ref":"#/components/schemas/SpansNode-Input"},{"items":{"$ref":"#/components/schemas/SpansNode-Input"},"type":"array"}]},"type":"object"},{"type":"null"}],"title":"Spans"}},"type":"object","title":"SpansTree"},"SpansTree-Output":{"properties":{"spans":{"anyOf":[{"additionalProperties":{"anyOf":[{"$ref":"#/components/schemas/SpansNode-Output"},{"items":{"$ref":"#/components/schemas/SpansNode-Output"},"type":"array"}]},"type":"object"},{"type":"null"}],"title":"Spans"}},"type":"object","title":"SpansTree"},"StandardProviderDTO":{"properties":{"kind":{"$ref":"#/components/schemas/StandardProviderKind"},"provider":{"$ref":"#/components/schemas/StandardProviderSettingsDTO"},"models":{"anyOf":[{"items":{"$ref":"#/components/schemas/CustomModelSettingsDTO"},"type":"array"},{"type":"null"}],"title":"Models"},"harnesses":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Harnesses"}},"type":"object","required":["kind","provider"],"title":"StandardProviderDTO"},"StandardProviderKind":{"type":"string","enum":["openai","cohere","anyscale","deepinfra","alephalpha","groq","minimax","mistral","mistralai","anthropic","perplexityai","together_ai","openrouter","gemini"],"title":"StandardProviderKind"},"StandardProviderSettingsDTO":{"properties":{"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key"}},"type":"object","title":"StandardProviderSettingsDTO"},"Status":{"properties":{"timestamp":{"type":"string","format":"date-time","title":"Timestamp"},"type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Type"},"code":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Code"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"stacktrace":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Stacktrace"}},"type":"object","title":"Status"},"StringOperator":{"type":"string","enum":["startswith","endswith","contains","matches","like"],"title":"StringOperator"},"Testcase-Input":{"properties":{"testset_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Id"},"set_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Set Id"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"data":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Data"}},"type":"object","title":"Testcase"},"Testcase-Output":{"properties":{"testset_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Id"},"set_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Set Id"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"data":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Data"}},"type":"object","title":"Testcase"},"TestcaseResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"1 if a testcase was returned, 0 otherwise.","default":0},"testcase":{"anyOf":[{"$ref":"#/components/schemas/Testcase-Output"},{"type":"null"}],"description":"The testcase blob. `data` carries the user-defined columns; `testcase_dedup_id` (inside `data`) is the caller-supplied dedup key when present."}},"type":"object","title":"TestcaseResponse"},"TestcasesQueryRequest":{"properties":{"testcase_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Testcase Ids","description":"Explicit list of testcase IDs to fetch. Combine with `testset_id` or testset references to scope the lookup."},"testset_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Id","description":"Return all testcases stored in this testset. The testset owns its testcases as a content-addressed bag; a revision references a subset of these."},"testset_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Testset reference used to resolve the latest revision on the default variant. The revision's ordered testcase IDs are used for the lookup and pagination."},"testset_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Testset variant reference used to resolve the latest revision on that variant."},"testset_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Specific testset revision reference. The revision's ordered testcase IDs drive the lookup and cursor pagination."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor-based pagination. When a revision reference is used, the cursor walks the revision's deterministic testcase ID list."}},"type":"object","title":"TestcasesQueryRequest"},"TestcasesResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of testcases returned on this page.","default":0},"testcases":{"items":{"$ref":"#/components/schemas/Testcase-Output"},"type":"array","title":"Testcases","description":"Testcase blobs matching the query, in revision-order when scoped by a revision reference."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor for the next page, if more results exist."}},"type":"object","title":"TestcasesResponse"},"Testset":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"Testset"},"TestsetCreate":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"TestsetCreate"},"TestsetCreateRequest":{"properties":{"testset":{"$ref":"#/components/schemas/TestsetCreate","description":"Testset artifact to create. The call only creates the artifact row; testcases are added by committing a revision (see /testsets/revisions/commit) or by using the /simple/testsets/ surface."}},"type":"object","required":["testset"],"title":"TestsetCreateRequest"},"TestsetEdit":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"TestsetEdit"},"TestsetEditRequest":{"properties":{"testset":{"$ref":"#/components/schemas/TestsetEdit","description":"Testset artifact fields to update. The `id` in the body must match the `testset_id` in the path."}},"type":"object","required":["testset"],"title":"TestsetEditRequest"},"TestsetFlags":{"properties":{},"type":"object","title":"TestsetFlags","description":"Placeholder for testset-level flags.\n\nThis model is intentionally empty but kept as a dedicated type so that:\n- existing references to `flags: Optional[TestsetFlags]` remain valid, and\n- structured flags can be added here in the future without breaking the\n surrounding DTOs."},"TestsetQuery":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"slugs":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Slugs"}},"type":"object","title":"TestsetQuery"},"TestsetQueryRequest":{"properties":{"testset":{"anyOf":[{"$ref":"#/components/schemas/TestsetQuery"},{"type":"null"}],"description":"Attribute filter (name, description, slug, flags, tags, meta, folder)."},"testset_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Testset Refs","description":"Restrict the query to specific testsets by reference (id or slug)."},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived","description":"Include soft-deleted testsets."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor-based pagination. See the Query Pattern guide."}},"type":"object","title":"TestsetQueryRequest"},"TestsetResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"1 if a testset was returned, 0 otherwise.","default":0},"testset":{"anyOf":[{"$ref":"#/components/schemas/Testset"},{"type":"null"}],"description":"The testset artifact. Does not include testcases."}},"type":"object","title":"TestsetResponse"},"TestsetRevision":{"properties":{"testset_variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Testset Variant Slug"},"variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Variant Slug"},"testset_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Testset Slug"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"testset_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"testset_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"author":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Author"},"date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Date"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/TestsetRevisionData-Output"},{"type":"null"}]}},"type":"object","title":"TestsetRevision"},"TestsetRevisionCommit":{"properties":{"testset_revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Revision Id"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"},"testset_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"testset_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"data":{"anyOf":[{"$ref":"#/components/schemas/TestsetRevisionData-Input"},{"type":"null"}]},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"delta":{"anyOf":[{"$ref":"#/components/schemas/TestsetRevisionDelta"},{"type":"null"}]}},"type":"object","title":"TestsetRevisionCommit"},"TestsetRevisionCommitRequest":{"properties":{"testset_revision":{"$ref":"#/components/schemas/TestsetRevisionCommit","description":"New revision to commit. Pass either `data` (full replacement of the testcase list) or `delta` (add/remove/replace operations against the base revision) — not both."},"include_testcases":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Testcases","description":"Include full testcase objects in the response."}},"type":"object","required":["testset_revision"],"title":"TestsetRevisionCommitRequest"},"TestsetRevisionCreate":{"properties":{"testset_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"testset_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"TestsetRevisionCreate"},"TestsetRevisionCreateRequest":{"properties":{"testset_revision":{"$ref":"#/components/schemas/TestsetRevisionCreate","description":"Revision to create on an existing variant. Typically used to seed an empty revision; use /testsets/revisions/commit to set testcases."},"include_testcases":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Testcases","description":"Include full testcase objects in the response. Defaults to true when the response would carry revision data."}},"type":"object","required":["testset_revision"],"title":"TestsetRevisionCreateRequest"},"TestsetRevisionData-Input":{"properties":{"testcase_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Testcase Ids"},"testcases":{"anyOf":[{"items":{"$ref":"#/components/schemas/Testcase-Input"},"type":"array"},{"type":"null"}],"title":"Testcases"}},"additionalProperties":false,"type":"object","title":"TestsetRevisionData"},"TestsetRevisionData-Output":{"properties":{"testcase_ids":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Testcase Ids"},"testcases":{"anyOf":[{"items":{"$ref":"#/components/schemas/Testcase-Output"},"type":"array"},{"type":"null"}],"title":"Testcases"}},"additionalProperties":false,"type":"object","title":"TestsetRevisionData"},"TestsetRevisionDelta":{"properties":{"rows":{"anyOf":[{"$ref":"#/components/schemas/TestsetRevisionDeltaRows"},{"type":"null"}]},"columns":{"anyOf":[{"$ref":"#/components/schemas/TestsetRevisionDeltaColumns"},{"type":"null"}]}},"type":"object","title":"TestsetRevisionDelta","description":"Operations to apply to a testset revision."},"TestsetRevisionDeltaColumns":{"properties":{"add":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Add"},"remove":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Remove"},"replace":{"anyOf":[{"items":{"prefixItems":[{"type":"string"},{"type":"string"}],"type":"array","maxItems":2,"minItems":2},"type":"array"},{"type":"null"}],"title":"Replace"}},"type":"object","title":"TestsetRevisionDeltaColumns","description":"Column-level operations applied to ALL testcases in the revision."},"TestsetRevisionDeltaRows":{"properties":{"add":{"anyOf":[{"items":{"$ref":"#/components/schemas/Testcase-Input"},"type":"array"},{"type":"null"}],"title":"Add"},"remove":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Remove"},"replace":{"anyOf":[{"items":{"$ref":"#/components/schemas/Testcase-Input"},"type":"array"},{"type":"null"}],"title":"Replace"}},"type":"object","title":"TestsetRevisionDeltaRows","description":"Row-level operations applied to testcases in the revision."},"TestsetRevisionEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"TestsetRevisionEdit"},"TestsetRevisionEditRequest":{"properties":{"testset_revision":{"$ref":"#/components/schemas/TestsetRevisionEdit","description":"Revision fields to update. The `id` in the body must match the `testset_revision_id` in the path. Only metadata fields are editable; content is committed as a new revision."},"include_testcases":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Testcases","description":"Include full testcase objects in the response."}},"type":"object","required":["testset_revision"],"title":"TestsetRevisionEditRequest"},"TestsetRevisionQuery":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"slugs":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Slugs"},"author":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Author"},"authors":{"anyOf":[{"items":{"type":"string","format":"uuid"},"type":"array"},{"type":"null"}],"title":"Authors"},"date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Date"},"dates":{"anyOf":[{"items":{"type":"string","format":"date-time"},"type":"array"},{"type":"null"}],"title":"Dates"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"}},"type":"object","title":"TestsetRevisionQuery"},"TestsetRevisionQueryRequest":{"properties":{"testset_revision":{"anyOf":[{"$ref":"#/components/schemas/TestsetRevisionQuery"},{"type":"null"}],"description":"Attribute filter on the revision (name, description, slug, author, date, message)."},"testset_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Testset Refs","description":"Scope revisions to these testsets."},"testset_variant_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Testset Variant Refs","description":"Scope revisions to these variants."},"testset_revision_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Testset Revision Refs","description":"Restrict to specific revisions by reference (id, slug, or version)."},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived","description":"Include soft-deleted revisions."},"include_testcases":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Testcases","description":"Include full testcase objects for each returned revision. Defaults to true."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor-based pagination. See the Query Pattern guide."}},"type":"object","title":"TestsetRevisionQueryRequest"},"TestsetRevisionResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"1 if a revision was returned, 0 otherwise.","default":0},"testset_revision":{"anyOf":[{"$ref":"#/components/schemas/TestsetRevision"},{"type":"null"}],"description":"The testset revision. `data.testcase_ids` is the ordered list of testcase IDs; `data.testcases` is populated when `include_testcases` is true."},"retrieval_info":{"anyOf":[{"$ref":"#/components/schemas/RetrievalInfo"},{"type":"null"}],"description":"References used to retrieve the top-level revision."}},"type":"object","title":"TestsetRevisionResponse"},"TestsetRevisionRetrieveRequest":{"properties":{"testset_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Testset artifact to look up. Identifies the artifact by `id` or `slug` (both project-unique). When no variant_ref or revision_ref is provided, returns the latest revision of an arbitrary variant of this testset."},"testset_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Testset variant to look up. Identifies the variant by `id` or `slug` (both project-unique). When no revision_ref is provided, returns the latest revision of this variant."},"testset_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Testset revision to look up. `id` alone identifies a revision (project-unique). `slug` alone identifies a revision (project-unique). `version` alone is a per-variant sequence number and is **not** sufficient on its own; it must be combined with a `testset_variant_ref`. Sending only `version` without a variant ref returns HTTP 400."},"include_testcase_ids":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Testcase Ids","description":"Include the ordered list of testcase IDs. Defaults to true (opt-out)."},"include_testcases":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Testcases","description":"Include full testcase objects. Defaults to true (opt-out). Note: this opt-out default is the opposite of `/queries/revisions/retrieve`, where trace materialization is opt-in."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Windowing applied to the testcases list when materialized."}},"type":"object","title":"TestsetRevisionRetrieveRequest"},"TestsetRevisionsLog":{"properties":{"testset_revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Revision Id"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"},"testset_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"testset_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"depth":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Depth"}},"type":"object","title":"TestsetRevisionsLog"},"TestsetRevisionsLogRequest":{"properties":{"testset_revisions":{"$ref":"#/components/schemas/TestsetRevisionsLog","description":"Scope for the log: one of `testset_id`, `testset_variant_id`, or `testset_revision_id`. Optional `depth` limits how far back to walk."},"include_testcases":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Testcases","description":"Include full testcase objects for each returned revision."}},"type":"object","required":["testset_revisions"],"title":"TestsetRevisionsLogRequest"},"TestsetRevisionsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of revisions returned.","default":0},"testset_revisions":{"items":{"$ref":"#/components/schemas/TestsetRevision"},"type":"array","title":"Testset Revisions","description":"Testset revisions matching the query, in the requested order."}},"type":"object","title":"TestsetRevisionsResponse"},"TestsetVariant":{"properties":{"testset_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Testset Slug"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"testset_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"TestsetVariant"},"TestsetVariantCreate":{"properties":{"testset_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Testset Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"TestsetVariantCreate"},"TestsetVariantCreateRequest":{"properties":{"testset_variant":{"$ref":"#/components/schemas/TestsetVariantCreate","description":"Variant to create on an existing testset. Pass `testset_id` to identify the parent artifact."}},"type":"object","required":["testset_variant"],"title":"TestsetVariantCreateRequest"},"TestsetVariantEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"TestsetVariantEdit"},"TestsetVariantEditRequest":{"properties":{"testset_variant":{"$ref":"#/components/schemas/TestsetVariantEdit","description":"Variant fields to update. The `id` in the body must match the `testset_variant_id` in the path."}},"type":"object","required":["testset_variant"],"title":"TestsetVariantEditRequest"},"TestsetVariantFork":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"TestsetVariantFork"},"TestsetVariantForkRequest":{"properties":{"testset_variant":{"$ref":"#/components/schemas/TestsetVariantFork","description":"Config for the new variant (slug, name, description, flags)."},"testset_variant_ref":{"$ref":"#/components/schemas/Reference","description":"Source variant to fork from."},"testset_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Pin the fork to this revision; defaults to the source variant's head."}},"type":"object","required":["testset_variant","testset_variant_ref"],"title":"TestsetVariantForkRequest"},"TestsetVariantQuery":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/TestsetFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"slugs":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Slugs"}},"type":"object","title":"TestsetVariantQuery"},"TestsetVariantQueryRequest":{"properties":{"testset_variant":{"anyOf":[{"$ref":"#/components/schemas/TestsetVariantQuery"},{"type":"null"}],"description":"Attribute filter on the variant (name, description, slug, flags, tags, meta)."},"testset_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Testset Refs","description":"Scope to variants whose parent testset matches one of these references."},"testset_variant_refs":{"anyOf":[{"items":{"$ref":"#/components/schemas/Reference"},"type":"array"},{"type":"null"}],"title":"Testset Variant Refs","description":"Restrict the query to specific variants by reference (id or slug)."},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived","description":"Include soft-deleted variants."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor-based pagination. See the Query Pattern guide."}},"type":"object","title":"TestsetVariantQueryRequest"},"TestsetVariantResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"1 if a variant was returned, 0 otherwise.","default":0},"testset_variant":{"anyOf":[{"$ref":"#/components/schemas/TestsetVariant"},{"type":"null"}],"description":"The testset variant (branch)."}},"type":"object","title":"TestsetVariantResponse"},"TestsetVariantsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of variants returned.","default":0},"testset_variants":{"items":{"$ref":"#/components/schemas/TestsetVariant"},"type":"array","title":"Testset Variants","description":"Testset variants matching the query."}},"type":"object","title":"TestsetVariantsResponse"},"TestsetsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of testsets returned on this page.","default":0},"testsets":{"items":{"$ref":"#/components/schemas/Testset"},"type":"array","title":"Testsets","description":"Testset artifacts matching the query, without testcases."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor for the next page, if more results exist."}},"type":"object","title":"TestsetsResponse"},"TextOptions":{"properties":{"case_sensitive":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Case Sensitive","default":false},"exact_match":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Exact Match","default":false}},"type":"object","title":"TextOptions"},"ToolAuthScheme":{"type":"string","enum":["oauth","api_key"],"title":"ToolAuthScheme"},"ToolCall":{"properties":{"data":{"$ref":"#/components/schemas/ToolCallData"},"context":{"anyOf":[{"$ref":"#/components/schemas/ToolCallContext"},{"type":"null"}]}},"type":"object","required":["data"],"title":"ToolCall","description":"Request envelope — wraps the raw OpenAI tool call."},"ToolCallContext":{"properties":{"provider":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider"},"integration":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Integration"},"connection":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Connection"},"tool":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tool"},"toolkit_version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Toolkit Version"},"toolkit_versions":{"anyOf":[{"additionalProperties":{"type":"string"},"type":"object"},{"type":"null"}],"title":"Toolkit Versions"}},"type":"object","title":"ToolCallContext","description":"Trusted routing the caller adds beside the model's arguments (contracts section 6).\n\nThe runner reads every field from its private resolved policy, so none of it is\nmodel input. ``connection`` and ``tool`` are absent for ``gateway.search``. The\ngateway routes refuse a call whose context is missing or incomplete; there is no\ndefault connection to fall back to."},"ToolCallData":{"properties":{"id":{"type":"string","title":"Id"},"type":{"type":"string","title":"Type","default":"function"},"function":{"$ref":"#/components/schemas/ToolCallFunction"}},"type":"object","required":["id","function"],"title":"ToolCallData","description":"OpenAI tool_calls array item — passed verbatim from the LLM."},"ToolCallFunction":{"properties":{"name":{"type":"string","title":"Name"},"arguments":{"title":"Arguments"}},"type":"object","required":["name","arguments"],"title":"ToolCallFunction","description":"Mirrors OpenAI function call: {name, arguments}."},"ToolCallResponse":{"properties":{"call":{"$ref":"#/components/schemas/ToolResult"}},"type":"object","required":["call"],"title":"ToolCallResponse"},"ToolCatalogAction":{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories","default":[]},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"provider_action_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider Action Id"},"read_only":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Read Only"}},"type":"object","required":["key","name"],"title":"ToolCatalogAction"},"ToolCatalogActionDetails":{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories","default":[]},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"provider_action_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider Action Id"},"read_only":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Read Only"},"schemas":{"anyOf":[{"$ref":"#/components/schemas/JsonSchemas-Output"},{"type":"null"}]},"scopes":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Scopes"}},"type":"object","required":["key","name"],"title":"ToolCatalogActionDetails"},"ToolCatalogActionResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"action":{"anyOf":[{"$ref":"#/components/schemas/ToolCatalogAction"},{"$ref":"#/components/schemas/ToolCatalogActionDetails"},{"type":"null"}],"title":"Action"}},"type":"object","title":"ToolCatalogActionResponse"},"ToolCatalogActionsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"total":{"type":"integer","title":"Total","default":0},"cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"},"actions":{"items":{"anyOf":[{"$ref":"#/components/schemas/ToolCatalogAction"},{"$ref":"#/components/schemas/ToolCatalogActionDetails"}]},"type":"array","title":"Actions","default":[]}},"type":"object","title":"ToolCatalogActionsResponse"},"ToolCatalogCategoriesResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"categories":{"items":{"$ref":"#/components/schemas/ToolCatalogCategory"},"type":"array","title":"Categories","default":[]}},"type":"object","title":"ToolCatalogCategoriesResponse"},"ToolCatalogCategory":{"properties":{"id":{"type":"string","title":"Id"},"name":{"type":"string","title":"Name"}},"type":"object","required":["id","name"],"title":"ToolCatalogCategory"},"ToolCatalogIntegration":{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories","default":[]},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"actions_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Actions Count"},"auth_schemes":{"anyOf":[{"items":{"$ref":"#/components/schemas/ToolAuthScheme"},"type":"array"},{"type":"null"}],"title":"Auth Schemes"}},"type":"object","required":["key","name"],"title":"ToolCatalogIntegration"},"ToolCatalogIntegrationDetails":{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories","default":[]},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"actions_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Actions Count"},"auth_schemes":{"anyOf":[{"items":{"$ref":"#/components/schemas/ToolAuthScheme"},"type":"array"},{"type":"null"}],"title":"Auth Schemes"},"actions":{"anyOf":[{"items":{"$ref":"#/components/schemas/ToolCatalogAction"},"type":"array"},{"type":"null"}],"title":"Actions"}},"type":"object","required":["key","name"],"title":"ToolCatalogIntegrationDetails"},"ToolCatalogIntegrationResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"integration":{"anyOf":[{"$ref":"#/components/schemas/ToolCatalogIntegration"},{"$ref":"#/components/schemas/ToolCatalogIntegrationDetails"},{"type":"null"}],"title":"Integration"}},"type":"object","title":"ToolCatalogIntegrationResponse"},"ToolCatalogIntegrationsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"total":{"type":"integer","title":"Total","default":0},"cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"},"integrations":{"items":{"anyOf":[{"$ref":"#/components/schemas/ToolCatalogIntegration"},{"$ref":"#/components/schemas/ToolCatalogIntegrationDetails"}]},"type":"array","title":"Integrations","default":[]}},"type":"object","title":"ToolCatalogIntegrationsResponse"},"ToolCatalogProvider":{"properties":{"key":{"$ref":"#/components/schemas/ToolProviderKind"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"integrations_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Integrations Count"}},"type":"object","required":["key","name"],"title":"ToolCatalogProvider"},"ToolCatalogProviderDetails":{"properties":{"key":{"$ref":"#/components/schemas/ToolProviderKind"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"integrations_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Integrations Count"},"integrations":{"anyOf":[{"items":{"$ref":"#/components/schemas/ToolCatalogIntegration"},"type":"array"},{"type":"null"}],"title":"Integrations"}},"type":"object","required":["key","name"],"title":"ToolCatalogProviderDetails"},"ToolCatalogProviderResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"provider":{"anyOf":[{"$ref":"#/components/schemas/ToolCatalogProvider"},{"$ref":"#/components/schemas/ToolCatalogProviderDetails"},{"type":"null"}],"title":"Provider"}},"type":"object","title":"ToolCatalogProviderResponse"},"ToolCatalogProvidersResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"providers":{"items":{"anyOf":[{"$ref":"#/components/schemas/ToolCatalogProvider"},{"$ref":"#/components/schemas/ToolCatalogProviderDetails"}]},"type":"array","title":"Providers","default":[]}},"type":"object","title":"ToolCatalogProvidersResponse"},"ToolConnection":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"provider_key":{"$ref":"#/components/schemas/ToolProviderKind"},"integration_key":{"type":"string","title":"Integration Key"},"data":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Data"},"status":{"anyOf":[{"$ref":"#/components/schemas/ToolConnectionStatus"},{"type":"null"}]}},"type":"object","required":["provider_key","integration_key"],"title":"ToolConnection"},"ToolConnectionCreate":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"provider_key":{"$ref":"#/components/schemas/ToolProviderKind"},"integration_key":{"type":"string","title":"Integration Key"},"data":{"anyOf":[{"$ref":"#/components/schemas/ToolConnectionCreateData"},{"type":"null"}]}},"type":"object","required":["provider_key","integration_key"],"title":"ToolConnectionCreate"},"ToolConnectionCreateData":{"properties":{"callback_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Callback Url"},"auth_scheme":{"anyOf":[{"$ref":"#/components/schemas/ToolAuthScheme"},{"type":"null"}]},"connected_account_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Connected Account Id"},"auth_config_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Auth Config Id"},"redirect_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Redirect Url"},"project_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Id"},"no_auth":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"No Auth"}},"type":"object","title":"ToolConnectionCreateData"},"ToolConnectionCreateRequest":{"properties":{"connection":{"$ref":"#/components/schemas/ToolConnectionCreate"}},"type":"object","required":["connection"],"title":"ToolConnectionCreateRequest"},"ToolConnectionResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"connection":{"anyOf":[{"$ref":"#/components/schemas/ToolConnection"},{"type":"null"}]}},"type":"object","title":"ToolConnectionResponse"},"ToolConnectionState":{"type":"string","enum":["ready","needs_auth","needs_input"],"title":"ToolConnectionState","description":"The connection state of one integration, derived per the design's state\nmachine. ``ready`` reuses an existing connection; the other two need a human."},"ToolConnectionStatus":{"properties":{"redirect_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Redirect Url"}},"type":"object","title":"ToolConnectionStatus"},"ToolConnectionsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"connections":{"items":{"$ref":"#/components/schemas/ToolConnection"},"type":"array","title":"Connections","default":[]}},"type":"object","title":"ToolConnectionsResponse"},"ToolProviderKind":{"type":"string","enum":["composio","agenta"],"title":"ToolProviderKind"},"ToolResolveRequest":{"properties":{"tools":{"items":{"anyOf":[{"$ref":"#/components/schemas/BuiltinToolConfig"},{"$ref":"#/components/schemas/GatewayToolConfig"},{"$ref":"#/components/schemas/GatewayConnectionToolConfig"}]},"type":"array","title":"Tools"}},"type":"object","title":"ToolResolveRequest"},"ToolResolveResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"builtins":{"items":{"type":"string"},"type":"array","title":"Builtins"},"custom":{"items":{"$ref":"#/components/schemas/ResolvedTool"},"type":"array","title":"Custom"},"gateway_connections":{"items":{"$ref":"#/components/schemas/ResolvedGatewayConnection"},"type":"array","title":"Gateway Connections"}},"type":"object","title":"ToolResolveResponse"},"ToolResult":{"properties":{"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"status":{"anyOf":[{"$ref":"#/components/schemas/Status"},{"type":"null"}]},"data":{"anyOf":[{"$ref":"#/components/schemas/ToolResultData"},{"type":"null"}]}},"type":"object","title":"ToolResult","description":"Response envelope with Agenta identity, status, and the OpenAI tool message."},"ToolResultData":{"properties":{"role":{"type":"string","title":"Role","default":"tool"},"tool_call_id":{"type":"string","title":"Tool Call Id"},"content":{"type":"string","title":"Content"}},"type":"object","required":["tool_call_id","content"],"title":"ToolResultData","description":"OpenAI tool message — passed verbatim back to the LLM."},"Trace-Input":{"properties":{"spans":{"anyOf":[{"additionalProperties":{"anyOf":[{"$ref":"#/components/schemas/SpansNode-Input"},{"items":{"$ref":"#/components/schemas/SpansNode-Input"},"type":"array"}]},"type":"object"},{"type":"null"}],"title":"Spans"},"trace_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Trace Id"}},"type":"object","title":"Trace"},"Trace-Output":{"properties":{"spans":{"anyOf":[{"additionalProperties":{"anyOf":[{"$ref":"#/components/schemas/SpansNode-Output"},{"items":{"$ref":"#/components/schemas/SpansNode-Output"},"type":"array"}]},"type":"object"},{"type":"null"}],"title":"Spans"},"trace_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Trace Id"}},"type":"object","title":"Trace"},"TraceIdResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` if a `trace_id` was returned, `0` otherwise.","default":0},"trace_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Trace Id","description":"32-char hex UUID identifying the trace that was created or edited."}},"type":"object","title":"TraceIdResponse"},"TraceIdsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of distinct trace IDs in this response.","default":0},"trace_ids":{"items":{"type":"string"},"type":"array","title":"Trace Ids","description":"32-char hex UUIDs of the traces that were ingested. Compare against the number you submitted to detect partial failures.","default":[]}},"type":"object","title":"TraceIdsResponse"},"TraceRequest":{"properties":{"trace":{"anyOf":[{"$ref":"#/components/schemas/Trace-Input"},{"type":"null"}],"description":"A single trace record (trace_id plus nested spans). The `trace_id` must match the path parameter on edit endpoints."}},"type":"object","title":"TraceRequest","description":"Ingest or edit payload for a single canonical `Trace`."},"TraceResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` if a trace was returned, `0` otherwise.","default":0},"trace":{"anyOf":[{"$ref":"#/components/schemas/Trace-Output"},{"type":"null"}],"description":"The trace in the canonical `Trace` shape (`trace_id` + nested `spans` tree)."}},"type":"object","title":"TraceResponse"},"TraceType":{"type":"string","enum":["invocation","annotation","unknown"],"title":"TraceType"},"TracesQueryRequest":{"properties":{"filtering":{"anyOf":[{"$ref":"#/components/schemas/Filtering-Input"},{"type":"null"}],"description":"Span-level conditions. A trace matches when any of its spans matches."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor pagination and time range (see [Query Pattern](/reference/api-guide/query-pattern#windowing))."},"query_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Resolve filtering/windowing from a saved query by `id`/`slug`. Only one of the three `query_*_ref` fields is needed."},"query_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Resolve from the latest revision of a specific query variant."},"query_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Resolve from a specific query revision. Returns `409` when the revision's stored `formatting.focus` is `span`."}},"type":"object","title":"TracesQueryRequest","description":"Request body for `POST /traces/query`."},"TracesRequest":{"properties":{"traces":{"anyOf":[{"items":{"$ref":"#/components/schemas/Trace-Input"},"type":"array"},{"type":"null"}],"title":"Traces","description":"List of trace records. Each record is a `trace_id` plus the nested `spans` tree. Equivalent to the map-shaped payload accepted by `POST /tracing/spans/ingest`."}},"type":"object","title":"TracesRequest","description":"Ingest payload in the canonical `Traces` list shape.\n\nUsed by `POST /traces/ingest`. Each entry is one trace with its\n`trace_id` and a nested `spans` tree."},"TracesResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Total number of matching traces in the window.","default":0},"traces":{"anyOf":[{"items":{"$ref":"#/components/schemas/Trace-Output"},"type":"array"},{"type":"null"}],"title":"Traces","description":"List of traces in the canonical `Traces` shape. For the map-shaped payload keyed by `trace_id`, call `POST /tracing/spans/query` with `focus=\"trace\"`."}},"type":"object","title":"TracesResponse"},"TracingQuery":{"properties":{"formatting":{"anyOf":[{"$ref":"#/components/schemas/Formatting"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]},"filtering":{"anyOf":[{"$ref":"#/components/schemas/Filtering-Output"},{"type":"null"}]}},"type":"object","title":"TracingQuery"},"TriggerAuthScheme":{"type":"string","enum":["oauth","api_key"],"title":"TriggerAuthScheme"},"TriggerCapabilitiesResult":{"properties":{"capabilities":{"items":{"$ref":"#/components/schemas/TriggerCapability"},"type":"array","title":"Capabilities"},"connections":{"items":{"$ref":"#/components/schemas/TriggerConnectionRequirement"},"type":"array","title":"Connections"},"guidance":{"$ref":"#/components/schemas/TriggerDiscoveryGuidance"},"ready":{"type":"boolean","title":"Ready","default":false},"notes":{"items":{"type":"string"},"type":"array","title":"Notes"}},"type":"object","title":"TriggerCapabilitiesResult"},"TriggerCapability":{"properties":{"use_case":{"type":"string","title":"Use Case"},"integration":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Integration"},"event":{"anyOf":[{"$ref":"#/components/schemas/DiscoveredTriggerEvent"},{"type":"null"}]},"alternatives":{"items":{"$ref":"#/components/schemas/DiscoveredTriggerAlternative"},"type":"array","title":"Alternatives"},"connection":{"anyOf":[{"$ref":"#/components/schemas/TriggerCapabilityConnection"},{"type":"null"}]},"note":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Note"}},"type":"object","required":["use_case"],"title":"TriggerCapability"},"TriggerCapabilityConnection":{"properties":{"state":{"$ref":"#/components/schemas/TriggerDiscoveryConnectionState"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","required":["state"],"title":"TriggerCapabilityConnection"},"TriggerCatalogEvent":{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"provider":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider"},"integration":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Integration"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories"},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"}},"type":"object","required":["key","name"],"title":"TriggerCatalogEvent"},"TriggerCatalogEventDetails":{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"provider":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider"},"integration":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Integration"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories"},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"trigger_config":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Trigger Config"},"payload":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Payload"}},"type":"object","required":["key","name"],"title":"TriggerCatalogEventDetails"},"TriggerCatalogEventResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"event":{"anyOf":[{"$ref":"#/components/schemas/TriggerCatalogEventDetails"},{"type":"null"}]}},"type":"object","title":"TriggerCatalogEventResponse"},"TriggerCatalogEventsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"total":{"type":"integer","title":"Total","default":0},"cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"},"events":{"items":{"$ref":"#/components/schemas/TriggerCatalogEvent"},"type":"array","title":"Events"}},"type":"object","title":"TriggerCatalogEventsResponse"},"TriggerCatalogIntegration":{"properties":{"key":{"type":"string","title":"Key"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"categories":{"items":{"type":"string"},"type":"array","title":"Categories","default":[]},"logo":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Logo"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"actions_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Actions Count"},"auth_schemes":{"anyOf":[{"items":{"$ref":"#/components/schemas/TriggerAuthScheme"},"type":"array"},{"type":"null"}],"title":"Auth Schemes"}},"type":"object","required":["key","name"],"title":"TriggerCatalogIntegration"},"TriggerCatalogIntegrationResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"integration":{"anyOf":[{"$ref":"#/components/schemas/TriggerCatalogIntegration"},{"type":"null"}]}},"type":"object","title":"TriggerCatalogIntegrationResponse"},"TriggerCatalogIntegrationsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"total":{"type":"integer","title":"Total","default":0},"cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor"},"integrations":{"items":{"$ref":"#/components/schemas/TriggerCatalogIntegration"},"type":"array","title":"Integrations"}},"type":"object","title":"TriggerCatalogIntegrationsResponse"},"TriggerCatalogProvider":{"properties":{"key":{"$ref":"#/components/schemas/TriggerProviderKind"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"integrations_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Integrations Count"}},"type":"object","required":["key","name"],"title":"TriggerCatalogProvider"},"TriggerCatalogProviderResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"provider":{"anyOf":[{"$ref":"#/components/schemas/TriggerCatalogProvider"},{"type":"null"}]}},"type":"object","title":"TriggerCatalogProviderResponse"},"TriggerCatalogProvidersResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"providers":{"items":{"$ref":"#/components/schemas/TriggerCatalogProvider"},"type":"array","title":"Providers"}},"type":"object","title":"TriggerCatalogProvidersResponse"},"TriggerConnectAffordance":{"properties":{"endpoint":{"type":"string","title":"Endpoint","default":"POST /triggers/connections/"},"body":{"additionalProperties":true,"type":"object","title":"Body"}},"type":"object","required":["body"],"title":"TriggerConnectAffordance"},"TriggerConnection":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"provider_key":{"$ref":"#/components/schemas/TriggerProviderKind"},"integration_key":{"type":"string","title":"Integration Key"},"data":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Data"},"status":{"anyOf":[{"$ref":"#/components/schemas/TriggerConnectionStatus"},{"type":"null"}]}},"type":"object","required":["provider_key","integration_key"],"title":"TriggerConnection"},"TriggerConnectionCreate":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"provider_key":{"$ref":"#/components/schemas/TriggerProviderKind"},"integration_key":{"type":"string","title":"Integration Key"},"data":{"anyOf":[{"$ref":"#/components/schemas/TriggerConnectionCreateData"},{"type":"null"}]}},"type":"object","required":["provider_key","integration_key"],"title":"TriggerConnectionCreate"},"TriggerConnectionCreateData":{"properties":{"callback_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Callback Url"},"auth_scheme":{"anyOf":[{"$ref":"#/components/schemas/TriggerAuthScheme"},{"type":"null"}]},"connected_account_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Connected Account Id"},"auth_config_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Auth Config Id"},"redirect_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Redirect Url"},"project_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Id"},"no_auth":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"No Auth"}},"type":"object","title":"TriggerConnectionCreateData"},"TriggerConnectionCreateRequest":{"properties":{"connection":{"$ref":"#/components/schemas/TriggerConnectionCreate"}},"type":"object","required":["connection"],"title":"TriggerConnectionCreateRequest"},"TriggerConnectionRequirement":{"properties":{"integration":{"type":"string","title":"Integration"},"state":{"$ref":"#/components/schemas/TriggerDiscoveryConnectionState"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"connect":{"anyOf":[{"$ref":"#/components/schemas/TriggerConnectAffordance"},{"type":"null"}]}},"type":"object","required":["integration","state"],"title":"TriggerConnectionRequirement"},"TriggerConnectionResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"connection":{"anyOf":[{"$ref":"#/components/schemas/TriggerConnection"},{"type":"null"}]}},"type":"object","title":"TriggerConnectionResponse"},"TriggerConnectionStatus":{"properties":{"redirect_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Redirect Url"}},"type":"object","title":"TriggerConnectionStatus"},"TriggerConnectionsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"connections":{"items":{"$ref":"#/components/schemas/TriggerConnection"},"type":"array","title":"Connections"}},"type":"object","title":"TriggerConnectionsResponse"},"TriggerDeliveriesResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"deliveries":{"items":{"$ref":"#/components/schemas/TriggerDelivery"},"type":"array","title":"Deliveries"}},"type":"object","title":"TriggerDeliveriesResponse"},"TriggerDelivery":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"status":{"$ref":"#/components/schemas/Status"},"data":{"anyOf":[{"$ref":"#/components/schemas/TriggerDeliveryData"},{"type":"null"}]},"subscription_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Subscription Id"},"schedule_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Schedule Id"},"event_id":{"type":"string","title":"Event Id"}},"type":"object","required":["status","event_id"],"title":"TriggerDelivery"},"TriggerDeliveryData":{"properties":{"event_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Event Key"},"session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Session Id"},"references":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/Reference"},"type":"object"},{"type":"null"}],"title":"References"},"inputs":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Inputs"},"result":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Result"},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"},"is_test":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Test"}},"type":"object","title":"TriggerDeliveryData"},"TriggerDeliveryQuery":{"properties":{"status":{"anyOf":[{"$ref":"#/components/schemas/Status"},{"type":"null"}]},"subscription_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Subscription Id"},"schedule_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Schedule Id"},"event_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Event Id"}},"type":"object","title":"TriggerDeliveryQuery"},"TriggerDeliveryQueryRequest":{"properties":{"delivery":{"anyOf":[{"$ref":"#/components/schemas/TriggerDeliveryQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"TriggerDeliveryQueryRequest"},"TriggerDeliveryResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"delivery":{"anyOf":[{"$ref":"#/components/schemas/TriggerDelivery"},{"type":"null"}]}},"type":"object","title":"TriggerDeliveryResponse"},"TriggerDiscoveryConnectionState":{"type":"string","enum":["ready","needs_auth","needs_input"],"title":"TriggerDiscoveryConnectionState"},"TriggerDiscoveryGuidance":{"properties":{"plan_steps":{"items":{"type":"string"},"type":"array","title":"Plan Steps"},"pitfalls":{"items":{"type":"string"},"type":"array","title":"Pitfalls"}},"type":"object","title":"TriggerDiscoveryGuidance"},"TriggerDiscoveryQuery":{"properties":{"use_cases":{"items":{"type":"string"},"type":"array","title":"Use Cases"},"provider":{"type":"string","title":"Provider","default":"composio"},"limit_alternatives":{"type":"integer","minimum":0.0,"title":"Limit Alternatives","default":3}},"type":"object","required":["use_cases"],"title":"TriggerDiscoveryQuery","description":"Request body for ``POST /triggers/discover``."},"TriggerEventAck":{"properties":{"status":{"type":"string","title":"Status","default":"accepted"},"detail":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Detail"}},"type":"object","title":"TriggerEventAck"},"TriggerProviderKind":{"type":"string","enum":["composio"],"title":"TriggerProviderKind"},"TriggerSchedule":{"properties":{"flags":{"$ref":"#/components/schemas/TriggerScheduleFlags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"$ref":"#/components/schemas/TriggerScheduleData"}},"type":"object","required":["data"],"title":"TriggerSchedule"},"TriggerScheduleCreate":{"properties":{"flags":{"$ref":"#/components/schemas/TriggerScheduleFlags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"data":{"$ref":"#/components/schemas/TriggerScheduleData"}},"type":"object","required":["data"],"title":"TriggerScheduleCreate"},"TriggerScheduleCreateRequest":{"properties":{"schedule":{"$ref":"#/components/schemas/TriggerScheduleCreate"}},"type":"object","required":["schedule"],"title":"TriggerScheduleCreateRequest"},"TriggerScheduleData":{"properties":{"event_key":{"type":"string","title":"Event Key"},"schedule":{"type":"string","title":"Schedule"},"start_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Start Time"},"end_time":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"End Time"},"inputs_fields":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"string"},{"type":"null"}],"title":"Inputs Fields"},"references":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/Reference"},"type":"object"},{"type":"null"}],"title":"References"},"selector":{"anyOf":[{"$ref":"#/components/schemas/Selector"},{"type":"null"}]}},"type":"object","required":["event_key","schedule"],"title":"TriggerScheduleData"},"TriggerScheduleEdit":{"properties":{"flags":{"$ref":"#/components/schemas/TriggerScheduleFlags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"$ref":"#/components/schemas/TriggerScheduleData"}},"type":"object","required":["data"],"title":"TriggerScheduleEdit"},"TriggerScheduleEditRequest":{"properties":{"schedule":{"$ref":"#/components/schemas/TriggerScheduleEdit"}},"type":"object","required":["schedule"],"title":"TriggerScheduleEditRequest"},"TriggerScheduleFlags":{"properties":{"is_active":{"type":"boolean","title":"Is Active","default":true}},"type":"object","title":"TriggerScheduleFlags"},"TriggerScheduleQuery":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"event_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Event Key"}},"type":"object","title":"TriggerScheduleQuery"},"TriggerScheduleQueryRequest":{"properties":{"schedule":{"anyOf":[{"$ref":"#/components/schemas/TriggerScheduleQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"TriggerScheduleQueryRequest"},"TriggerScheduleResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"schedule":{"anyOf":[{"$ref":"#/components/schemas/TriggerSchedule"},{"type":"null"}]}},"type":"object","title":"TriggerScheduleResponse"},"TriggerSchedulesResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"schedules":{"items":{"$ref":"#/components/schemas/TriggerSchedule"},"type":"array","title":"Schedules"}},"type":"object","title":"TriggerSchedulesResponse"},"TriggerSubscription":{"properties":{"flags":{"$ref":"#/components/schemas/TriggerSubscriptionFlags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"connection_id":{"type":"string","format":"uuid","title":"Connection Id"},"trigger_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Trigger Id"},"data":{"$ref":"#/components/schemas/TriggerSubscriptionData"}},"type":"object","required":["connection_id","data"],"title":"TriggerSubscription"},"TriggerSubscriptionCreate":{"properties":{"flags":{"$ref":"#/components/schemas/TriggerSubscriptionFlags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"connection_id":{"type":"string","format":"uuid","title":"Connection Id"},"data":{"$ref":"#/components/schemas/TriggerSubscriptionData"}},"type":"object","required":["connection_id","data"],"title":"TriggerSubscriptionCreate"},"TriggerSubscriptionCreateRequest":{"properties":{"subscription":{"$ref":"#/components/schemas/TriggerSubscriptionCreate"}},"type":"object","required":["subscription"],"title":"TriggerSubscriptionCreateRequest"},"TriggerSubscriptionData":{"properties":{"event_key":{"type":"string","title":"Event Key"},"trigger_config":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Trigger Config"},"inputs_fields":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"string"},{"type":"null"}],"title":"Inputs Fields"},"references":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/Reference"},"type":"object"},{"type":"null"}],"title":"References"},"selector":{"anyOf":[{"$ref":"#/components/schemas/Selector"},{"type":"null"}]}},"type":"object","required":["event_key"],"title":"TriggerSubscriptionData"},"TriggerSubscriptionEdit":{"properties":{"flags":{"$ref":"#/components/schemas/TriggerSubscriptionFlags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"connection_id":{"type":"string","format":"uuid","title":"Connection Id"},"data":{"$ref":"#/components/schemas/TriggerSubscriptionData"}},"type":"object","required":["connection_id","data"],"title":"TriggerSubscriptionEdit"},"TriggerSubscriptionEditRequest":{"properties":{"subscription":{"$ref":"#/components/schemas/TriggerSubscriptionEdit"}},"type":"object","required":["subscription"],"title":"TriggerSubscriptionEditRequest"},"TriggerSubscriptionFlags":{"properties":{"is_active":{"type":"boolean","title":"Is Active","default":true},"is_valid":{"type":"boolean","title":"Is Valid","default":true},"is_test":{"type":"boolean","title":"Is Test","default":false}},"type":"object","title":"TriggerSubscriptionFlags"},"TriggerSubscriptionQuery":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"connection_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Connection Id"},"event_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Event Key"}},"type":"object","title":"TriggerSubscriptionQuery"},"TriggerSubscriptionQueryRequest":{"properties":{"subscription":{"anyOf":[{"$ref":"#/components/schemas/TriggerSubscriptionQuery"},{"type":"null"}]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"TriggerSubscriptionQueryRequest"},"TriggerSubscriptionResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"subscription":{"anyOf":[{"$ref":"#/components/schemas/TriggerSubscription"},{"type":"null"}]}},"type":"object","title":"TriggerSubscriptionResponse"},"TriggerSubscriptionsResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"subscriptions":{"items":{"$ref":"#/components/schemas/TriggerSubscription"},"type":"array","title":"Subscriptions"}},"type":"object","title":"TriggerSubscriptionsResponse"},"UpdateProjectRequest":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"make_default":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Make Default"}},"type":"object","title":"UpdateProjectRequest"},"UpdateSecretDTO":{"properties":{"header":{"anyOf":[{"$ref":"#/components/schemas/Header"},{"type":"null"}]},"secret":{"anyOf":[{"$ref":"#/components/schemas/UpdateSecretPayloadDTO"},{"type":"null"}]}},"additionalProperties":false,"type":"object","title":"UpdateSecretDTO"},"UpdateSecretPayloadDTO":{"properties":{"kind":{"$ref":"#/components/schemas/SecretKind"},"data":{"anyOf":[{"$ref":"#/components/schemas/StandardProviderDTO"},{"$ref":"#/components/schemas/CustomProviderDTO"},{"$ref":"#/components/schemas/SSOProviderDTO"},{"$ref":"#/components/schemas/WebhookProviderDTO"},{"$ref":"#/components/schemas/CustomSecretDTO"}],"title":"Data"}},"type":"object","required":["kind","data"],"title":"UpdateSecretPayloadDTO","description":"Update-time payload. Omitted credential fields keep their stored values."},"UpdateWorkspace":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"}},"type":"object","title":"UpdateWorkspace"},"UserIdsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of user IDs in this page.","default":0},"user_ids":{"items":{"type":"string"},"type":"array","title":"User Ids","description":"Distinct values of `ag.user.id` in this page.","default":[]},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor for the next page. Pass verbatim as `windowing.next`."}},"type":"object","title":"UserIdsResponse"},"UserRole":{"properties":{"email":{"type":"string","title":"Email"},"role":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Role"},"organization_id":{"type":"string","title":"Organization Id"}},"type":"object","required":["email","organization_id"],"title":"UserRole"},"UserUpdate":{"properties":{"username":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Username"},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email"},"updated_at":{"type":"string","title":"Updated At"}},"type":"object","title":"UserUpdate"},"UsersQueryRequest":{"properties":{"realtime":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Realtime","description":"When `true`, paginate by `last_active`. When `false` or unset, paginate by the stable `first_active` cursor."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Cursor pagination and time range."}},"type":"object","title":"UsersQueryRequest","description":"Request body for `POST /tracing/users/query`."},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"WebhookDeliveriesResponse":{"properties":{"count":{"type":"integer","title":"Count"},"deliveries":{"items":{"$ref":"#/components/schemas/WebhookDelivery"},"type":"array","title":"Deliveries","default":[]}},"type":"object","required":["count"],"title":"WebhookDeliveriesResponse"},"WebhookDelivery":{"properties":{"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"status":{"$ref":"#/components/schemas/Status"},"data":{"anyOf":[{"$ref":"#/components/schemas/WebhookDeliveryData"},{"type":"null"}]},"subscription_id":{"type":"string","format":"uuid","title":"Subscription Id"},"event_id":{"type":"string","format":"uuid","title":"Event Id"}},"type":"object","required":["status","subscription_id","event_id"],"title":"WebhookDelivery"},"WebhookDeliveryCreate":{"properties":{"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"status":{"$ref":"#/components/schemas/Status"},"data":{"anyOf":[{"$ref":"#/components/schemas/WebhookDeliveryData"},{"type":"null"}]},"subscription_id":{"type":"string","format":"uuid","title":"Subscription Id"},"event_id":{"type":"string","format":"uuid","title":"Event Id"}},"type":"object","required":["status","subscription_id","event_id"],"title":"WebhookDeliveryCreate"},"WebhookDeliveryCreateRequest":{"properties":{"delivery":{"$ref":"#/components/schemas/WebhookDeliveryCreate"}},"type":"object","required":["delivery"],"title":"WebhookDeliveryCreateRequest"},"WebhookDeliveryData":{"properties":{"event_type":{"anyOf":[{"$ref":"#/components/schemas/WebhookEventType"},{"type":"null"}]},"url":{"type":"string","maxLength":2083,"minLength":1,"format":"uri","title":"Url"},"headers":{"anyOf":[{"additionalProperties":{"type":"string"},"type":"object"},{"type":"null"}],"title":"Headers"},"payload":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Payload"},"response":{"anyOf":[{"$ref":"#/components/schemas/WebhookDeliveryResponseInfo"},{"type":"null"}]},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"}},"type":"object","required":["url"],"title":"WebhookDeliveryData"},"WebhookDeliveryQuery":{"properties":{"status":{"anyOf":[{"$ref":"#/components/schemas/Status"},{"type":"null"}]},"subscription_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Subscription Id"},"event_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Event Id"}},"type":"object","title":"WebhookDeliveryQuery"},"WebhookDeliveryQueryRequest":{"properties":{"delivery":{"anyOf":[{"$ref":"#/components/schemas/WebhookDeliveryQuery"},{"type":"null"}]},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"WebhookDeliveryQueryRequest"},"WebhookDeliveryResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"delivery":{"anyOf":[{"$ref":"#/components/schemas/WebhookDelivery"},{"type":"null"}]}},"type":"object","title":"WebhookDeliveryResponse"},"WebhookDeliveryResponseInfo":{"properties":{"status_code":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Status Code"},"body":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Body"}},"type":"object","title":"WebhookDeliveryResponseInfo"},"WebhookEventType":{"type":"string","enum":["webhooks.subscriptions.tested","traces.fetched","traces.queried","queries.revisions.retrieved","queries.revisions.fetched","queries.revisions.queried","queries.revisions.logged","queries.revisions.committed","testcases.fetched","testcases.queried","testsets.revisions.retrieved","testsets.revisions.fetched","testsets.revisions.queried","testsets.revisions.logged","testsets.revisions.committed","workflows.revisions.retrieved","workflows.revisions.fetched","workflows.revisions.queried","workflows.revisions.logged","workflows.revisions.committed","environments.revisions.retrieved","environments.revisions.fetched","environments.revisions.queried","environments.revisions.logged","environments.revisions.committed"],"title":"WebhookEventType","description":"Subscribable event types — a strict subset of EventType.\n\nValues are derived from EventType so the strings stay in sync.\nTo add a new subscribable event type, it must first exist in EventType.\nWhen extending this enum, regenerate Fern clients and update the\n\"Available event types\" section in `04-webhooks.mdx`."},"WebhookProviderDTO":{"properties":{"provider":{"$ref":"#/components/schemas/WebhookProviderSettingsDTO"}},"type":"object","required":["provider"],"title":"WebhookProviderDTO"},"WebhookProviderSettingsDTO":{"properties":{"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key"}},"type":"object","title":"WebhookProviderSettingsDTO"},"WebhookSubscription":{"properties":{"flags":{"$ref":"#/components/schemas/WebhookSubscriptionFlags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"$ref":"#/components/schemas/WebhookSubscriptionData"},"secret_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Secret Id"},"secret":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Secret"}},"type":"object","required":["data"],"title":"WebhookSubscription"},"WebhookSubscriptionCreate":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"data":{"$ref":"#/components/schemas/WebhookSubscriptionData"},"secret":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Secret"}},"type":"object","required":["data"],"title":"WebhookSubscriptionCreate"},"WebhookSubscriptionCreateRequest":{"properties":{"subscription":{"$ref":"#/components/schemas/WebhookSubscriptionCreate"}},"type":"object","required":["subscription"],"title":"WebhookSubscriptionCreateRequest"},"WebhookSubscriptionData":{"properties":{"url":{"type":"string","maxLength":2083,"minLength":1,"format":"uri","title":"Url"},"headers":{"anyOf":[{"additionalProperties":{"type":"string"},"type":"object"},{"type":"null"}],"title":"Headers"},"payload_fields":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Payload Fields"},"auth_mode":{"anyOf":[{"type":"string","enum":["signature","authorization"]},{"type":"null"}],"title":"Auth Mode"},"event_types":{"anyOf":[{"items":{"$ref":"#/components/schemas/WebhookEventType"},"type":"array"},{"type":"null"}],"title":"Event Types"}},"type":"object","required":["url"],"title":"WebhookSubscriptionData"},"WebhookSubscriptionEdit":{"properties":{"flags":{"$ref":"#/components/schemas/WebhookSubscriptionFlags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"$ref":"#/components/schemas/WebhookSubscriptionData"},"secret":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Secret"}},"type":"object","required":["data"],"title":"WebhookSubscriptionEdit"},"WebhookSubscriptionEditRequest":{"properties":{"subscription":{"$ref":"#/components/schemas/WebhookSubscriptionEdit"}},"type":"object","required":["subscription"],"title":"WebhookSubscriptionEditRequest"},"WebhookSubscriptionFlags":{"properties":{"is_active":{"type":"boolean","title":"Is Active","default":true}},"type":"object","title":"WebhookSubscriptionFlags"},"WebhookSubscriptionQuery":{"properties":{"flags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Flags"},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},"type":"object","title":"WebhookSubscriptionQuery"},"WebhookSubscriptionQueryRequest":{"properties":{"subscription":{"anyOf":[{"$ref":"#/components/schemas/WebhookSubscriptionQuery"},{"type":"null"}]},"include_archived":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Include Archived"},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}]}},"type":"object","title":"WebhookSubscriptionQueryRequest"},"WebhookSubscriptionResponse":{"properties":{"count":{"type":"integer","title":"Count","default":0},"subscription":{"anyOf":[{"$ref":"#/components/schemas/WebhookSubscription"},{"type":"null"}]}},"type":"object","title":"WebhookSubscriptionResponse"},"WebhookSubscriptionTestRequest":{"properties":{"subscription_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Subscription Id"},"subscription":{"anyOf":[{"$ref":"#/components/schemas/WebhookSubscriptionEdit"},{"$ref":"#/components/schemas/WebhookSubscriptionCreate"},{"type":"null"}],"title":"Subscription"}},"type":"object","title":"WebhookSubscriptionTestRequest"},"WebhookSubscriptionsResponse":{"properties":{"count":{"type":"integer","title":"Count"},"subscriptions":{"items":{"$ref":"#/components/schemas/WebhookSubscription"},"type":"array","title":"Subscriptions","default":[]}},"type":"object","required":["count"],"title":"WebhookSubscriptionsResponse"},"Windowing":{"properties":{"newest":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Newest"},"oldest":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Oldest"},"next":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Next"},"limit":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit"},"order":{"anyOf":[{"type":"string","enum":["ascending","descending"]},{"type":"null"}],"title":"Order"},"interval":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Interval"},"rate":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Rate"}},"type":"object","title":"Windowing"},"Workflow":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowArtifactFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"Workflow"},"WorkflowArtifactFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false}},"type":"object","title":"WorkflowArtifactFlags"},"WorkflowCatalogFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false},"is_archived":{"type":"boolean","title":"Is Archived","default":false},"is_recommended":{"type":"boolean","title":"Is Recommended","default":false}},"type":"object","title":"WorkflowCatalogFlags"},"WorkflowCatalogHarness":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"key":{"type":"string","title":"Key"},"capabilities":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Capabilities"}},"type":"object","required":["key"],"title":"WorkflowCatalogHarness"},"WorkflowCatalogHarnessResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when a harness record is returned, `0` when not found.","default":0},"harness":{"anyOf":[{"$ref":"#/components/schemas/WorkflowCatalogHarness"},{"type":"null"}],"description":"A harness record referenced by a template's harness field via `x-ag-harness-ref`."}},"type":"object","title":"WorkflowCatalogHarnessResponse"},"WorkflowCatalogHarnessesResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of harness records available.","default":0},"harnesses":{"items":{"$ref":"#/components/schemas/WorkflowCatalogHarness"},"type":"array","title":"Harnesses","description":"Harness records shipped with the product (each carries its `capabilities`)."}},"type":"object","title":"WorkflowCatalogHarnessesResponse"},"WorkflowCatalogPreset":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"key":{"type":"string","title":"Key"},"categories":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Categories"},"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowCatalogFlags"},{"type":"null"}]},"data":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevisionData-Output"},{"type":"null"}]}},"type":"object","required":["key"],"title":"WorkflowCatalogPreset"},"WorkflowCatalogPresetResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when the preset is returned, `0` when not found.","default":0},"preset":{"anyOf":[{"$ref":"#/components/schemas/WorkflowCatalogPreset"},{"type":"null"}],"description":"Named parameter set defined against a template."}},"type":"object","title":"WorkflowCatalogPresetResponse"},"WorkflowCatalogPresetsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of presets returned.","default":0},"presets":{"items":{"$ref":"#/components/schemas/WorkflowCatalogPreset"},"type":"array","title":"Presets","description":"Named parameter sets defined against a template."}},"type":"object","title":"WorkflowCatalogPresetsResponse"},"WorkflowCatalogTemplate":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"key":{"type":"string","title":"Key"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"categories":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Categories"},"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowCatalogFlags"},{"type":"null"}]},"data":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevisionData-Output"},{"type":"null"}]}},"type":"object","required":["key"],"title":"WorkflowCatalogTemplate"},"WorkflowCatalogTemplateResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when the template is returned, `0` when not found.","default":0},"template":{"anyOf":[{"$ref":"#/components/schemas/WorkflowCatalogTemplate"},{"type":"null"}],"description":"Workflow blueprint (key, name, description, flags, default data)."}},"type":"object","title":"WorkflowCatalogTemplateResponse"},"WorkflowCatalogTemplatesResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of templates returned.","default":0},"templates":{"items":{"$ref":"#/components/schemas/WorkflowCatalogTemplate"},"type":"array","title":"Templates","description":"Workflow blueprints shipped with the product."}},"type":"object","title":"WorkflowCatalogTemplatesResponse"},"WorkflowCatalogType":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"key":{"type":"string","title":"Key"},"json_schema":{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object","title":"Json Schema"}},"type":"object","required":["key","json_schema"],"title":"WorkflowCatalogType"},"WorkflowCatalogTypeResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when a type definition is returned, `0` when not found.","default":0},"type":{"anyOf":[{"$ref":"#/components/schemas/WorkflowCatalogType"},{"type":"null"}],"description":"JSON Schema fragment referenced by workflow input/output schemas via `x-ag-type-ref`."}},"type":"object","title":"WorkflowCatalogTypeResponse"},"WorkflowCatalogTypesResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of type definitions available.","default":0},"types":{"items":{"$ref":"#/components/schemas/WorkflowCatalogType"},"type":"array","title":"Types","description":"Shared JSON Schema fragments shipped with the product."}},"type":"object","title":"WorkflowCatalogTypesResponse"},"WorkflowCreate":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"WorkflowCreate"},"WorkflowCreateRequest":{"properties":{"workflow":{"$ref":"#/components/schemas/WorkflowCreate","description":"Workflow artifact to create. Must include a project-unique `slug`; `name`, `description`, `flags`, `tags`, and `meta` are optional."}},"type":"object","required":["workflow"],"title":"WorkflowCreateRequest"},"WorkflowEdit":{"properties":{"folder_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Folder Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"WorkflowEdit"},"WorkflowEditRequest":{"properties":{"workflow":{"$ref":"#/components/schemas/WorkflowEdit","description":"Workflow fields to update. `id` is required and must match the path parameter; only supplied fields are modified."}},"type":"object","required":["workflow"],"title":"WorkflowEditRequest"},"WorkflowFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false},"is_managed":{"type":"boolean","title":"Is Managed","default":false},"is_custom":{"type":"boolean","title":"Is Custom","default":false},"is_llm":{"type":"boolean","title":"Is Llm","default":false},"is_hook":{"type":"boolean","title":"Is Hook","default":false},"is_code":{"type":"boolean","title":"Is Code","default":false},"is_match":{"type":"boolean","title":"Is Match","default":false},"is_feedback":{"type":"boolean","title":"Is Feedback","default":false},"is_agent":{"type":"boolean","title":"Is Agent","default":false},"is_skill":{"type":"boolean","title":"Is Skill","default":false},"is_chat":{"type":"boolean","title":"Is Chat","default":false},"has_url":{"type":"boolean","title":"Has Url","default":false},"has_script":{"type":"boolean","title":"Has Script","default":false},"has_handler":{"type":"boolean","title":"Has Handler","default":false},"is_static":{"type":"boolean","title":"Is Static","default":false}},"type":"object","title":"WorkflowFlags","description":"Legacy full workflow flag set."},"WorkflowRequestData":{"properties":{"revision":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Revision"},"parameters":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Parameters"},"testcase":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Testcase"},"inputs":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Inputs"},"trace":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Trace"},"outputs":{"anyOf":[{},{"type":"null"}],"title":"Outputs"}},"type":"object","title":"WorkflowRequestData"},"WorkflowResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when a workflow is returned, `0` when none matched.","default":0},"workflow":{"anyOf":[{"$ref":"#/components/schemas/Workflow"},{"type":"null"}],"description":"The workflow artifact."}},"type":"object","title":"WorkflowResponse"},"WorkflowRevision-Input":{"properties":{"workflow_variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Variant Slug"},"variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Variant Slug"},"workflow_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Slug"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"workflow_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"author":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Author"},"date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Date"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevisionFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevisionData-Input"},{"type":"null"}]}},"type":"object","title":"WorkflowRevision"},"WorkflowRevision-Output":{"properties":{"workflow_variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Variant Slug"},"variant_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Variant Slug"},"workflow_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Slug"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"workflow_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"author":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Author"},"date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Date"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevisionFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"},"data":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevisionData-Output"},{"type":"null"}]}},"type":"object","title":"WorkflowRevision"},"WorkflowRevisionCommit":{"properties":{"workflow_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"data":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevisionData-Input"},{"type":"null"}]},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"},"delta":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevisionDelta"},{"type":"null"}]},"base_revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Base Revision Id","description":"The revision this change was built on. Omit it to keep today's last-write-wins behavior. Send it on a legacy delta and the commit is refused with `409` when the variant's head has moved since: sending the field is how a caller asks for that check. An ordered delta requires it."}},"type":"object","title":"WorkflowRevisionCommit"},"WorkflowRevisionCommitRequest":{"properties":{"workflow_revision":{"$ref":"#/components/schemas/WorkflowRevisionCommit","description":"Revision to append to a variant's history. Requires `workflow_variant_id` and optional `message`; `data` carries the new configuration."}},"type":"object","required":["workflow_revision"],"title":"WorkflowRevisionCommitRequest"},"WorkflowRevisionCreate":{"properties":{"workflow_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"WorkflowRevisionCreate"},"WorkflowRevisionCreateRequest":{"properties":{"workflow_revision":{"$ref":"#/components/schemas/WorkflowRevisionCreate","description":"Revision to create on an existing variant. The revision is immutable once persisted; to change the payload, commit a new revision."}},"type":"object","required":["workflow_revision"],"title":"WorkflowRevisionCreateRequest"},"WorkflowRevisionData-Input":{"properties":{"uri":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Uri"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"headers":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/Reference"}]},"type":"object"},{"type":"null"}],"title":"Headers"},"runtime":{"anyOf":[{"type":"string","enum":["python","typescript","javascript"]},{"type":"null"}],"title":"Runtime"},"script":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Script"},"schemas":{"anyOf":[{"$ref":"#/components/schemas/JsonSchemas-Input"},{"type":"null"}]},"parameters":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Parameters"}},"additionalProperties":false,"type":"object","title":"WorkflowRevisionData"},"WorkflowRevisionData-Output":{"properties":{"uri":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Uri"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"headers":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"$ref":"#/components/schemas/Reference"}]},"type":"object"},{"type":"null"}],"title":"Headers"},"runtime":{"anyOf":[{"type":"string","enum":["python","typescript","javascript"]},{"type":"null"}],"title":"Runtime"},"script":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Script"},"schemas":{"anyOf":[{"$ref":"#/components/schemas/JsonSchemas-Output"},{"type":"null"}]},"parameters":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Parameters"}},"additionalProperties":false,"type":"object","title":"WorkflowRevisionData"},"WorkflowRevisionDelta":{"properties":{"set":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Set"},"remove":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Remove"},"operations":{"anyOf":[{"items":{"$ref":"#/components/schemas/WorkflowRevisionOperation"},"type":"array"},{"type":"null"}],"title":"Operations"}},"type":"object","title":"WorkflowRevisionDelta","description":"Delta operations on a workflow revision's data tree.\n\nTwo forms, never mixed (contract 3):\n\n- **legacy** — ``set``: a partial data tree deep-merged onto the base revision's data\n (nested dicts merge; scalars and lists replace); ``remove``: dotted key paths to\n delete (e.g. ``parameters.agent.tools``).\n- **ordered** — ``operations``: the seven verbs, applied in array order, all or\n nothing.\n\nThe engine enforces the exclusivity and every operation rule; this model only carries\nthe shapes.\n\nUnknown keys beside ``set``/``remove``/``operations`` are refused on the ORDERED arm\nonly, so a caller cannot believe it sent an operation modifier the server never saw.\nA pure-legacy envelope keeps its shipped tolerance: the server has always ignored\nstray keys there, and playbooks in the field send them. Neither rule reaches the tree\ninside ``set``, which stays free-form."},"WorkflowRevisionDeployRequest":{"properties":{"workflow_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Workflow artifact to deploy. One of the workflow refs is required."},"workflow_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Workflow variant to deploy. Resolves to the latest revision of this variant."},"workflow_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Specific workflow revision to deploy."},"environment_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Target environment artifact. One of the environment refs is required."},"environment_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Target environment variant."},"environment_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Target environment revision."},"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key","description":"Reference key to set on the environment revision. Defaults to `.revision` when omitted."},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message","description":"Commit message recorded on the resulting environment revision."}},"type":"object","title":"WorkflowRevisionDeployRequest"},"WorkflowRevisionEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"WorkflowRevisionEdit"},"WorkflowRevisionEditRequest":{"properties":{"workflow_revision":{"$ref":"#/components/schemas/WorkflowRevisionEdit","description":"Revision fields to update (lifecycle metadata only). Data and configuration are immutable — commit a new revision to change them."}},"type":"object","required":["workflow_revision"],"title":"WorkflowRevisionEditRequest"},"WorkflowRevisionFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false},"is_managed":{"type":"boolean","title":"Is Managed","default":false},"is_custom":{"type":"boolean","title":"Is Custom","default":false},"is_llm":{"type":"boolean","title":"Is Llm","default":false},"is_hook":{"type":"boolean","title":"Is Hook","default":false},"is_code":{"type":"boolean","title":"Is Code","default":false},"is_match":{"type":"boolean","title":"Is Match","default":false},"is_feedback":{"type":"boolean","title":"Is Feedback","default":false},"is_agent":{"type":"boolean","title":"Is Agent","default":false},"is_skill":{"type":"boolean","title":"Is Skill","default":false},"is_chat":{"type":"boolean","title":"Is Chat","default":false},"has_url":{"type":"boolean","title":"Has Url","default":false},"has_script":{"type":"boolean","title":"Has Script","default":false},"has_handler":{"type":"boolean","title":"Has Handler","default":false},"is_static":{"type":"boolean","title":"Is Static","default":false}},"type":"object","title":"WorkflowRevisionFlags"},"WorkflowRevisionOperation":{"properties":{"operation":{"type":"string","enum":["set","merge","remove","edit_text","add_item","replace_item","remove_item"],"title":"Operation"},"target":{"items":{},"type":"array","title":"Target"},"value":{"anyOf":[{},{"type":"null"}],"title":"Value"},"edits":{"anyOf":[{"items":{"additionalProperties":{"type":"string"},"type":"object"},"type":"array"},{"type":"null"}],"title":"Edits"},"match_mode":{"anyOf":[{"type":"string","enum":["auto","exact"]},{"type":"null"}],"title":"Match Mode"}},"additionalProperties":false,"type":"object","required":["operation","target"],"title":"WorkflowRevisionOperation","description":"One ordered operation. The new delta arm (agent-config-editing, contract 3.2).\n\n``extra=\"forbid\"`` applies to this NEW model only. The legacy ``set``/``remove`` arm\nstays permissive on purpose: tightening it would reject payloads that shipped\nplaybooks send today and that the server has always ignored."},"WorkflowRevisionResolveRequest":{"properties":{"workflow_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Workflow artifact; resolves against its latest revision."},"workflow_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Workflow variant; resolves against its latest revision."},"workflow_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Specific workflow revision to resolve."},"workflow_revision":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevision-Input"},{"type":"null"}],"description":"Resolve the references embedded in this revision payload directly, without fetching it first."},"max_depth":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Depth","description":"Maximum recursive depth for nested `@ag.references`.","default":10},"max_embeds":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Embeds","description":"Maximum number of embeds to resolve in one call.","default":100},"error_policy":{"anyOf":[{"$ref":"#/components/schemas/ErrorPolicy"},{"type":"null"}],"description":"How to handle unresolved references: `EXCEPTION` or `IGNORE`.","default":"exception"}},"type":"object","title":"WorkflowRevisionResolveRequest"},"WorkflowRevisionResolveResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when a revision is returned, `0` when none matched.","default":0},"workflow_revision":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevision-Output"},{"type":"null"}],"description":"The workflow revision with `@ag.references` replaced by their resolved payloads."},"resolution_info":{"anyOf":[{"$ref":"#/components/schemas/ResolutionInfo"},{"type":"null"}],"description":"Metadata describing which references were resolved, depth reached, and errors."},"retrieval_info":{"anyOf":[{"$ref":"#/components/schemas/RetrievalInfo"},{"type":"null"}],"description":"References (artifact / variant / revision) actually used to retrieve this revision."}},"type":"object","title":"WorkflowRevisionResolveResponse"},"WorkflowRevisionResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when a revision is returned, `0` when none matched.","default":0},"workflow_revision":{"anyOf":[{"$ref":"#/components/schemas/WorkflowRevision-Output"},{"type":"null"}],"description":"The workflow revision."},"status":{"anyOf":[{"type":"string","enum":["committed","no_change"]},{"type":"null"}],"title":"Status","description":"Commit outcome. `no_change` means the change produced the stored configuration, so no revision was created and `workflow_revision` is the current head. Absent on paths that do not run the checked commit; a reader must treat absent as `committed`."},"warnings":{"anyOf":[{"items":{"$ref":"#/components/schemas/CommitWarning"},"type":"array"},{"type":"null"}],"title":"Warnings","description":"Structured advisories about the commit; never an error."},"resolution_info":{"anyOf":[{"$ref":"#/components/schemas/ResolutionInfo"},{"type":"null"}],"description":"Reference-resolution metadata; populated when `resolve=true` on retrieve."},"retrieval_info":{"anyOf":[{"$ref":"#/components/schemas/RetrievalInfo"},{"type":"null"}],"description":"References used to retrieve the top-level revision."}},"type":"object","title":"WorkflowRevisionResponse"},"WorkflowRevisionRetrieveRequest":{"properties":{"workflow_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Workflow artifact to look up. Identifies the artifact by `id` or `slug` (both project-unique). When no variant_ref or revision_ref is provided, returns the latest revision of the workflow's default variant."},"workflow_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Workflow variant to look up. Identifies the variant by `id` or `slug` (both project-unique). When no revision_ref is provided, returns the latest revision of this variant."},"workflow_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Workflow revision to look up. `id` alone identifies a revision (project-unique). `slug` alone identifies a revision (project-unique). `version` alone is a per-variant sequence number and is **not** sufficient on its own; it must be combined with a `workflow_variant_ref`. Sending only `version` without a variant ref returns HTTP 400."},"environment_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Environment artifact backing the deployment to resolve from."},"environment_variant_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Environment variant backing the deployment to resolve from."},"environment_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Specific environment revision to resolve from."},"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key","description":"Key into the environment revision's reference map. Required when retrieving via environment refs."},"resolve":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Resolve","description":"When true, resolve `@ag.references` tokens embedded in the revision configuration before returning it."}},"type":"object","title":"WorkflowRevisionRetrieveRequest","description":"Request body for `POST /workflows/revisions/retrieve`.\n\nResolves to a single revision by one or more reference types. Every\nreference supplied must agree with the resolved revision; contradictions\nreturn HTTP 400. For environment-backed lookup, `key` may be omitted when\n`workflow_ref` is provided, in which case it defaults to\n`.revision`."},"WorkflowRevisionsLog":{"properties":{"workflow_revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Revision Id"},"revision_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Revision Id"},"workflow_variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Variant Id"},"variant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Variant Id"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"depth":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Depth"}},"type":"object","title":"WorkflowRevisionsLog"},"WorkflowRevisionsLogRequest":{"properties":{"workflow_revisions":{"$ref":"#/components/schemas/WorkflowRevisionsLog","description":"Log query. Supply `workflow_id`, `workflow_variant_id`, or `workflow_revision_id` to scope the log, and an optional `depth`."}},"type":"object","required":["workflow_revisions"],"title":"WorkflowRevisionsLogRequest"},"WorkflowRevisionsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of revisions in this page.","default":0},"workflow_revisions":{"items":{"$ref":"#/components/schemas/WorkflowRevision-Output"},"type":"array","title":"Workflow Revisions","description":"Workflow revisions matching the query, ordered by commit time."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Pagination cursor."}},"type":"object","title":"WorkflowRevisionsResponse"},"WorkflowVariant":{"properties":{"workflow_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Slug"},"artifact_slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Artifact Slug"},"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowVariantFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Output"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Output"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Deleted At"},"created_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Created By Id"},"updated_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Updated By Id"},"deleted_by_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Deleted By Id"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"WorkflowVariant"},"WorkflowVariantCreate":{"properties":{"workflow_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Workflow Id"},"artifact_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Artifact Id"},"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"WorkflowVariantCreate"},"WorkflowVariantCreateRequest":{"properties":{"workflow_variant":{"$ref":"#/components/schemas/WorkflowVariantCreate","description":"Variant to create under an existing workflow. Requires `workflow_id` (the artifact) and a project-unique `slug`."}},"type":"object","required":["workflow_variant"],"title":"WorkflowVariantCreateRequest"},"WorkflowVariantEdit":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Id"}},"type":"object","title":"WorkflowVariantEdit"},"WorkflowVariantEditRequest":{"properties":{"workflow_variant":{"$ref":"#/components/schemas/WorkflowVariantEdit","description":"Variant fields to update. `id` is required and must match the path parameter."}},"type":"object","required":["workflow_variant"],"title":"WorkflowVariantEditRequest"},"WorkflowVariantFlags":{"properties":{"is_application":{"type":"boolean","title":"Is Application","default":false},"is_evaluator":{"type":"boolean","title":"Is Evaluator","default":false},"is_snippet":{"type":"boolean","title":"Is Snippet","default":false}},"type":"object","title":"WorkflowVariantFlags"},"WorkflowVariantFork":{"properties":{"flags":{"anyOf":[{"$ref":"#/components/schemas/WorkflowVariantFlags"},{"type":"null"}]},"tags":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/LabelJson-Input"},"type":"object"},{"type":"null"}],"title":"Tags"},"meta":{"anyOf":[{"additionalProperties":{"$ref":"#/components/schemas/FullJson-Input"},"type":"object"},{"type":"null"}],"title":"Meta"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"slug":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Slug"}},"type":"object","title":"WorkflowVariantFork"},"WorkflowVariantForkRequest":{"properties":{"workflow_variant":{"$ref":"#/components/schemas/WorkflowVariantFork","description":"Config for the new variant (slug, name, description, flags)."},"workflow_variant_ref":{"$ref":"#/components/schemas/Reference","description":"Source variant to fork from."},"workflow_revision_ref":{"anyOf":[{"$ref":"#/components/schemas/Reference"},{"type":"null"}],"description":"Pin the fork to this revision; defaults to the source variant's head."}},"type":"object","required":["workflow_variant","workflow_variant_ref"],"title":"WorkflowVariantForkRequest"},"WorkflowVariantResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"`1` when a variant is returned, `0` when none matched.","default":0},"workflow_variant":{"anyOf":[{"$ref":"#/components/schemas/WorkflowVariant"},{"type":"null"}],"description":"The workflow variant."}},"type":"object","title":"WorkflowVariantResponse"},"WorkflowVariantsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of variants in this page.","default":0},"workflow_variants":{"items":{"$ref":"#/components/schemas/WorkflowVariant"},"type":"array","title":"Workflow Variants","description":"Workflow variants matching the query."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Pagination cursor."}},"type":"object","title":"WorkflowVariantsResponse"},"WorkflowsResponse":{"properties":{"count":{"type":"integer","title":"Count","description":"Number of workflows in this page.","default":0},"workflows":{"items":{"$ref":"#/components/schemas/Workflow"},"type":"array","title":"Workflows","description":"Workflow artifacts matching the query."},"windowing":{"anyOf":[{"$ref":"#/components/schemas/Windowing"},{"type":"null"}],"description":"Pagination cursor; pass `windowing.next` back to fetch the following page."}},"type":"object","title":"WorkflowsResponse"},"Workspace":{"properties":{"id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Id"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Type"}},"type":"object","required":["name","type"],"title":"Workspace"},"WorkspaceMemberResponse":{"properties":{"user":{"additionalProperties":true,"type":"object","title":"User"},"roles":{"items":{"$ref":"#/components/schemas/WorkspacePermission"},"type":"array","title":"Roles"}},"type":"object","required":["user","roles"],"title":"WorkspaceMemberResponse"},"WorkspacePermission":{"properties":{"role_name":{"type":"string","title":"Role Name"},"role_description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Role Description"},"permissions":{"anyOf":[{"items":{"$ref":"#/components/schemas/Permission"},"type":"array"},{"type":"null"}],"title":"Permissions"}},"type":"object","required":["role_name"],"title":"WorkspacePermission"},"WorkspaceResponse":{"properties":{"created_at":{"type":"string","title":"Created At"},"updated_at":{"type":"string","title":"Updated At"},"id":{"type":"string","title":"Id"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Type"},"organization":{"type":"string","title":"Organization"},"members":{"anyOf":[{"items":{"$ref":"#/components/schemas/WorkspaceMemberResponse"},"type":"array"},{"type":"null"}],"title":"Members"}},"type":"object","required":["id","name","type","organization"],"title":"WorkspaceResponse"}},"securitySchemes":{"APIKeyHeader":{"type":"apiKey","name":"Authorization","in":"header"}}},"tags":[{"name":"Status","description":"API server liveness and readiness status."},{"name":"Organizations","description":"Manage organizations, workspaces, SSO domains, and identity providers."},{"name":"Workspaces","description":"Manage workspaces within an organization and their members."},{"name":"Projects","description":"Manage projects within a workspace."},{"name":"Users","description":"User profile and account management — view profile, update username, reset password."},{"name":"Keys","description":"Create and revoke API keys used to authenticate programmatic requests."},{"name":"Workflows","description":"Workflow definitions — the runnable pipelines that back an application."},{"name":"Applications","description":"LLM applications — create, update, list, and delete apps."},{"name":"Evaluators","description":"Evaluator definitions — the metrics and judges used in evaluation runs."},{"name":"Testsets","description":"Test datasets — collections of input/output pairs used in evaluations."},{"name":"Testcases","description":"Individual test cases within a testset."},{"name":"Queries","description":"Saved query definitions used to filter and retrieve trace data."},{"name":"Traces","description":"Ingest and query traces, spans, and metrics from running applications."},{"name":"Invocations","description":"Run an application against a payload and capture the resulting trace."},{"name":"Annotations","description":"Attach evaluator-style feedback to existing traces and spans."},{"name":"Evaluations","description":"Evaluation runs — execute evaluators against variants and testsets."},{"name":"Environments","description":"Deployment environments (e.g. production, staging) and their active variants."},{"name":"Secrets","description":"Manage provider credentials and secret values stored in the vault."},{"name":"Tools","description":"External tool connections and OAuth integrations available to applications."},{"name":"Triggers","description":"Inbound provider event triggers and their watchable event catalog."},{"name":"Sessions","description":"Agent sessions — runner coordination (invoke/cancel/steer/attach/detach/heartbeat/liveness), state persistence (durable SDK state and sandbox resume pointer), records, and streams."},{"name":"Interactions","description":"Human-in-the-loop interaction requests raised by running agents — approvals, inputs, and tool confirmations."},{"name":"Folders","description":"Organize applications and other resources into folder hierarchies."},{"name":"Mounts","description":"Durable object-store mounts for agent working directories."},{"name":"Webhooks","description":"Register and manage webhooks that fire on platform events."},{"name":"OpenTelemetry","description":"OTLP-compatible endpoints for ingesting traces directly from OpenTelemetry-instrumented services."},{"name":"Access","description":"Authentication discovery, organization access checks, and SSO callback endpoints."},{"name":"Billing","description":"Subscription, plan, and usage endpoints for workspace billing."},{"name":"Admin","description":"Internal administration endpoints — restricted to platform operators."},{"name":"Legacy","description":"Stable legacy endpoints retained for existing integrations — not deprecated, but new integrations should prefer the canonical surface."},{"name":"Deprecated","description":"Deprecated endpoints kept for backwards compatibility — avoid in new integrations."}],"security":[{"APIKeyHeader":[]}],"servers":[{"url":"/api"},{"url":"https://eu.cloud.agenta.ai/api"}]} \ No newline at end of file diff --git a/docs/scripts/monitor-production.mjs b/docs/scripts/monitor-production.mjs new file mode 100644 index 00000000000..51037e1ff68 --- /dev/null +++ b/docs/scripts/monitor-production.mjs @@ -0,0 +1,52 @@ +import process from "node:process"; + +const origin = (process.env.DOCS_ORIGIN || "https://agenta.ai").replace(/\/$/, ""); +const timeoutMs = 30_000; +const checks = [ + { path: "/docs/sitemap.xml", type: "application/xml" }, + { path: "/docs/", type: "text/html" }, + { path: "/docs/1.0/", type: "text/html" }, + { path: "/docs/administration/security/overview", type: "text/html" }, + { path: "/docs/reference/api/accept-invitation", type: "text/html" }, +]; +const agents = [ + ["browser", "agenta-docs-monitor/1.0"], + ["googlebot", "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"], + ["bingbot", "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)"], +]; + +let failures = 0; +for (const { path, type } of checks) { + for (const [agent, userAgent] of agents) { + const response = await fetch(`${origin}${path}`, { + headers: { "user-agent": userAgent }, + redirect: "follow", + signal: AbortSignal.timeout(timeoutMs), + }); + const cacheControl = response.headers.get("cache-control") || ""; + const challenge = response.headers.get("x-vercel-mitigated"); + const contentType = response.headers.get("content-type") || ""; + const errors = []; + + if (response.status !== 200) errors.push(`HTTP ${response.status}`); + if (challenge) errors.push(`x-vercel-mitigated: ${challenge}`); + if (response.status !== 200 && /(?:s-maxage|max-age)\s*=\s*[1-9]/i.test(cacheControl)) { + errors.push(`cacheable error (${cacheControl})`); + } + if (!contentType.toLowerCase().includes(type)) { + errors.push(`expected ${type}, got ${contentType || "no content-type"}`); + } + + if (errors.length) { + failures += 1; + console.error(`FAIL ${agent} ${path}: ${errors.join(", ")}`); + } else { + console.log(`PASS ${agent} ${path}`); + } + } +} + +if (failures) { + console.error(`${failures} docs health check(s) failed`); + process.exit(1); +} diff --git a/docs/wrangler.production.jsonc b/docs/wrangler.production.jsonc index 14981dd4fd0..a73da5ba355 100644 --- a/docs/wrangler.production.jsonc +++ b/docs/wrangler.production.jsonc @@ -6,23 +6,15 @@ // Deployed by .github/workflows/19-docs-production.yml on every merge to main // that touches docs/**. // - // No `routes` block yet. The docs are still served through the older - // `new-docs-router` worker, which proxies agenta.ai/docs/* to Vercel. Cutover - // is a deliberate one-time swap: remove the agenta.ai/docs* route from - // new-docs-router and add these two routes here. - // - // "routes": [ - // { "pattern": "agenta.ai/docs", "zone_name": "agenta.ai" }, - // { "pattern": "agenta.ai/docs/*", "zone_name": "agenta.ai" } - // ] - // - // new-docs-router keeps the docs.agenta.ai -> agenta.ai/docs redirect and the - // combined /sitemap_index.xml; only its Vercel proxy rule goes away. "name": "agenta-docs", "compatibility_date": "2026-08-01", // Production takes no per-version preview aliases; those live on the preview // worker. "preview_urls": false, + "routes": [ + { "pattern": "agenta.ai/docs", "zone_name": "agenta.ai" }, + { "pattern": "agenta.ai/docs/*", "zone_name": "agenta.ai" } + ], "assets": { "directory": "./dist", "html_handling": "drop-trailing-slash", diff --git a/hosting/docker-compose/ee/docker-compose.dev.yml b/hosting/docker-compose/ee/docker-compose.dev.yml index b8be5619aa1..e90fdd70593 100644 --- a/hosting/docker-compose/ee/docker-compose.dev.yml +++ b/hosting/docker-compose/ee/docker-compose.dev.yml @@ -498,6 +498,9 @@ services: # Worst-case turn duration. Keep AGENTA_MOUNTS_CREDENTIALS_TTL_SECONDS (API) above it # plus 60s of skew, or every dispatch rebuilds the environment cold. AGENTA_RUNNER_RUN_TOTAL_TIMEOUT_MS: ${AGENTA_RUNNER_RUN_TOTAL_TIMEOUT_MS:-} + # Defaults: 30 min without progress; 30 min for one tool call. + AGENTA_RUNNER_RUN_IDLE_TIMEOUT_MS: ${AGENTA_RUNNER_RUN_IDLE_TIMEOUT_MS:-} + AGENTA_RUNNER_TOOL_CALL_TIMEOUT_MS: ${AGENTA_RUNNER_TOOL_CALL_TIMEOUT_MS:-} AGENTA_RUNNER_DAYTONA_API_KEY: ${AGENTA_RUNNER_DAYTONA_API_KEY:-} AGENTA_RUNNER_DAYTONA_API_URL: ${AGENTA_RUNNER_DAYTONA_API_URL:-} AGENTA_RUNNER_DAYTONA_TARGET: ${AGENTA_RUNNER_DAYTONA_TARGET:-} @@ -515,6 +518,7 @@ services: # only its last message loses the conversation. AGENTA_SESSIONS_RECONSTRUCT: ${AGENTA_SESSIONS_RECONSTRUCT:-} AGENTA_RECORDS_DURABLE: ${AGENTA_RECORDS_DURABLE:-} + AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-false} # === STORAGE ============================================== # volumes: - ../../../services/runner/src:/app/src diff --git a/hosting/docker-compose/ee/docker-compose.gh.local.yml b/hosting/docker-compose/ee/docker-compose.gh.local.yml index f1d9d123be1..b1092ce9ea1 100644 --- a/hosting/docker-compose/ee/docker-compose.gh.local.yml +++ b/hosting/docker-compose/ee/docker-compose.gh.local.yml @@ -332,6 +332,9 @@ services: # Worst-case turn duration. Keep AGENTA_MOUNTS_CREDENTIALS_TTL_SECONDS (API) above it # plus 60s of skew, or every dispatch rebuilds the environment cold. AGENTA_RUNNER_RUN_TOTAL_TIMEOUT_MS: ${AGENTA_RUNNER_RUN_TOTAL_TIMEOUT_MS:-} + # Defaults: 30 min without progress; 30 min for one tool call. + AGENTA_RUNNER_RUN_IDLE_TIMEOUT_MS: ${AGENTA_RUNNER_RUN_IDLE_TIMEOUT_MS:-} + AGENTA_RUNNER_TOOL_CALL_TIMEOUT_MS: ${AGENTA_RUNNER_TOOL_CALL_TIMEOUT_MS:-} PI_CODING_AGENT_DIR: ${PI_CODING_AGENT_DIR:-/pi-agent} AGENTA_RUNNER_DAYTONA_API_KEY: ${AGENTA_RUNNER_DAYTONA_API_KEY:-} AGENTA_RUNNER_DAYTONA_API_URL: ${AGENTA_RUNNER_DAYTONA_API_URL:-} @@ -343,6 +346,7 @@ services: AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM: ${AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM:-} AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS: ${AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS:-} + AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-false} # === NETWORK ============================================== # networks: - agenta-ee-gh-network diff --git a/hosting/docker-compose/ee/docker-compose.gh.yml b/hosting/docker-compose/ee/docker-compose.gh.yml index 965261db52e..7c3bc8bdcc8 100644 --- a/hosting/docker-compose/ee/docker-compose.gh.yml +++ b/hosting/docker-compose/ee/docker-compose.gh.yml @@ -334,6 +334,9 @@ services: # Worst-case turn duration. Keep AGENTA_MOUNTS_CREDENTIALS_TTL_SECONDS (API) above it # plus 60s of skew, or every dispatch rebuilds the environment cold. AGENTA_RUNNER_RUN_TOTAL_TIMEOUT_MS: ${AGENTA_RUNNER_RUN_TOTAL_TIMEOUT_MS:-} + # Defaults: 30 min without progress; 30 min for one tool call. + AGENTA_RUNNER_RUN_IDLE_TIMEOUT_MS: ${AGENTA_RUNNER_RUN_IDLE_TIMEOUT_MS:-} + AGENTA_RUNNER_TOOL_CALL_TIMEOUT_MS: ${AGENTA_RUNNER_TOOL_CALL_TIMEOUT_MS:-} PI_CODING_AGENT_DIR: ${PI_CODING_AGENT_DIR:-/pi-agent} AGENTA_RUNNER_DAYTONA_API_KEY: ${AGENTA_RUNNER_DAYTONA_API_KEY:-} AGENTA_RUNNER_DAYTONA_API_URL: ${AGENTA_RUNNER_DAYTONA_API_URL:-} @@ -352,6 +355,7 @@ services: # only its last message loses the conversation. AGENTA_SESSIONS_RECONSTRUCT: ${AGENTA_SESSIONS_RECONSTRUCT:-} AGENTA_RECORDS_DURABLE: ${AGENTA_RECORDS_DURABLE:-} + AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-false} # === SUBSCRIPTION MOUNTS (opt-in) ========================= # # Use your own harness subscription for LOCAL runs instead of a managed API # key. Mount the login READ-WRITE: the harness runs directly out of the mount and diff --git a/hosting/docker-compose/ee/env.ee.dev.example b/hosting/docker-compose/ee/env.ee.dev.example index 411db1efddf..6cb33aa2012 100644 --- a/hosting/docker-compose/ee/env.ee.dev.example +++ b/hosting/docker-compose/ee/env.ee.dev.example @@ -136,6 +136,19 @@ AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER=local # Smart truncation preserves the structure of a record whose body exceeds the API size # cap (higher-fidelity reconstruction). Still opt-in, default off. # AGENTA_RECORDS_SMART_TRUNCATION=true +# Durable Stop is enabled by default; set false to use legacy cancellation. +AGENTA_SESSIONS_DURABLE_STOP=true +# AGENTA_SESSIONS_LATE_OUTPUT=quarantine + +# --- Live session relay (opt-in) --- +# The disposable live-frame stream keeps at most this many frames per deployment. +# AGENTA_SESSIONS_LIVE_STREAM_MAXLEN=100000 +# AGENTA_SESSIONS_LIVE_FRAME_MAX_AGE_SECONDS=900 +# Enable both switches to publish runner frames and advertise the shared reader. +# AGENTA_RUNNER_LIVE_FRAMES=false +# AGENTA_SESSIONS_SHARED_READER=false +# AGENTA_SESSIONS_LIVE_AUTH_RECHECK_SECONDS=60 +# AGENTA_SESSIONS_LIVE_READER_BUFFER_LIMIT=256 # --- Attachment limits (files attached to an agent chat turn) --- # Per-file caps in bytes, by kind: 10 MB, except audio at 15 MB. Read by the api. diff --git a/hosting/docker-compose/ee/env.ee.gh.example b/hosting/docker-compose/ee/env.ee.gh.example index 7fd4ed414bd..94398f700c1 100644 --- a/hosting/docker-compose/ee/env.ee.gh.example +++ b/hosting/docker-compose/ee/env.ee.gh.example @@ -141,6 +141,16 @@ AGENTA_RUNNER_TOKEN=replace-me # cap (higher-fidelity reconstruction). Still opt-in, default off. # AGENTA_RECORDS_SMART_TRUNCATION=true +# --- Live session relay (opt-in) --- +# The disposable live-frame stream keeps at most this many frames per deployment. +# AGENTA_SESSIONS_LIVE_STREAM_MAXLEN=100000 +# AGENTA_SESSIONS_LIVE_FRAME_MAX_AGE_SECONDS=900 +# Enable both switches to publish runner frames and advertise the shared reader. +# AGENTA_RUNNER_LIVE_FRAMES=false +# AGENTA_SESSIONS_SHARED_READER=false +# AGENTA_SESSIONS_LIVE_AUTH_RECHECK_SECONDS=60 +# AGENTA_SESSIONS_LIVE_READER_BUFFER_LIMIT=256 + # --- Attachment limits (files attached to an agent chat turn) --- # Per-file caps in bytes, by kind: 10 MB, except audio at 15 MB. Read by the api. # AGENTA_ATTACHMENTS_MAX_IMAGE_BYTES=10485760 diff --git a/hosting/docker-compose/oss/docker-compose.dev.yml b/hosting/docker-compose/oss/docker-compose.dev.yml index 26f04cd1b43..be93fd29a8c 100644 --- a/hosting/docker-compose/oss/docker-compose.dev.yml +++ b/hosting/docker-compose/oss/docker-compose.dev.yml @@ -463,6 +463,9 @@ services: # Worst-case turn duration. Keep AGENTA_MOUNTS_CREDENTIALS_TTL_SECONDS (API) above it # plus 60s of skew, or every dispatch rebuilds the environment cold. AGENTA_RUNNER_RUN_TOTAL_TIMEOUT_MS: ${AGENTA_RUNNER_RUN_TOTAL_TIMEOUT_MS:-} + # Defaults: 30 min without progress; 30 min for one tool call. + AGENTA_RUNNER_RUN_IDLE_TIMEOUT_MS: ${AGENTA_RUNNER_RUN_IDLE_TIMEOUT_MS:-} + AGENTA_RUNNER_TOOL_CALL_TIMEOUT_MS: ${AGENTA_RUNNER_TOOL_CALL_TIMEOUT_MS:-} AGENTA_RUNNER_DAYTONA_API_KEY: ${AGENTA_RUNNER_DAYTONA_API_KEY:-} AGENTA_RUNNER_DAYTONA_API_URL: ${AGENTA_RUNNER_DAYTONA_API_URL:-} AGENTA_RUNNER_DAYTONA_TARGET: ${AGENTA_RUNNER_DAYTONA_TARGET:-} @@ -480,6 +483,7 @@ services: # only its last message loses the conversation. AGENTA_SESSIONS_RECONSTRUCT: ${AGENTA_SESSIONS_RECONSTRUCT:-} AGENTA_RECORDS_DURABLE: ${AGENTA_RECORDS_DURABLE:-} + AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-false} # === STORAGE ============================================== # volumes: - ../../../services/runner/src:/app/src diff --git a/hosting/docker-compose/oss/docker-compose.gh.local.yml b/hosting/docker-compose/oss/docker-compose.gh.local.yml index 7ea9ce66690..f6ab7134c8f 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.local.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.local.yml @@ -328,6 +328,9 @@ services: # Worst-case turn duration. Keep AGENTA_MOUNTS_CREDENTIALS_TTL_SECONDS (API) above it # plus 60s of skew, or every dispatch rebuilds the environment cold. AGENTA_RUNNER_RUN_TOTAL_TIMEOUT_MS: ${AGENTA_RUNNER_RUN_TOTAL_TIMEOUT_MS:-} + # Defaults: 30 min without progress; 30 min for one tool call. + AGENTA_RUNNER_RUN_IDLE_TIMEOUT_MS: ${AGENTA_RUNNER_RUN_IDLE_TIMEOUT_MS:-} + AGENTA_RUNNER_TOOL_CALL_TIMEOUT_MS: ${AGENTA_RUNNER_TOOL_CALL_TIMEOUT_MS:-} PI_CODING_AGENT_DIR: ${PI_CODING_AGENT_DIR:-/pi-agent} AGENTA_RUNNER_DAYTONA_API_KEY: ${AGENTA_RUNNER_DAYTONA_API_KEY:-} AGENTA_RUNNER_DAYTONA_API_URL: ${AGENTA_RUNNER_DAYTONA_API_URL:-} @@ -339,6 +342,7 @@ services: AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM: ${AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM:-} AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS: ${AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS:-} + AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-false} # === NETWORK ============================================== # networks: - agenta-oss-gh-network diff --git a/hosting/docker-compose/oss/docker-compose.gh.ssl.yml b/hosting/docker-compose/oss/docker-compose.gh.ssl.yml index 756d780c0f7..ed34c52f944 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.ssl.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.ssl.yml @@ -355,6 +355,9 @@ services: # Worst-case turn duration. Keep AGENTA_MOUNTS_CREDENTIALS_TTL_SECONDS (API) above it # plus 60s of skew, or every dispatch rebuilds the environment cold. AGENTA_RUNNER_RUN_TOTAL_TIMEOUT_MS: ${AGENTA_RUNNER_RUN_TOTAL_TIMEOUT_MS:-} + # Defaults: 30 min without progress; 30 min for one tool call. + AGENTA_RUNNER_RUN_IDLE_TIMEOUT_MS: ${AGENTA_RUNNER_RUN_IDLE_TIMEOUT_MS:-} + AGENTA_RUNNER_TOOL_CALL_TIMEOUT_MS: ${AGENTA_RUNNER_TOOL_CALL_TIMEOUT_MS:-} AGENTA_RUNNER_DAYTONA_API_KEY: ${AGENTA_RUNNER_DAYTONA_API_KEY:-} AGENTA_RUNNER_DAYTONA_API_URL: ${AGENTA_RUNNER_DAYTONA_API_URL:-} AGENTA_RUNNER_DAYTONA_TARGET: ${AGENTA_RUNNER_DAYTONA_TARGET:-} @@ -365,6 +368,7 @@ services: AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM: ${AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM:-} AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS: ${AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS:-} + AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-false} # === NETWORK ============================================== # networks: - agenta-gh-ssl-network diff --git a/hosting/docker-compose/oss/docker-compose.gh.yml b/hosting/docker-compose/oss/docker-compose.gh.yml index e95002913ed..952d971bf1d 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.yml @@ -352,6 +352,9 @@ services: # Worst-case turn duration. Keep AGENTA_MOUNTS_CREDENTIALS_TTL_SECONDS (API) above it # plus 60s of skew, or every dispatch rebuilds the environment cold. AGENTA_RUNNER_RUN_TOTAL_TIMEOUT_MS: ${AGENTA_RUNNER_RUN_TOTAL_TIMEOUT_MS:-} + # Defaults: 30 min without progress; 30 min for one tool call. + AGENTA_RUNNER_RUN_IDLE_TIMEOUT_MS: ${AGENTA_RUNNER_RUN_IDLE_TIMEOUT_MS:-} + AGENTA_RUNNER_TOOL_CALL_TIMEOUT_MS: ${AGENTA_RUNNER_TOOL_CALL_TIMEOUT_MS:-} PI_CODING_AGENT_DIR: ${PI_CODING_AGENT_DIR:-/pi-agent} AGENTA_RUNNER_DAYTONA_API_KEY: ${AGENTA_RUNNER_DAYTONA_API_KEY:-} AGENTA_RUNNER_DAYTONA_API_URL: ${AGENTA_RUNNER_DAYTONA_API_URL:-} @@ -370,6 +373,7 @@ services: # only its last message loses the conversation. AGENTA_SESSIONS_RECONSTRUCT: ${AGENTA_SESSIONS_RECONSTRUCT:-} AGENTA_RECORDS_DURABLE: ${AGENTA_RECORDS_DURABLE:-} + AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-false} # === SUBSCRIPTION MOUNTS (opt-in) ========================= # # Use your own harness subscription for LOCAL runs instead of a managed API # key. Mount the login READ-WRITE: the harness runs directly out of the mount and diff --git a/hosting/docker-compose/oss/env.oss.dev.example b/hosting/docker-compose/oss/env.oss.dev.example index 6f65ca6c913..7a84c167870 100644 --- a/hosting/docker-compose/oss/env.oss.dev.example +++ b/hosting/docker-compose/oss/env.oss.dev.example @@ -142,6 +142,19 @@ NEXT_PUBLIC_AGENT_FILE_UPLOADS=true # Smart truncation preserves the structure of a record whose body exceeds the API size # cap (higher-fidelity reconstruction). Still opt-in, default off. # AGENTA_RECORDS_SMART_TRUNCATION=true +# Durable Stop is enabled by default; set false to use legacy cancellation. +AGENTA_SESSIONS_DURABLE_STOP=true +# AGENTA_SESSIONS_LATE_OUTPUT=quarantine + +# --- Live session relay (opt-in) --- +# The disposable live-frame stream keeps at most this many frames per deployment. +# AGENTA_SESSIONS_LIVE_STREAM_MAXLEN=100000 +# AGENTA_SESSIONS_LIVE_FRAME_MAX_AGE_SECONDS=900 +# Enable both switches to publish runner frames and advertise the shared reader. +# AGENTA_RUNNER_LIVE_FRAMES=false +# AGENTA_SESSIONS_SHARED_READER=false +# AGENTA_SESSIONS_LIVE_AUTH_RECHECK_SECONDS=60 +# AGENTA_SESSIONS_LIVE_READER_BUFFER_LIMIT=256 # --- Attachment limits (files attached to an agent chat turn) --- # Per-file caps in bytes, by kind: 10 MB, except audio at 15 MB. Read by the api. diff --git a/hosting/docker-compose/oss/env.oss.gh.example b/hosting/docker-compose/oss/env.oss.gh.example index e1ab99123e0..dacff6d5b24 100644 --- a/hosting/docker-compose/oss/env.oss.gh.example +++ b/hosting/docker-compose/oss/env.oss.gh.example @@ -146,6 +146,16 @@ AGENTA_RUNNER_TOKEN=replace-me # cap (higher-fidelity reconstruction). Still opt-in, default off. # AGENTA_RECORDS_SMART_TRUNCATION=true +# --- Live session relay (opt-in) --- +# The disposable live-frame stream keeps at most this many frames per deployment. +# AGENTA_SESSIONS_LIVE_STREAM_MAXLEN=100000 +# AGENTA_SESSIONS_LIVE_FRAME_MAX_AGE_SECONDS=900 +# Enable both switches to publish runner frames and advertise the shared reader. +# AGENTA_RUNNER_LIVE_FRAMES=false +# AGENTA_SESSIONS_SHARED_READER=false +# AGENTA_SESSIONS_LIVE_AUTH_RECHECK_SECONDS=60 +# AGENTA_SESSIONS_LIVE_READER_BUFFER_LIMIT=256 + # --- Attachment limits (files attached to an agent chat turn) --- # Per-file caps in bytes, by kind: 10 MB, except audio at 15 MB. Read by the api. # AGENTA_ATTACHMENTS_MAX_IMAGE_BYTES=10485760 diff --git a/hosting/kubernetes/helm/Chart.yaml b/hosting/kubernetes/helm/Chart.yaml index ed15cff1dff..18586ecf5c0 100644 --- a/hosting/kubernetes/helm/Chart.yaml +++ b/hosting/kubernetes/helm/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: agenta description: A Helm chart for deploying Agenta (OSS or EE) on Kubernetes type: application -version: 0.114.4 -appVersion: "v0.114.4" +version: 0.115.1 +appVersion: "v0.115.1" keywords: - agenta - llm diff --git a/hosting/kubernetes/helm/templates/runner-deployment.yaml b/hosting/kubernetes/helm/templates/runner-deployment.yaml index 49bc1008a3b..eb56eda8de1 100644 --- a/hosting/kubernetes/helm/templates/runner-deployment.yaml +++ b/hosting/kubernetes/helm/templates/runner-deployment.yaml @@ -86,6 +86,10 @@ spec: - name: AGENTA_RUNNER_LOG_LEVEL value: {{ $runner.logLevel | quote }} {{- end }} + {{- if and (hasKey $runner "liveFrames") (not (hasKey (default dict $runner.env) "AGENTA_RUNNER_LIVE_FRAMES")) }} + - name: AGENTA_RUNNER_LIVE_FRAMES + value: {{ $runner.liveFrames | quote }} + {{- end }} {{- if $daytona.apiUrl }} - name: AGENTA_RUNNER_DAYTONA_API_URL value: {{ $daytona.apiUrl | quote }} diff --git a/hosting/kubernetes/helm/values.schema.json b/hosting/kubernetes/helm/values.schema.json index 91ab2df83cb..4ed369b9773 100644 --- a/hosting/kubernetes/helm/values.schema.json +++ b/hosting/kubernetes/helm/values.schema.json @@ -307,6 +307,7 @@ "externalUrl": { "type": "string", "description": "AGENTA_RUNNER_INTERNAL_URL override pointing at an external runner." }, "piAgentDir": { "type": "string", "description": "PI_CODING_AGENT_DIR for local Pi runs (default /pi-agent); unset means no Agenta extension for the run (the runner logs a warning)." }, "logLevel": { "type": "string", "description": "AGENTA_RUNNER_LOG_LEVEL read by the runner service." }, + "liveFrames": { "type": "boolean", "description": "AGENTA_RUNNER_LIVE_FRAMES; opt-in temporary live-frame publication. Defaults to false." }, "providers": { "type": "object", "additionalProperties": false, diff --git a/hosting/kubernetes/helm/values.yaml b/hosting/kubernetes/helm/values.yaml index 55344705556..64f7e4791e3 100644 --- a/hosting/kubernetes/helm/values.yaml +++ b/hosting/kubernetes/helm/values.yaml @@ -138,6 +138,7 @@ redisDurable: # ================================================================== # # agentRunner: # enabled: true +# liveFrames: false # AGENTA_RUNNER_LIVE_FRAMES; opt in to temporary live-frame relay # providers: # enabled: [local] # AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS (rendered comma-joined) # default: local # AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER (must be one of enabled) diff --git a/hosting/railway/oss/scripts/configure.sh b/hosting/railway/oss/scripts/configure.sh index cca0e0e2f61..8a1e2862604 100755 --- a/hosting/railway/oss/scripts/configure.sh +++ b/hosting/railway/oss/scripts/configure.sh @@ -297,6 +297,54 @@ set_healthcheck() { railway_call environment edit --environment "$ENV_NAME" --service-config "$service" healthcheckPath "$path" --message "set healthcheck for ${service}" --json >/dev/null } +# clear_healthcheck : remove a healthcheck an earlier run of this +# script stored. Dropping a set_healthcheck call does not undo what is already +# on the environment, so the two services that must carry no healthcheck are +# cleared explicitly on every run. +# +# This goes through the API rather than `railway environment edit +# --service-config`: serviceInstanceUpdate with an empty healthcheckPath is +# the write template/apply.sh makes, and it was confirmed against the live +# preview template. The CLI's dot-path form is reported to no-op silently on +# deploy settings (railwayapp/cli issue 1119), and healthcheckPath is one. +# +# The value is read back afterwards, so a write that does not stick fails the +# run instead of leaving a service that can never turn green while the script +# prints "Configuration completed". +clear_healthcheck() { + local service="$1" svc_id live + + if [ -z "${RAILWAY_API_TOKEN:-}" ] || [ -z "$RAILWAY_ENVIRONMENT_ID" ]; then + printf "Cannot clear the healthcheck for '%s': RAILWAY_API_TOKEN and a resolved environment id are required.\n" \ + "$service" >&2 + return 1 + fi + + svc_id="$(_service_id_with_retry "$service")" + if [ -z "$svc_id" ]; then + printf "Could not resolve the service id for '%s' to clear its healthcheck.\n" "$service" >&2 + return 1 + fi + + _railway_graphql "$(jq -nc --arg s "$svc_id" --arg e "$RAILWAY_ENVIRONMENT_ID" \ + '{query: "mutation($s: String!, $e: String!, $in: ServiceInstanceUpdateInput!){ serviceInstanceUpdate(serviceId: $s, environmentId: $e, input: $in) }", + variables: {s: $s, e: $e, in: {healthcheckPath: ""}}}')" >/dev/null || return 1 + + live="$(_railway_graphql "$(jq -nc --arg id "$RAILWAY_ENVIRONMENT_ID" \ + '{query: "query($id: String!){ environment(id: $id){ serviceInstances { edges { node { serviceName healthcheckPath } } } } }", + variables: {id: $id}}')" \ + | jq -r --arg n "$service" \ + '.data.environment.serviceInstances.edges[].node | select(.serviceName == $n) | .healthcheckPath // ""' \ + | head -n1)" + + if [ -n "$live" ]; then + printf "Healthcheck for '%s' is still '%s' after the clear; Railway did not apply the write.\n" \ + "$service" "$live" >&2 + return 1 + fi + printf "Healthcheck cleared for '%s'.\n" "$service" +} + main() { require_cmd railway require_railway_auth @@ -396,6 +444,8 @@ main() { POSTGRES_URI_CORE="$pg_async_core" \ POSTGRES_URI_TRACING="$pg_async_tracing" \ POSTGRES_URI_SUPERTOKENS="$pg_sync_supertokens" \ + AGENTA_RUNNER_INTERNAL_URL="$agent_runner_url" \ + AGENTA_RUNNER_TOKEN="$AGENTA_RUNNER_TOKEN" \ AGENTA_STORE_ENDPOINT_URL="$seaweedfs_endpoint_url" \ AGENTA_STORE_ACCESS_KEY="$AGENTA_STORE_ACCESS_KEY" \ AGENTA_STORE_SECRET_KEY="$AGENTA_STORE_SECRET_KEY" \ @@ -463,7 +513,8 @@ main() { "AGENTA_RUNNER_DAYTONA_API_URL=${AGENTA_RUNNER_DAYTONA_API_URL:-}" \ "AGENTA_RUNNER_DAYTONA_TARGET=${AGENTA_RUNNER_DAYTONA_TARGET:-}" \ "AGENTA_RUNNER_DAYTONA_SNAPSHOT=${AGENTA_RUNNER_DAYTONA_SNAPSHOT:-}" \ - "AGENTA_RUNNER_DAYTONA_IMAGE=${AGENTA_RUNNER_DAYTONA_IMAGE:-}" + "AGENTA_RUNNER_DAYTONA_IMAGE=${AGENTA_RUNNER_DAYTONA_IMAGE:-}" \ + "AGENTA_RUNNER_LIVE_FRAMES=${AGENTA_RUNNER_LIVE_FRAMES:-}" # Do NOT list the runner's AGENTA_RUNNER_DAYTONA_* vars here: unset_vars always deletes, # which previously wiped a Daytona-configured runner's credentials right after setting them. @@ -541,10 +592,24 @@ main() { SSL_CERT_DAYS=820 \ RAILWAY_DEPLOYMENT_DRAINING_SECONDS=60 - set_healthcheck gateway "/" set_healthcheck api "/health" set_healthcheck services "/health" - set_healthcheck runner "/health" + + # Two services must have NO healthcheck, and both are cleared rather than + # merely left unset, because an earlier run of this script may have stored + # one. The template declares the same policy (template/template.json). + # + # gateway: it proxies / to web, and the web app answers / with a 308 + # redirect to /w (web/oss/next.config.ts). Railway counts a 308 as a + # failed probe, so the deployment never goes green. The gateway can carry + # a healthcheck again once the wrapper image serves its own 200 endpoint, + # for example location = /healthz. + # + # runner: it serves /health, but on AGENTA_RUNNER_PORT (8765), not on the + # port Railway probes, so Railway cannot reach it. Dropped from the + # template in 9fcbcec9d6 for that reason. + clear_healthcheck gateway + clear_healthcheck runner printf "Configuration completed for project '%s' environment '%s'\n" "$PROJECT_NAME" "$ENV_NAME" } diff --git a/hosting/railway/oss/scripts/preview-clone-create.sh b/hosting/railway/oss/scripts/preview-clone-create.sh index 5687fe02fde..ea2c8dd8068 100644 --- a/hosting/railway/oss/scripts/preview-clone-create.sh +++ b/hosting/railway/oss/scripts/preview-clone-create.sh @@ -140,7 +140,7 @@ OPTIONAL_SERVICES=(web-mobile) # Filled in by patch_commit_images / deploy_all for the run summary. MISSING_SERVICES="" -Q_ENV_SERVICES='query($id: String!) { environment(id: $id) { serviceInstances { edges { node { serviceId serviceName source { image } latestDeployment { status } domains { serviceDomains { domain } } } } } } }' +Q_ENV_SERVICES='query($id: String!) { environment(id: $id) { serviceInstances { edges { node { serviceId serviceName source { image } latestDeployment { id status } domains { serviceDomains { domain } } } } } } }' Q_ENVS='query($p: String!) { environments(projectId: $p, first: 100) { edges { node { id name } } } }' M_ENV_CREATE='mutation($in: EnvironmentCreateInput!) { environmentCreate(input: $in) { id name } }' M_DEPLOY='mutation($e: String!, $s: String!) { serviceInstanceDeployV2(environmentId: $e, serviceId: $s) }' @@ -287,6 +287,12 @@ clone_service_status() { <<<"$CLONE_SERVICES_JSON" | head -n1 } +clone_service_deployment_id() { + jq -r --arg n "$1" \ + '.data.environment.serviceInstances.edges[].node | select(.serviceName == $n) | .latestDeployment.id // ""' \ + <<<"$CLONE_SERVICES_JSON" | head -n1 +} + clone_service_image() { jq -r --arg n "$1" \ '.data.environment.serviceInstances.edges[].node | select(.serviceName == $n) | .source.image // ""' \ @@ -381,12 +387,23 @@ dump_failed_service_logs() { # wait_services_success # Returns 0 when every service is SUCCESS, 2 when a service ends FAILED or -# CRASHED (terminal — retrying the wait is pointless), 1 on timeout. alembic -# is a one-shot: an exited container may report SLEEPING/REMOVED, accepted for -# alembic only. +# CRASHED for good, 1 on timeout. alembic is a one-shot: an exited container +# may report SLEEPING/REMOVED, accepted for alembic only. +# +# A terminal state is retried once per service (RW_DEPLOY_RETRIES) before it +# fails the run. Railway drops a deployment now and then and marks it FAILED +# within ~15s with no build logs and no deploy logs, i.e. it never scheduled +# the container; redeploying the same image then goes green. A dropped infra +# deployment also takes its dependants down: when redis was dropped in a fresh +# clone, both workers ended CRASHED behind it. Losing one deployment out of the +# six to nine a clone starts at once killed the whole run, so retry once. A +# service that is genuinely broken fails its retry too and still stops the run. wait_services_success() { local timeout="$1"; shift local waited=0 interval="${RW_POLL_INTERVAL:-15}" svc st all_ok + local max_retries="${RW_DEPLOY_RETRIES:-1}" dep_id + declare -A retried=() # service -> redeploys issued + declare -A acted_on=() # service -> deployment id already redeployed while :; do refresh_clone_services || return 1 all_ok=1 @@ -396,7 +413,23 @@ wait_services_success() { SUCCESS) : ;; SLEEPING | REMOVED) [ "$svc" = "alembic" ] || all_ok=0 ;; FAILED | CRASHED) - printf "Service '%s' deployment ended %s.\n" "$svc" "$st" >&2 + dep_id="$(clone_service_deployment_id "$svc")" + # The redeploy is already in flight; Railway has not yet + # replaced latestDeployment. Keep waiting. + if [ -n "$dep_id" ] && [ "${acted_on[$svc]:-}" = "$dep_id" ]; then + all_ok=0 + continue + fi + if [ "${retried[$svc]:-0}" -lt "$max_retries" ]; then + retried[$svc]=$(( ${retried[$svc]:-0} + 1 )) + acted_on[$svc]="$dep_id" + printf "Service '%s' deployment ended %s; redeploying (attempt %s of %s).\n" \ + "$svc" "$st" "${retried[$svc]}" "$max_retries" >&2 + deploy_service "$svc" || return 1 + all_ok=0 + continue + fi + printf "Service '%s' deployment ended %s after %s retry(ies).\n" "$svc" "$st" "$max_retries" >&2 dump_failed_service_logs "$svc" return 2 ;; *) all_ok=0 ;; @@ -502,8 +535,8 @@ deploy_all() { fi done # A single Postgres first-deploy timeout in a fresh clone is transient - # (volume provisioning); retry the deploy once. Terminal FAILED/CRASHED - # (rc=2) is not retried. + # (volume provisioning); retry the deploy once. A FAILED/CRASHED Postgres + # already got its redeploy inside the wait, so rc=2 here is terminal. wait_services_success "${RW_INFRA_WAIT_SECONDS:-420}" Postgres && rc=0 || rc=$? if [ "$rc" -eq 1 ]; then printf 'Postgres first deploy timed out once; retrying the deploy (single retry).\n' >&2 diff --git a/hosting/railway/oss/template/README.md b/hosting/railway/oss/template/README.md index 58bc86f2843..ef9e4b75ab2 100644 --- a/hosting/railway/oss/template/README.md +++ b/hosting/railway/oss/template/README.md @@ -183,11 +183,20 @@ deployments are irrelevant to clones — clones copy config, not deployments). `AGENTA_MOBILE_GATE=true` so a phone is redirected from a desktop route to `/m`, and `web-mobile` sets `AGENTA_MOBILE_REVERSE_GATE=false` so a reviewer on a laptop can open `/m` directly. -- **Healthchecks:** unset on all services, matching the live-proven template - (10/10 green clone cycles). The standalone deployment path - (`../scripts/configure.sh`) sets healthchecks on gateway/api/services/runner; - adding them to the template is a deliberate change to `template.json`, not - silent drift. +- **Healthchecks:** set only on `api` and `services`, which both serve + `/health`. Two services have none on purpose, and `../scripts/configure.sh` + clears the same two. + - `gateway`: it proxies `/` to `web`, and the web app answers `/` with a 308 + redirect to `/w` (`web/oss/next.config.ts`). Railway counts a 308 as a + failed probe, so a healthcheck on `/` never goes green and the gateway + deployment of every clone ends FAILED. The gateway can carry a healthcheck + again once the wrapper image serves its own 200 endpoint (for example + `location = /healthz`), which needs a new `gateway_tag`. + - `runner`: it serves `/health`, but on `AGENTA_RUNNER_PORT` (8765), not on + the port Railway probes, so Railway cannot reach it. Dropped in 9fcbcec9d6. + + Adding a healthcheck to any other service is a deliberate change to + `template.json`, not silent drift. - **Deploy order** (for anything deploying a fresh clone): infra (Postgres/redis/seaweedfs) → alembic → everything else; supertokens must not start before alembic has created its database. A single Postgres diff --git a/hosting/railway/oss/template/lib-graphql.sh b/hosting/railway/oss/template/lib-graphql.sh index 1247b25eff1..f37c50b36ea 100644 --- a/hosting/railway/oss/template/lib-graphql.sh +++ b/hosting/railway/oss/template/lib-graphql.sh @@ -29,7 +29,11 @@ # RW_NO_TRANSIENT_RETRY Set to 1 around a NON-idempotent mutation (e.g. # serviceCreate, volumeCreate) so an ambiguous timeout # is not blind-retried; the caller must reconcile by -# querying (check-then-act). +# querying (check-then-act). A 429 and a workspace +# rate-limit rejection are still retried under it: +# both reject the request before any work happens. +# RW_RATE_LIMIT_WAIT Seconds to wait after a workspace rate-limit +# rejection (default: 35, past Railway's 30s window). # Call counter. A file, not a shell variable: callers invoke rw_graphql inside # command substitutions (subshells), where a variable increment would be lost. @@ -84,6 +88,23 @@ _rw_retry_after() { # prints the full response body (with .data) to stdout and returns 0. On # failure prints a redacted diagnostic to stderr and returns 1. GraphQL-level # errors (HTTP 200 + "errors") are deterministic and never retried. +# _rw_is_rate_limit_rejection : true only for a response that +# rejected the whole operation on a workspace rate limit and produced NO +# result. Both halves matter, because the caller retries on this even under +# RW_NO_TRANSIENT_RETRY: a partial response (some data plus an error), or an +# unrelated message that happens to carry the words, must never send a +# non-idempotent mutation a second time. The match reads .errors[].message +# rather than the raw body, so a rate-limit phrase sitting inside returned +# data cannot trigger a retry either. +_rw_is_rate_limit_rejection() { + jq -e ' + ((.data == null) or ([.data[]? | select(. != null)] | length == 0)) + and (((.errors // []) | map(.message // "") + | map(test("too quickly|allows [0-9]+ [a-z]+ per [0-9]+ seconds"; "i")) + | any) // false) + ' "$1" >/dev/null 2>&1 +} + rw_graphql() { local query="$1" local variables="${2:-}" @@ -94,6 +115,7 @@ rw_graphql() { local max_attempts="${RW_RETRY_MAX:-5}" [ "$max_attempts" -ge 1 ] 2>/dev/null || max_attempts=1 local delay="${RW_RETRY_DELAY:-5}" + local RW_RATE_LIMIT_WAIT="${RW_RATE_LIMIT_WAIT:-35}" local attempt=1 local payload @@ -133,6 +155,17 @@ rw_graphql() { wait_s="$(_rw_retry_after "$RW_LAST_HEADERS_FILE" "$delay")" elif printf '%s' "$http" | grep -qE '^5[0-9][0-9]$'; then [ "${RW_NO_TRANSIENT_RETRY:-0}" = "1" ] || retryable=1 + elif [ "$http" = "200" ] && _rw_is_rate_limit_rejection "$body_file"; then + # A workspace rate limit comes back as HTTP 200 with a GraphQL + # error, not as a 429: "You are creating environments too quickly. + # This workspace allows 1 environment per 30 seconds." Nothing was + # created, so this is a clean rejection and is safe to retry even + # for a non-idempotent mutation. The windows are 30s + # (projectCreate, environmentCreate, volumeCreate), so wait past + # the window instead of using the short default backoff. + retryable=1 + wait_s=$(( delay > RW_RATE_LIMIT_WAIT ? delay : RW_RATE_LIMIT_WAIT )) + printf 'rw_graphql: workspace rate limit hit; nothing was created.\n' >&2 fi if [ "$retryable" -eq 1 ] && [ "$attempt" -lt "$max_attempts" ]; then diff --git a/hosting/railway/oss/template/template.json b/hosting/railway/oss/template/template.json index 96a0c729d13..00c3b03de68 100644 --- a/hosting/railway/oss/template/template.json +++ b/hosting/railway/oss/template/template.json @@ -9,7 +9,7 @@ "system-variables": "Railway injects RAILWAY_* variables (RAILWAY_ENVIRONMENT*, RAILWAY_PROJECT_*, RAILWAY_SERVICE_*, RAILWAY_PRIVATE_DOMAIN, ...) into every service. The diff ignores any RAILWAY_* name UNLESS it is declared here (declared ones, e.g. redis's RAILWAY_RUN_UID, are managed and diffed like any other).", "start-command-policy": "Services whose image owns its entrypoint (Postgres, supertokens, and the three WP1 wrapper images gateway/redis/seaweedfs) MUST have startCommand empty/unset. The nine app services carry explicit startCommands because one image backs several services (agenta-api alone backs api, worker-streams, worker-queues, cron, and alembic), and because web/web-mobile share an entrypoint but run different servers. API fact: startCommand null in serviceInstanceUpdate is a NO-OP; \"\" clears the override (findings.md close-out addendum).", "tag-policy": "Template image tags must NEVER equal a PR image tag (pr--) and must NEVER be 'latest': environmentPatchCommit silently no-ops when a patched tag equals the template's tag, which strands a clone on template images (findings.md, deploy-mode section). apply.sh enforces this.", - "healthchecks": "healthcheckPath is unset on all services, matching the live-proven template (10/10 green clone cycles). The standalone deployment path (scripts/configure.sh) sets healthchecks for gateway/api/services/runner; adding them to the template is a deliberate change to this file, not silent drift.", + "healthchecks": "healthcheckPath is set only on api and services, both of which serve /health. Two services are unset on purpose, and scripts/configure.sh clears the same two. The gateway proxies / to web, and the web app answers / with a 308 redirect to /w (web/oss/next.config.ts), which Railway counts as a failed probe, so a healthcheck on / can never go green and every clone's gateway deployment ends FAILED; a gateway healthcheck can come back once the wrapper image serves its own 200 endpoint (for example location = /healthz), which needs a new gateway_tag. The runner serves /health, but on AGENTA_RUNNER_PORT (8765) rather than the port Railway probes, so Railway cannot reach it (dropped in 9fcbcec9d6). Adding a healthcheck to any other service is a deliberate change to this file, not silent drift.", "mobile-app": "The mobile app is the web-mobile service, served at /m by the gateway (location ~ ^/m(/|$) -> web-mobile, NO prefix strip: the Next app is built with basePath /m and owns the prefix). Its image ships web/entrypoint.sh, so it takes the same runtime config as web and writes the same __env.js. Preview gate policy: web carries AGENTA_MOBILE_GATE=true so a phone landing on a desktop route is redirected to /m, and web-mobile carries AGENTA_MOBILE_REVERSE_GATE=false so a reviewer on a laptop can open /m directly instead of being bounced back." }, "parameters": { @@ -97,7 +97,7 @@ "gateway": { "image": "ghcr.io/agenta-ai/agenta-preview-gateway:{gateway_tag}", "startCommand": "", - "healthcheckPath": "/", + "healthcheckPath": null, "restartPolicyType": "ON_FAILURE", "restartPolicyMaxRetries": 10, "volumes": [], @@ -181,6 +181,10 @@ "POSTGRES_URI_CORE": "postgresql+asyncpg://${{Postgres.POSTGRES_USER}}:${{Postgres.POSTGRES_PASSWORD}}@${{Postgres.RAILWAY_PRIVATE_DOMAIN}}:${{Postgres.PGPORT}}/agenta_oss_core", "POSTGRES_URI_TRACING": "postgresql+asyncpg://${{Postgres.POSTGRES_USER}}:${{Postgres.POSTGRES_PASSWORD}}@${{Postgres.RAILWAY_PRIVATE_DOMAIN}}:${{Postgres.PGPORT}}/agenta_oss_tracing", "POSTGRES_URI_SUPERTOKENS": "postgresql://${{Postgres.POSTGRES_USER}}:${{Postgres.POSTGRES_PASSWORD}}@${{Postgres.RAILWAY_PRIVATE_DOMAIN}}:${{Postgres.PGPORT}}/agenta_oss_supertokens", + "AGENTA_RUNNER_INTERNAL_URL": "http://${{runner.RAILWAY_PRIVATE_DOMAIN}}:8765", + "AGENTA_RUNNER_TOKEN": { + "secret": "AGENTA_RUNNER_TOKEN" + }, "AGENTA_STORE_ENDPOINT_URL": "http://${{seaweedfs.RAILWAY_PRIVATE_DOMAIN}}:8333", "AGENTA_STORE_ACCESS_KEY": { "secret": "AGENTA_STORE_ACCESS_KEY" @@ -293,7 +297,8 @@ "AGENTA_RUNNER_DAYTONA_API_URL", "AGENTA_RUNNER_DAYTONA_TARGET", "AGENTA_RUNNER_DAYTONA_SNAPSHOT", - "AGENTA_RUNNER_DAYTONA_IMAGE" + "AGENTA_RUNNER_DAYTONA_IMAGE", + "AGENTA_RUNNER_LIVE_FRAMES" ] }, "worker-streams": { diff --git a/sdks/python/agenta/sdk/agents/__init__.py b/sdks/python/agenta/sdk/agents/__init__.py index 3efe9f6d502..c4dc858bcb1 100644 --- a/sdks/python/agenta/sdk/agents/__init__.py +++ b/sdks/python/agenta/sdk/agents/__init__.py @@ -78,6 +78,8 @@ RunContextTrace, RunContextWorkflow, SandboxPermission, + SandboxCredentialConfig, + ResolvedSandboxCredential, SessionConfig, ToolCallback, TraceContext, @@ -188,6 +190,8 @@ "ToolCallback", "PermissionMode", "SandboxPermission", + "SandboxCredentialConfig", + "ResolvedSandboxCredential", "NetworkEgress", # Canonical tools API "ToolConfig", diff --git a/sdks/python/agenta/sdk/agents/adapters/agenta_builtins.py b/sdks/python/agenta/sdk/agents/adapters/agenta_builtins.py index f5eff2e6b18..013c387f901 100644 --- a/sdks/python/agenta/sdk/agents/adapters/agenta_builtins.py +++ b/sdks/python/agenta/sdk/agents/adapters/agenta_builtins.py @@ -920,7 +920,7 @@ `oneOf: [{const, title, description}]` when options need a sentence of explanation. For a form with several questions, set `"x-ag-stepper": true` on requestedSchema — it presents one question at a time with a final review step. - Never request secrets through it; credentials go through `request_connection`. + Never request secrets through it; custom secrets go through `request_secret`, while integration credentials go through `request_connection`. 2. Decide from the table. Most agents need only instructions. If the ask needs outside actions, call `discover_tools` with one short fragment per capability, such as "list github issues" or "post a slack message". diff --git a/sdks/python/agenta/sdk/agents/adapters/harnesses.py b/sdks/python/agenta/sdk/agents/adapters/harnesses.py index ee1107d6309..98977d1be5a 100644 --- a/sdks/python/agenta/sdk/agents/adapters/harnesses.py +++ b/sdks/python/agenta/sdk/agents/adapters/harnesses.py @@ -71,12 +71,14 @@ def _to_harness_config(self, config: SessionConfig) -> PiAgentTemplate: mcp_servers=list(config.mcp_servers), skills=list(config.agent.skills), sandbox_permission=config.agent.sandbox_permission, + sandbox_credentials=list(config.sandbox_credentials), permission_default=config.permission_default, harness_permissions=config.agent.harness_permissions, system=_opt_str(extras.get("system")), append_system=_opt_str(extras.get("append_system")), platform_instructions=compose_platform_instructions( - config.gateway_integration_names + config.gateway_integration_names, + [credential.binding.name for credential in config.sandbox_credentials], ), ) @@ -96,7 +98,8 @@ def _to_harness_config(self, config: SessionConfig) -> ClaudeAgentTemplate: return ClaudeAgentTemplate( agents_md=config.agent.instructions, platform_instructions=compose_platform_instructions( - config.gateway_integration_names + config.gateway_integration_names, + [credential.binding.name for credential in config.sandbox_credentials], ), model=config.agent.model, resolved_connection=config.resolved_connection, @@ -105,6 +108,7 @@ def _to_harness_config(self, config: SessionConfig) -> ClaudeAgentTemplate: mcp_servers=list(config.mcp_servers), skills=list(config.agent.skills), sandbox_permission=config.agent.sandbox_permission, + sandbox_credentials=list(config.sandbox_credentials), permission_default=config.permission_default, harness_permissions=config.agent.harness_permissions, ) @@ -125,7 +129,8 @@ def _to_harness_config(self, config: SessionConfig) -> CodexAgentTemplate: return CodexAgentTemplate( agents_md=config.agent.instructions, platform_instructions=compose_platform_instructions( - config.gateway_integration_names + config.gateway_integration_names, + [credential.binding.name for credential in config.sandbox_credentials], ), model=config.agent.model, resolved_connection=config.resolved_connection, @@ -134,6 +139,7 @@ def _to_harness_config(self, config: SessionConfig) -> CodexAgentTemplate: mcp_servers=list(config.mcp_servers), skills=list(config.agent.skills), sandbox_permission=config.agent.sandbox_permission, + sandbox_credentials=list(config.sandbox_credentials), permission_default=config.permission_default, harness_permissions=config.agent.harness_permissions, ) diff --git a/sdks/python/agenta/sdk/agents/adapters/local.py b/sdks/python/agenta/sdk/agents/adapters/local.py index 7b150c9232e..7417a93640a 100644 --- a/sdks/python/agenta/sdk/agents/adapters/local.py +++ b/sdks/python/agenta/sdk/agents/adapters/local.py @@ -48,6 +48,7 @@ async def create_session( trace: Optional[TraceContext] = None, run_context: Optional[RunContext] = None, session_id: Optional[str] = None, + detached: bool = False, effective_parameters: Optional[Dict[str, Any]] = None, gateway_policy: Optional[ResolvedGatewayPolicy] = None, ) -> Session: diff --git a/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py b/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py index a7f654bda52..f4429357f6d 100644 --- a/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py +++ b/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py @@ -68,6 +68,7 @@ def __init__( trace: Optional[TraceContext], run_context: Optional[RunContext], session_id: Optional[str], + detached: bool = False, effective_parameters: Optional[Dict[str, Any]] = None, gateway_policy: Optional[ResolvedGatewayPolicy] = None, ) -> None: @@ -78,6 +79,7 @@ def __init__( self._trace = trace self._run_context = run_context self._session_id = session_id + self._detached = detached self._effective_parameters = effective_parameters self._gateway_policy = gateway_policy @@ -95,6 +97,7 @@ def _wire_payload(self, messages: Sequence[Message]) -> Dict[str, Any]: trace=self._trace, run_context=self._run_context, session_id=self._session_id, + detached=self._detached, effective_parameters=self._effective_parameters, gateway_policy=self._gateway_policy, ) @@ -168,6 +171,7 @@ async def create_session( trace: Optional[TraceContext] = None, run_context: Optional[RunContext] = None, session_id: Optional[str] = None, + detached: bool = False, effective_parameters: Optional[Dict[str, Any]] = None, gateway_policy: Optional[ResolvedGatewayPolicy] = None, ) -> SandboxAgentSession: @@ -183,6 +187,7 @@ async def create_session( trace=trace, run_context=run_context, session_id=session_id, + detached=detached, effective_parameters=effective_parameters, gateway_policy=gateway_policy, ) diff --git a/sdks/python/agenta/sdk/agents/adapters/vercel/stream.py b/sdks/python/agenta/sdk/agents/adapters/vercel/stream.py index 5722cffc681..832a95fb655 100644 --- a/sdks/python/agenta/sdk/agents/adapters/vercel/stream.py +++ b/sdks/python/agenta/sdk/agents/adapters/vercel/stream.py @@ -358,6 +358,25 @@ async def _agent_run_to_vercel_parts_impl( failure_code=_runner_failure_code(data.get("code")), ): yield part + elif etype == "turn": + # The runner's admitted execution id, forwarded onto the MESSAGE METADATA so the + # client reads it as `message.metadata.turnId`, beside the `sessionId` the `start` + # frame already carries and the `traceId`/`usage` the `finish` frame adds. + # + # It cannot ride the `start` frame itself: that frame is emitted before the runner + # replies at all (see the `start` yield above), so a runner-minted id does not + # exist yet. A `message-metadata` chunk is the same channel one frame later, and + # the AI SDK MERGES metadata rather than replacing it (`mergeObjects`), so the id + # survives the `finish` frame's own metadata to the end of the turn. + # + # A client keeps it to name the execution it means to Stop + # (`expected_execution_id`) instead of cancelling "whatever runs now". + turn_id = data.get("turnId") + if isinstance(turn_id, str) and turn_id: + yield { + "type": "message-metadata", + "messageMetadata": {"turnId": turn_id}, + } elif etype == "done": # Last non-null stop reason wins; see the routing-layer twin's `done` note. reason = data.get("stopReason") @@ -641,6 +660,25 @@ async def _agent_stream_to_vercel_stream_impl( failure_code=_runner_failure_code(data.get("code")), ): yield part + elif etype == "turn": + # The runner's admitted execution id, forwarded onto the MESSAGE METADATA so the + # client reads it as `message.metadata.turnId`, beside the `sessionId` the `start` + # frame already carries and the `traceId`/`usage` the `finish` frame adds. + # + # It cannot ride the `start` frame itself: that frame is emitted before the runner + # replies at all (see the `start` yield above), so a runner-minted id does not + # exist yet. A `message-metadata` chunk is the same channel one frame later, and + # the AI SDK MERGES metadata rather than replacing it (`mergeObjects`), so the id + # survives the `finish` frame's own metadata to the end of the turn. + # + # A client keeps it to name the execution it means to Stop + # (`expected_execution_id`) instead of cancelling "whatever runs now". + turn_id = data.get("turnId") + if isinstance(turn_id, str) and turn_id: + yield { + "type": "message-metadata", + "messageMetadata": {"turnId": turn_id}, + } elif etype == "done": # Prefer the LAST non-null stop reason. The handler appends a corrective # terminal `done` after the runner's `done` when the authoritative result diff --git a/sdks/python/agenta/sdk/agents/dtos.py b/sdks/python/agenta/sdk/agents/dtos.py index ca2fbbcc879..d61382e6727 100644 --- a/sdks/python/agenta/sdk/agents/dtos.py +++ b/sdks/python/agenta/sdk/agents/dtos.py @@ -24,7 +24,7 @@ from agenta.sdk.engines.running.errors import ERRORS_BASE_URL, ErrorStatus -from .connections import ModelRef, ResolvedConnection +from .connections import EnvironmentCredentialBinding, ModelRef, ResolvedConnection from .mcp import ( MCPServerConfig, ResolvedMCPServer, @@ -619,6 +619,32 @@ class AgentResult(BaseModel): trace_id: Optional[str] = None +class SandboxSecretReference(BaseModel): + model_config = ConfigDict(extra="forbid") + slug: str + + +class SandboxEnvironmentBinding(BaseModel): + model_config = ConfigDict(extra="forbid") + type: Literal["env"] = "env" + name: str + + +class SandboxCredentialConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + secret: SandboxSecretReference + binding: SandboxEnvironmentBinding + + +class ResolvedSandboxCredential(BaseModel): + model_config = ConfigDict(extra="forbid") + binding: EnvironmentCredentialBinding + value: str = Field(repr=False, min_length=1) + + def to_wire(self) -> Dict[str, Any]: + return {"binding": self.binding.to_wire(), "value": self.value} + + # --------------------------------------------------------------------------- # The neutral agent definition + run selection # --------------------------------------------------------------------------- @@ -663,6 +689,7 @@ class AgentTemplate(BaseModel): harness_permissions: Dict[str, Any] = Field(default_factory=dict) harness_extras: Dict[str, Any] = Field(default_factory=dict) sandbox_permission: Optional[SandboxPermission] = None + sandbox_credentials: List[SandboxCredentialConfig] = Field(default_factory=list) # The execution selectors: the coding agent to drive, where it runs, and the runner-enforced # default permission mode (sourced from ``runner.permissions.default``). harness: str = "pi_core" @@ -722,6 +749,7 @@ def from_params( harness_permissions=harness_permissions, harness_extras=harness_extras, sandbox_permission=_parse_sandbox_permission(params, base), + sandbox_credentials=_parse_sandbox_credentials(params, base), harness=harness, sandbox=sandbox, permission_default=permission_default, @@ -760,6 +788,9 @@ class HarnessAgentTemplate(BaseModel): mcp_servers: List[ResolvedMCPServer] = Field(default_factory=list) skills: List[SkillTemplate] = Field(default_factory=list) sandbox_permission: Optional[SandboxPermission] = None + sandbox_credentials: List[ResolvedSandboxCredential] = Field( + default_factory=list, repr=False + ) permission_default: PermissionMode = "allow_reads" # The selected harness's first-class allow/ask/deny posture, carried verbatim from # ``AgentTemplate.harness_permissions`` by the harness adapter. A gating harness's CONFIG renders @@ -842,6 +873,13 @@ def wire_sandbox_permission(self) -> Dict[str, Any]: return {} return {"sandboxPermission": self.sandbox_permission.to_wire()} + def wire_sandbox_credentials(self) -> Dict[str, Any]: + if not self.sandbox_credentials: + return {} + return { + "sandboxCredentials": [item.to_wire() for item in self.sandbox_credentials] + } + def wire_harness_files(self) -> Dict[str, Any]: """The generic ``harnessFiles`` field for the ``/run`` payload: files this harness's config renders to drop in the session cwd before the session starts. Empty by default (Pi/Agenta @@ -1146,6 +1184,8 @@ class SessionConfig(BaseModel): # wire when unset, so a run that needs no binding is byte-identical to before. run_context: Optional[RunContext] = None session_id: Optional[str] = None + # Explicit per-invoke ownership handoff. False preserves request-owned cancellation. + detached: bool = False # The post-hydration config this turn runs, carried verbatim so the runner can stamp it on # the interaction row of any HITL gate the turn parks (see # ``agents/utils/effective_config.py``). Wire-emitted only for a session run; never consumed @@ -1161,6 +1201,9 @@ class SessionConfig(BaseModel): # /run serializer. ``None`` when the agent has no connection entry. gateway_policy: Optional[ResolvedGatewayPolicy] = None mcp_servers: List[ResolvedMCPServer] = Field(default_factory=list) + sandbox_credentials: List[ResolvedSandboxCredential] = Field( + default_factory=list, repr=False + ) @field_validator("tool_specs", mode="before") @classmethod @@ -1279,7 +1322,7 @@ def _has_agent_template(params: Dict[str, Any]) -> bool: # for forward-compat; only these three objects are locked down. _SELECTOR_ALLOWED_KEYS: Dict[str, frozenset] = { "harness": frozenset({"kind", "permissions", "extras"}), - "sandbox": frozenset({"kind", "permissions"}), + "sandbox": frozenset({"kind", "permissions", "credentials"}), "runner": frozenset({"kind", "permissions"}), } @@ -1479,6 +1522,27 @@ def _model_from_llm(llm: Dict[str, Any]) -> Any: return ref +def _parse_sandbox_credentials( + params: Dict[str, Any], defaults: AgentTemplate +) -> List[SandboxCredentialConfig]: + sandbox = _section(params, "sandbox") + if "credentials" not in sandbox: + return list(defaults.sandbox_credentials) + raw = sandbox.get("credentials") + if raw is None: + return [] + if not isinstance(raw, list): + raise AgentTemplateShapeError( + "agent template sandbox.credentials must be a list" + ) + try: + return [SandboxCredentialConfig.model_validate(item) for item in raw] + except Exception as exc: + raise AgentTemplateShapeError( + f"agent template sandbox.credentials is invalid: {exc}" + ) from exc + + def _parse_agent_fields( params: Dict[str, Any], defaults: AgentTemplate, diff --git a/sdks/python/agenta/sdk/agents/handler.py b/sdks/python/agenta/sdk/agents/handler.py index 2c04e8bd725..393abb0e161 100644 --- a/sdks/python/agenta/sdk/agents/handler.py +++ b/sdks/python/agenta/sdk/agents/handler.py @@ -34,6 +34,9 @@ from agenta.sdk.agents.errors import SandboxNotAllowedError from agenta.sdk.agents.sandbox_providers import sandbox_provider_enabled from agenta.sdk.agents.mcp import ResolvedMCPServer +from agenta.sdk.agents.sandbox_credentials import ( + resolve_sandbox_credentials as _resolve_sandbox_credentials, +) from agenta.sdk.agents.platform import ( resolve_connection as _platform_resolve_connection, ) @@ -51,7 +54,10 @@ from agenta.sdk.engines.running.errors import ForceNotSupportedV0Error from agenta.sdk.redaction.context import get_active_redactor, redaction_context from agenta.sdk.redaction.redactor import Redactor -from agenta.sdk.redaction.seed import seed_from_request +from agenta.sdk.redaction.seed import ( + is_non_secret_credential_locator, + seed_from_request, +) from agenta.sdk.models.workflows import ( WorkflowInvokeRequestFlags, WorkflowServiceRequest, @@ -63,6 +69,7 @@ ResolveToolsFn = Callable[..., Awaitable[ResolvedToolSet]] ResolveMCPFn = Callable[..., Awaitable[List[ResolvedMCPServer]]] ResolveConnectionFn = Callable[..., Awaitable[ResolvedConnection]] +ResolveSandboxCredentialsFn = Callable[..., Awaitable[List[Any]]] ResolveSessionConnectionFn = Callable[ [ModelRef, RuntimeAuthContext], Awaitable[ResolvedConnection] ] @@ -191,6 +198,9 @@ class AgentComposition: resolve_tools: ResolveToolsFn = field(default=_default_resolve_tools) resolve_mcp_servers: ResolveMCPFn = field(default=_default_resolve_mcp_servers) resolve_connection: ResolveConnectionFn = field(default=_default_resolve_connection) + resolve_sandbox_credentials: ResolveSandboxCredentialsFn = field( + default=_resolve_sandbox_credentials + ) # capability gating + fail-closed resolution policy; override to replace, not just add to. resolve_session_connection: Optional[ResolveSessionConnectionFn] = field( default=None @@ -263,6 +273,12 @@ async def _agent( ) resolved_connection = await resolve_session_connection(model_ref, ctx) + resolved_sandbox_credentials = await comp.resolve_sandbox_credentials( + agent_template.sandbox_credentials, + resolved_connection=resolved_connection, + mcp_servers=resolved_mcp, + ) + # Seed a FRESH per-run redactor immediately after trusted resolution and before # transport, trace, event, error, or result sinks can observe an echoed credential. # The redactor is installed into the ambient context for exactly this run's scope — @@ -277,12 +293,19 @@ async def _agent( for credential in ( resolved_connection.credentials if resolved_connection else [] ) + if not ( + credential.binding.kind == "environment" + and is_non_secret_credential_locator( + credential.binding.name, credential.value + ) + ) ), *( credential.value for server in resolved_mcp for credential in server.credentials ), + *(credential.value for credential in resolved_sandbox_credentials), ] ) @@ -302,6 +325,7 @@ async def _agent( trace=comp.trace_context(), run_context=rc, session_id=session_id, + detached=bool(flags.detached), # POST-hydration: the normalizer hands the handler `request.data.parameters` AFTER # the resolver has hydrated references (or kept the caller's inline config), so this # is the config the turn actually runs — the thing a HITL gate must be resumable @@ -314,6 +338,7 @@ async def _agent( # drops it fails as a silently tool-less agent rather than as an error. gateway_policy=resolved_tools.gateway_policy, mcp_servers=resolved_mcp, + sandbox_credentials=resolved_sandbox_credentials, ) if stream: diff --git a/sdks/python/agenta/sdk/agents/interfaces.py b/sdks/python/agenta/sdk/agents/interfaces.py index 3be298c5ce3..6e6ed92a83c 100644 --- a/sdks/python/agenta/sdk/agents/interfaces.py +++ b/sdks/python/agenta/sdk/agents/interfaces.py @@ -130,6 +130,7 @@ async def create_session( trace: Optional[TraceContext] = None, run_context: Optional[RunContext] = None, session_id: Optional[str] = None, + detached: bool = False, effective_parameters: Optional[Dict[str, Any]] = None, gateway_policy: Optional[ResolvedGatewayPolicy] = None, ) -> Session: @@ -201,6 +202,7 @@ async def create_session( trace=session_config.trace, run_context=session_config.run_context, session_id=session_config.session_id, + detached=session_config.detached, effective_parameters=session_config.effective_parameters, gateway_policy=session_config.gateway_policy, ) diff --git a/sdks/python/agenta/sdk/agents/platform/workflow.py b/sdks/python/agenta/sdk/agents/platform/workflow.py index 8e5a6e7d2e9..66b116c182b 100644 --- a/sdks/python/agenta/sdk/agents/platform/workflow.py +++ b/sdks/python/agenta/sdk/agents/platform/workflow.py @@ -38,6 +38,8 @@ REQUEST_CONNECTION_WORKFLOW_SLUG = "__ag__request_connection" REQUEST_CONNECTION_TOOL_NAME = "request_connection" +REQUEST_SECRET_WORKFLOW_SLUG = "__ag__request_secret" +REQUEST_SECRET_TOOL_NAME = "request_secret" class AgentaWorkflowToolResolver: @@ -64,14 +66,14 @@ async def resolve( authorization = self._connection.authorization() # Resolve every model-visible name up front. Sanitizing can merge two distinct children - # onto one name ("Support Router" and "Support/Router" both become `Support_Router`), and + # onto one name ("support router" and "support/router" both become `support_router`), and # a duplicate name silently shadows the earlier tool instead of erroring — so the second # subagent would simply never be callable. This is the only place that sees siblings. names_by_call_ref = disambiguate_tool_names( [ (tool_config.call_ref, tool_config.tool_name) for tool_config in tools - if not _is_request_connection_workflow(tool_config) + if not _is_client_platform_workflow(tool_config) ] ) @@ -87,28 +89,18 @@ async def resolve( log.warning("agent: %s", error) raise error seen.add(call_ref) - if _is_request_connection_workflow(tool_config): - tool_specs.append( - ClientToolSpec( - kind="client", - name=REQUEST_CONNECTION_TOOL_NAME, - description=tool_config.description - or "Request a connection from the user.", - input_schema=expand_type_refs(tool_config.input_schema), - render={"kind": "connect"}, - ) - ) + client_tool = _client_platform_tool(tool_config) + if client_tool is not None: + tool_specs.append(client_tool) continue resolved_name = names_by_call_ref[call_ref] tool_specs.append( CallbackToolSpec( name=resolved_name, - # The DESCRIPTION keeps the authored display name when there is one: that is - # what the model reads to decide whether to call this subagent, and the - # sanitized wire name may have lost the spacing that made it readable. - description=tool_config.description - or tool_config.name - or resolved_name, + # The DESCRIPTION is what the model reads to decide whether to call this + # subagent, so it never falls back to the stored `name`: that copy goes stale + # the moment the target is renamed (#6444). + description=tool_config.description or resolved_name, # Expand Agenta catalog pointers (``x-ag-type-ref``, e.g. ``messages``) into # concrete JSON Schema so the harness sees a real shape (an array WITH items, # not a bare ``x-ag-type-ref``) and can construct the call. Reference tools are @@ -130,6 +122,44 @@ async def resolve( ) +def _workflow_matches(tool_config: ReferenceToolConfig, slug: str) -> bool: + workflow = getattr(tool_config, "workflow", None) + if getattr(workflow, "slug", None) == slug: + return True + call_ref = tool_config.call_ref + return call_ref == f"workflow.variant.{slug}" or call_ref.startswith( + f"workflow.variant.{slug}." + ) + + +def _is_client_platform_workflow(tool_config: ReferenceToolConfig) -> bool: + return _is_request_connection_workflow(tool_config) or _workflow_matches( + tool_config, REQUEST_SECRET_WORKFLOW_SLUG + ) + + +def _client_platform_tool(tool_config: ReferenceToolConfig) -> Optional[ClientToolSpec]: + if _is_request_connection_workflow(tool_config): + return ClientToolSpec( + kind="client", + name=REQUEST_CONNECTION_TOOL_NAME, + description=tool_config.description + or "Request a connection from the user.", + input_schema=expand_type_refs(tool_config.input_schema), + render={"kind": "connect"}, + ) + if _workflow_matches(tool_config, REQUEST_SECRET_WORKFLOW_SLUG): + return ClientToolSpec( + kind="client", + name=REQUEST_SECRET_TOOL_NAME, + description=tool_config.description + or "Request a custom secret from the user.", + input_schema=expand_type_refs(tool_config.input_schema), + render={"kind": "secret"}, + ) + return None + + def _is_request_connection_workflow(tool_config: ReferenceToolConfig) -> bool: workflow = getattr(tool_config, "workflow", None) if getattr(workflow, "slug", None) == REQUEST_CONNECTION_WORKFLOW_SLUG: diff --git a/sdks/python/agenta/sdk/agents/platform_instructions.py b/sdks/python/agenta/sdk/agents/platform_instructions.py index dfc117e1625..de0eb399408 100644 --- a/sdks/python/agenta/sdk/agents/platform_instructions.py +++ b/sdks/python/agenta/sdk/agents/platform_instructions.py @@ -9,7 +9,27 @@ ## Agenta platform You are operating through Agenta. Use the documented tools and skills you receive, and never -invent tool results.""" +invent tool results. + +Use configured credential variables only to authenticate the requested operation. Do not inspect, +print, enumerate, or include their values in messages or files. If a required credential is +unavailable and `request_secret` is available, use it to open the secret setup flow. Otherwise +explain that the user must configure the credential in Agenta. Never ask the user to paste a +credential into chat. If the user cancels or declines secret setup, stop the affected operation +and do not request that secret again unless the user asks to retry.""" + + +def credential_guidance(environment_names: Sequence[str]) -> Optional[str]: + """Build names-only guidance for credentials already attached to this run.""" + names = sorted(set(environment_names)) + if not names: + return None + rendered = ", ".join(f"`{name}`" for name in names) + return f"""\ +## Configured credential variables + +The following credential variables are available for this run: {rendered}. +Use these names directly. Do not inspect or enumerate the environment to discover credentials.""" def gateway_guidance(integration_names: Sequence[str]) -> Optional[str]: @@ -40,9 +60,14 @@ def gateway_guidance(integration_names: Sequence[str]) -> Optional[str]: arguments — report it instead of looping.""" -def compose_platform_instructions(integration_names: Sequence[str]) -> str: +def compose_platform_instructions( + integration_names: Sequence[str], + credential_environment_names: Sequence[str] = (), +) -> str: """Compose deterministic SDK-owned text, with optional guidance after the common base.""" - guidance = gateway_guidance(integration_names) - if not guidance: - return AGENTA_PLATFORM_BASE - return f"{AGENTA_PLATFORM_BASE}\n\n{guidance}" + sections = [ + AGENTA_PLATFORM_BASE, + credential_guidance(credential_environment_names), + gateway_guidance(integration_names), + ] + return "\n\n".join(section for section in sections if section) diff --git a/sdks/python/agenta/sdk/agents/sandbox_credentials.py b/sdks/python/agenta/sdk/agents/sandbox_credentials.py new file mode 100644 index 00000000000..a81c0557e82 --- /dev/null +++ b/sdks/python/agenta/sdk/agents/sandbox_credentials.py @@ -0,0 +1,132 @@ +"""Validation and project-scoped resolution for sandbox environment credentials.""" + +from __future__ import annotations + +import re +from typing import Awaitable, Callable, Dict, List, Optional, Sequence + +from agenta.sdk.engines.running.errors import ERRORS_BASE_URL, ErrorStatus + +from .connections import EnvironmentCredentialBinding, ResolvedConnection +from .dtos import ResolvedSandboxCredential, SandboxCredentialConfig +from .mcp import ResolvedMCPServer +from .platform.secrets import resolve_named_secrets + +ENVIRONMENT_NAME_PATTERN = r"^[A-Za-z_][A-Za-z0-9_]*$" +RESERVED_SANDBOX_ENVIRONMENT_NAMES = frozenset( + { + "PATH", + "HOME", + "LD_PRELOAD", + "NODE_OPTIONS", + "PYTHONPATH", + "PI_CODING_AGENT_DIR", + "PI_CODING_AGENT_SESSION_DIR", + "PI_ACP_PI_COMMAND", + "CODEX_HOME", + "CODEX_SQLITE_HOME", + "CLAUDE_CONFIG_DIR", + "AGENTA_AGENT_TOOLS_RELAY_DIR", + "AGENTA_AGENT_TOOLS_PUBLIC_SPECS_FILE", + "AGENTA_AGENT_TELEMETRY_CONTROL_PATH", + "AGENTA_AGENT_TOOLS_RELAY_RESPONSE_WATCH_ENABLED", + "AGENTA_AGENT_MODEL_PROVIDER_OVERRIDE", + "AGENTA_AGENT_BUILTIN_ACTIVATION", + "AGENTA_AGENT_BUILTIN_GATING", + "AGENTA_AGENT_USAGE_CAPTURE_PATH", + "ENABLE_TOOL_SEARCH", + } +) +RESERVED_SANDBOX_ENVIRONMENT_PREFIXES = ( + "AGENTA_AGENT_", + "SANDBOX_AGENT_", + "PI_CODING_AGENT_", +) +_NAME_RE = re.compile(ENVIRONMENT_NAME_PATTERN) + + +class SandboxCredentialError(ErrorStatus, ValueError): + code: int = 400 + type: str = f"{ERRORS_BASE_URL}#v0:agent:invalid-sandbox-credential" + + def __init__(self, message: str) -> None: + super().__init__(code=self.code, type=self.type, message=message) + + +def validate_sandbox_credential_bindings( + credentials: Sequence[SandboxCredentialConfig], + *, + resolved_connection: Optional[ResolvedConnection] = None, + mcp_servers: Sequence[ResolvedMCPServer] = (), +) -> None: + occupied = set(RESERVED_SANDBOX_ENVIRONMENT_NAMES) + if resolved_connection: + occupied.update(resolved_connection.environment) + occupied.update(item.binding.name for item in resolved_connection.credentials) + for server in mcp_servers: + occupied.update( + item.binding.name + for item in server.credentials + if getattr(item.binding, "kind", None) == "environment" + ) + + seen: set[str] = set() + for credential in credentials: + slug = credential.secret.slug + name = credential.binding.name + if not slug: + raise SandboxCredentialError( + "sandbox credential secret.slug must be non-empty" + ) + if not _NAME_RE.fullmatch(name): + raise SandboxCredentialError( + f"sandbox credential binding name {name!r} is not a valid environment variable" + ) + if name in seen: + raise SandboxCredentialError( + f"duplicate sandbox credential environment binding {name!r}" + ) + if name.startswith(RESERVED_SANDBOX_ENVIRONMENT_PREFIXES): + raise SandboxCredentialError( + f"sandbox credential environment binding {name!r} is reserved by the runtime" + ) + if name in occupied: + raise SandboxCredentialError( + f"sandbox credential environment binding {name!r} is reserved or already owned" + ) + seen.add(name) + + +ResolveNamedSecrets = Callable[[Sequence[str]], Awaitable[Dict[str, str]]] + + +async def resolve_sandbox_credentials( + credentials: Sequence[SandboxCredentialConfig], + *, + resolved_connection: Optional[ResolvedConnection] = None, + mcp_servers: Sequence[ResolvedMCPServer] = (), + resolver: Optional[ResolveNamedSecrets] = None, +) -> List[ResolvedSandboxCredential]: + validate_sandbox_credential_bindings( + credentials, + resolved_connection=resolved_connection, + mcp_servers=mcp_servers, + ) + if not credentials: + return [] + + slugs = list(dict.fromkeys(item.secret.slug for item in credentials)) + values = await (resolver or resolve_named_secrets)(slugs) + missing = [slug for slug in slugs if not values.get(slug)] + if missing: + raise SandboxCredentialError( + f"{len(missing)} configured sandbox credential secret(s) could not be resolved" + ) + + return [ + ResolvedSandboxCredential( + binding=EnvironmentCredentialBinding(name=item.binding.name), + value=values[item.secret.slug], + ) + for item in credentials + ] diff --git a/sdks/python/agenta/sdk/agents/tools/models.py b/sdks/python/agenta/sdk/agents/tools/models.py index 85b5fae50ab..6956286a5a0 100644 --- a/sdks/python/agenta/sdk/agents/tools/models.py +++ b/sdks/python/agenta/sdk/agents/tools/models.py @@ -28,19 +28,19 @@ def _empty_object_schema() -> Dict[str, Any]: _TOOL_NAME_ALLOWED = re.compile(r"[^a-zA-Z0-9_.-]+") -def sanitize_tool_name(raw: Optional[str], *, fallback: str) -> str: - """Coerce an authored name into the provider's tool-name pattern. +def sanitize_tool_name(raw: Optional[str], *, fallback: str = "") -> str: + """Coerce a name into the provider's tool-name pattern. - A subagent's model-visible name is a DISPLAY name the user typed, so it can carry spaces, - slashes, or anything else a person writes. Sending it unchanged made the provider refuse the - entire tool list with `Invalid 'tools[N].name'`, which bricks every run of the parent agent - until the child is renamed. Names like "Support Router" are an ordinary thing to type. + Every major provider requires `^[a-zA-Z0-9_.-]+$` and refuses the WHOLE tool list when any + entry violates it, which bricks every run of the parent agent. A subagent's name comes from + its workflow slug, and a slug saved through the platform already matches; this guards the + one that did not, because `ReferenceToolConfig.slug` is an unvalidated string a hand-authored + configuration can fill with anything. The mapping is deterministic and stable, because the model sees this name and a name that changed between turns would strand a conversation mid-tool-call: every disallowed run of characters becomes one `_`, leading and trailing separators are trimmed, and an input that - survives none of that falls back to `fallback` (itself sanitized). Only the WIRE name is - touched; the display name is never rewritten. + survives none of that falls back to `fallback` (itself sanitized), then to "tool". """ collapsed = _TOOL_NAME_ALLOWED.sub("_", (raw or "").strip()) # Trim separators the collapse may have produced at either end. `.` and `-` are legal @@ -378,7 +378,14 @@ class ReferenceToolConfig(ToolConfigBase): default=None, description="Pin a workflow revision (ref_by='variant' only); absent = latest.", ) - name: Optional[str] = Field(default=None, min_length=1) + name: Optional[str] = Field( + default=None, + min_length=1, + description=( + "Legacy: a stale copy of the target's display name (#6444). No wire value derives " + "from it; kept so references saved before then still parse." + ), + ) description: Optional[str] = None input_schema: Dict[str, Any] = Field(default_factory=_empty_object_schema) @@ -403,15 +410,18 @@ def _check_axis(self) -> "ReferenceToolConfig": @property def tool_name(self) -> str: - """The model-visible name; defaults to the workflow slug when none is authored. + """The model-visible name: the workflow SLUG, never the stored display name. + + The slug is the reference's only identity and a rename never touches it, so this is + stable inside a conversation AND correct after the target is renamed — the stored `name` + was neither, because it was a copy taken at add time (#6444). - Sanitized to the provider's tool-name pattern, because the authored `name` is a DISPLAY - name a person typed and may contain spaces or punctuation the provider refuses. The - display name itself is never rewritten — only this wire value. Collisions between two - children that sanitize alike are resolved by the caller building the tool list, which is - the only place that can see siblings. + Still sanitized to the provider's tool-name pattern: a slug authored through the API + rather than the UI need not match it, and a name the provider refuses fails the whole + tool list. Collisions between two slugs that sanitize alike are resolved by the caller + building the tool list, which is the only place that can see siblings. """ - return sanitize_tool_name(self.name, fallback=self.slug) + return sanitize_tool_name(self.slug) @property def call_ref(self) -> str: diff --git a/sdks/python/agenta/sdk/agents/tools/resolver.py b/sdks/python/agenta/sdk/agents/tools/resolver.py index b8b605960b7..9f6aceb5eff 100644 --- a/sdks/python/agenta/sdk/agents/tools/resolver.py +++ b/sdks/python/agenta/sdk/agents/tools/resolver.py @@ -137,13 +137,12 @@ def _validate_declared_config_names(tool_configs: Sequence[ToolConfig]) -> None: if name is None: continue if isinstance(tool_config, ReferenceToolConfig): - # A reference tool's model-visible name is DERIVED: an authored display name, - # sanitized to the provider's tool-name pattern. Two distinct children can therefore - # arrive here sharing one name ("Support Router" and "Support/Router" both sanitize - # to `Support_Router`) without either being a mistake. The workflow adapter gives - # them distinct names before they reach the wire, and `_validate_unique_names` still - # checks the result, so rejecting them here would refuse a valid configuration. The - # reserved-name check still applies — that one is about shadowing a built-in. + # A reference tool's model-visible name is DERIVED: its workflow slug, sanitized to + # the provider's tool-name pattern. Two slugs can therefore arrive here sharing one + # name, and the workflow adapter gives them distinct names before they reach the wire + # while `_validate_unique_names` still checks the result. Rejecting here would refuse + # a pair the adapter handles. The reserved-name check still applies — that one is + # about shadowing a built-in. _reject_reserved_tool_name(name) continue _check_tool_name(name, seen) diff --git a/sdks/python/agenta/sdk/agents/utils/ts_runner.py b/sdks/python/agenta/sdk/agents/utils/ts_runner.py index 292ebb99f96..7729cdd71a8 100644 --- a/sdks/python/agenta/sdk/agents/utils/ts_runner.py +++ b/sdks/python/agenta/sdk/agents/utils/ts_runner.py @@ -180,8 +180,9 @@ async def deliver_http_stream( ) -> AsyncIterator[Dict[str, Any]]: """POST ``/run`` asking for NDJSON and yield each parsed record as it arrives. - The ``async with`` closes the connection when the generator is closed or cancelled, which - the runner observes as a client disconnect and turns into run cancellation. + The ``async with`` closes the connection when the generator is closed or cancelled. The + runner turns that disconnect into cancellation for request-owned runs, while an explicitly + detached session run continues under session ownership. """ import httpx # local import: only the HTTP transport needs it diff --git a/sdks/python/agenta/sdk/agents/utils/wire.py b/sdks/python/agenta/sdk/agents/utils/wire.py index bc104e6ac23..5a486e94b36 100644 --- a/sdks/python/agenta/sdk/agents/utils/wire.py +++ b/sdks/python/agenta/sdk/agents/utils/wire.py @@ -93,6 +93,7 @@ def request_to_wire( trace: Optional[TraceContext] = None, run_context: Optional[RunContext] = None, session_id: Optional[str] = None, + detached: bool = False, turn_id: Optional[str] = None, project_id: Optional[str] = None, effective_parameters: Optional[Dict[str, Any]] = None, @@ -159,6 +160,7 @@ def request_to_wire( **config.wire_mcp(), **config.wire_skills(), **config.wire_sandbox_permission(), + **config.wire_sandbox_credentials(), **config.wire_connection_ref(), **config.wire_model_connection(), **config.wire_harness_mode(), @@ -172,6 +174,8 @@ def request_to_wire( payload["runContext"] = run_context_wire if turn_id is not None: payload["turnId"] = turn_id + if detached and session_id: + payload["detached"] = True if project_id is not None: payload["projectId"] = project_id if session_id: diff --git a/sdks/python/agenta/sdk/agents/wire_models.py b/sdks/python/agenta/sdk/agents/wire_models.py index bf522913c21..065451f5ddf 100644 --- a/sdks/python/agenta/sdk/agents/wire_models.py +++ b/sdks/python/agenta/sdk/agents/wire_models.py @@ -100,6 +100,13 @@ class WireCredentialBinding(_WireModel): name: str +class WireSandboxCredential(_WireModel): + """One resolved credential delivered to the sandbox environment.""" + + binding: WireCredentialBinding + value: str + + class WireCredential(_WireModel): """One model credential, its binding, and its consumer usage contract.""" @@ -510,6 +517,7 @@ class WireRunRequest(_WireModel): harness: Optional[str] = None sandbox: Optional[str] = None session_id: Optional[str] = Field(default=None, alias="sessionId") + detached: Optional[bool] = None # Session-owned (detached) turn identity: the runner uses these to own the alive lock and # persist the transcript independently of any client connection. Omitted on ad-hoc runs. turn_id: Optional[str] = Field(default=None, alias="turnId") @@ -523,6 +531,9 @@ class WireRunRequest(_WireModel): model_connection: Optional[WireModelConnection] = Field( default=None, alias="modelConnection" ) + sandbox_credentials: Optional[List[WireSandboxCredential]] = Field( + default=None, alias="sandboxCredentials" + ) harness_mode: Optional[str] = Field(default=None, alias="harnessMode") # Resolved model input modalities. Omitted when the resolver cannot determine them. model_capabilities: Optional[WireModelCapabilities] = Field( diff --git a/sdks/python/agenta/sdk/models/workflows.py b/sdks/python/agenta/sdk/models/workflows.py index c4841ab9c58..9fbb1772ec5 100644 --- a/sdks/python/agenta/sdk/models/workflows.py +++ b/sdks/python/agenta/sdk/models/workflows.py @@ -136,6 +136,8 @@ class WorkflowInvokeRequestFlags(BaseModel): trim: Optional[bool] = None force: Optional[bool] = None resolve: Optional[bool] = None + # A shared-event sender may close invoke after acceptance without owning the turn lifetime. + detached: Optional[bool] = None class WorkflowRevisionData(BaseModel): diff --git a/sdks/python/agenta/sdk/redaction/seed.py b/sdks/python/agenta/sdk/redaction/seed.py index 19a1fa0bf1e..1974b58aff8 100644 --- a/sdks/python/agenta/sdk/redaction/seed.py +++ b/sdks/python/agenta/sdk/redaction/seed.py @@ -99,11 +99,33 @@ def _looks_secret(name: str) -> bool: return bool(blocklist) and any(b in name for b in blocklist) +def _looks_like_file_path(value: str) -> bool: + trimmed = value.strip() + return trimmed.startswith(("/", "./", "../", "~/", "\\\\")) or ( + len(trimmed) >= 3 + and trimmed[0].isalpha() + and trimmed[1] == ":" + and trimmed[2] in ("/", "\\") + ) + + +def is_non_secret_credential_locator(name: str, value: str) -> bool: + """Whether a provider-SDK environment value locates credentials but is not material.""" + upper = name.upper() + return upper == "AWS_PROFILE" or ( + upper == "GOOGLE_APPLICATION_CREDENTIALS" and _looks_like_file_path(value) + ) + + def curated_env_secret_values() -> List[str]: """The VALUES (never the names) of every env var whose name is selected by the matchers.""" values: List[str] = [] for name, value in os.environ.items(): - if value and _looks_secret(name.upper()): + if ( + value + and _looks_secret(name.upper()) + and not is_non_secret_credential_locator(name, value) + ): values.append(value) return values diff --git a/sdks/python/agenta/sdk/utils/types.py b/sdks/python/agenta/sdk/utils/types.py index db1aa75c11b..0da9291a378 100644 --- a/sdks/python/agenta/sdk/utils/types.py +++ b/sdks/python/agenta/sdk/utils/types.py @@ -1413,6 +1413,26 @@ class _RunnerSchema(BaseModel): ) +class _SandboxSecretReferenceSchema(BaseModel): + model_config = ConfigDict(extra="forbid", title="Secret reference") + slug: str = Field(title="Secret", description="Project secret slug.") + + +class _SandboxEnvironmentBindingSchema(BaseModel): + model_config = ConfigDict(extra="forbid", title="Environment binding") + type: Literal["env"] = "env" + name: str = Field( + title="Variable name", + pattern=r"^[A-Za-z_][A-Za-z0-9_]*$", + ) + + +class _SandboxCredentialSchema(BaseModel): + model_config = ConfigDict(extra="forbid", title="Sandbox credential") + secret: _SandboxSecretReferenceSchema + binding: _SandboxEnvironmentBindingSchema + + class _SandboxSchema(BaseModel): """Where the agent runs plus its security boundary (was the flat ``sandbox`` scalar and the sibling ``sandbox_permission``). @@ -1427,6 +1447,11 @@ class _SandboxSchema(BaseModel): title="Sandbox", description="Where the agent runs: local daemon or a Daytona sandbox.", ) + credentials: List[_SandboxCredentialSchema] = Field( + default_factory=list, + title="Credentials", + description="Project secret references bound to sandbox environment variables.", + ) permissions: Optional[SandboxPermission] = Field( default=None, title="Permissions", diff --git a/sdks/python/oss/tests/pytest/integration/agents/_fake_runner_backend.py b/sdks/python/oss/tests/pytest/integration/agents/_fake_runner_backend.py index d83f5d69778..dcede9cf0cd 100644 --- a/sdks/python/oss/tests/pytest/integration/agents/_fake_runner_backend.py +++ b/sdks/python/oss/tests/pytest/integration/agents/_fake_runner_backend.py @@ -58,6 +58,7 @@ def __init__( trace: Optional[TraceContext], run_context: Optional[RunContext], session_id: Optional[str], + detached: bool = False, effective_parameters: Optional[Dict[str, Any]] = None, gateway_policy: Optional[ResolvedGatewayPolicy] = None, ) -> None: @@ -67,6 +68,7 @@ def __init__( self._trace = trace self._run_context = run_context self._session_id = session_id + self._detached = detached self._effective_parameters = effective_parameters self._gateway_policy = gateway_policy @@ -84,6 +86,7 @@ def _wire_payload(self, messages: Sequence[Message]) -> Dict[str, Any]: trace=self._trace, run_context=self._run_context, session_id=self._session_id, + detached=self._detached, effective_parameters=self._effective_parameters, gateway_policy=self._gateway_policy, ) @@ -161,6 +164,7 @@ async def create_session( trace: Optional[TraceContext] = None, run_context: Optional[RunContext] = None, session_id: Optional[str] = None, + detached: bool = False, effective_parameters: Optional[Dict[str, Any]] = None, gateway_policy: Optional[ResolvedGatewayPolicy] = None, ) -> FakeRunnerSession: @@ -171,6 +175,7 @@ async def create_session( trace=trace, run_context=run_context, session_id=session_id, + detached=detached, effective_parameters=effective_parameters, gateway_policy=gateway_policy, ) diff --git a/sdks/python/oss/tests/pytest/unit/agents/adapters/test_vercel_stream_conformance.py b/sdks/python/oss/tests/pytest/unit/agents/adapters/test_vercel_stream_conformance.py index 474f3f4598f..aaa3cde0e79 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/adapters/test_vercel_stream_conformance.py +++ b/sdks/python/oss/tests/pytest/unit/agents/adapters/test_vercel_stream_conformance.py @@ -423,3 +423,109 @@ def test_vendored_version_matches_package_pin() -> None: # CI-grep-able tripwire: bump this const (and re-audit the shape above) whenever # web/oss/package.json's "ai" pin changes. assert _AI_PACKAGE_VERSION == "6.0.0-beta.150" + + +# --------------------------------------------------------------------------- +# The turn id pass-through. +# +# The runner mints the turn id per execution and, until this, told no one. The `start` frame is +# built and emitted before the runner replies at all, so it CANNOT carry a runner-minted id — +# which is why `expected_execution_id` on the public Cancel had no first-party caller able to fill +# it. The runner now emits a `turn` event as its first frame and the egress forwards it unchanged +# as `data-agent-turn`, the earliest part that can carry it. +# --------------------------------------------------------------------------- + +_TURN_ID = "d3b4a1c2-0000-4000-8000-abcdefabcdef" + +# The runner's AgentEvent is FLAT (`{type, turnId}`, like `{type, message, code}` for an error), +# and each path wraps it differently. The live handler yields `{"type", "data"}` where `data` is +# the whole flat runner event; `AgentStream` (the dev twin) hands the flat record through +# `Event.from_wire`, which also sets `data` to the whole record. Both fixtures below are the real +# shapes, not a convenient one — a fixture that reshapes the event tests nothing about the wire. +_TURN_EVENTS_LIVE: List[Dict[str, Any]] = [ + {"type": "turn", "data": {"type": "turn", "turnId": _TURN_ID}}, + {"type": "message", "data": {"text": "hello"}}, + {"type": "done", "data": {"stopReason": "stop"}}, +] +_TURN_EVENTS_RUN: List[Dict[str, Any]] = [ + {"type": "turn", "turnId": _TURN_ID}, + {"type": "message", "text": "hello"}, + {"type": "done", "stopReason": "stop"}, +] + +_TURN_METADATA = {"type": "message-metadata", "messageMetadata": {"turnId": _TURN_ID}} + + +@pytest.mark.asyncio +async def test_live_projection_puts_the_turn_id_on_message_metadata() -> None: + parts = [ + part + async for part in agent_stream_to_vercel_stream( + _records(_TURN_EVENTS_LIVE), trace_id="t1" + ) + ] + for part in parts: + assert_conforms(part) + + metadata_parts = [p for p in parts if p["type"] == "message-metadata"] + assert metadata_parts == [_TURN_METADATA], ( + "the egress must forward the runner's id verbatim, exactly once, as message metadata" + ) + + # It must land before any content, so a client that Stops early already holds the id. + turn_index = next(i for i, p in enumerate(parts) if p["type"] == "message-metadata") + first_text = next( + (i for i, p in enumerate(parts) if p["type"].startswith("text-")), None + ) + assert first_text is None or turn_index < first_text + + +@pytest.mark.asyncio +async def test_the_finish_frames_metadata_does_not_displace_the_turn_id() -> None: + """The whole reason `message-metadata` is a safe carrier. + + The AI SDK merges metadata rather than replacing it (`mergeObjects` in ai@6), so the + `finish` frame's own `messageMetadata` (traceId, usage) lands BESIDE the turn id rather than + over it. If that ever changed, a client would lose the id exactly when a late Stop needs it, + so pin that the two carry disjoint keys and the turn id is written first. + """ + parts = [ + part + async for part in agent_stream_to_vercel_stream( + _records(_TURN_EVENTS_LIVE), trace_id="t1" + ) + ] + turn_index = next(i for i, p in enumerate(parts) if p["type"] == "message-metadata") + finish = next(p for p in parts if p["type"] == "finish") + finish_index = parts.index(finish) + + assert turn_index < finish_index + assert "turnId" not in (finish.get("messageMetadata") or {}), ( + "the finish frame must not restate the turn id; it merges beside it" + ) + + +@pytest.mark.asyncio +async def test_dev_twin_projection_puts_the_turn_id_on_message_metadata() -> None: + run = _run_with(_TURN_EVENTS_RUN, result={"output": "hello"}) + parts = [part async for part in agent_run_to_vercel_parts(run)] + for part in parts: + assert_conforms(part) + assert _TURN_METADATA in parts + + +@pytest.mark.asyncio +async def test_a_turn_event_with_no_usable_id_emits_nothing() -> None: + # An older runner, or a malformed frame, must not put an empty id on the stream: a client + # would send it as `expected_execution_id` and cancel nothing, or worse, read it as "no + # guard". Dropping it leaves the client in the honest "I do not know the id" state. + for bad in ({}, {"turnId": None}, {"turnId": ""}, {"turnId": 7}): + parts = [ + part + async for part in agent_stream_to_vercel_stream( + _records([{"type": "turn", "data": bad}]), trace_id="t1" + ) + ] + assert not [p for p in parts if p["type"] == "message-metadata"], ( + f"a turn event with data={bad!r} must emit no metadata frame" + ) diff --git a/sdks/python/oss/tests/pytest/unit/agents/conftest.py b/sdks/python/oss/tests/pytest/unit/agents/conftest.py index 22fb54a7e37..d2eab8fd4bf 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/conftest.py +++ b/sdks/python/oss/tests/pytest/unit/agents/conftest.py @@ -145,6 +145,7 @@ async def create_session( trace=None, run_context=None, session_id=None, + detached=False, effective_parameters=None, gateway_policy=None, ) -> FakeSession: @@ -157,6 +158,7 @@ async def create_session( "trace": trace, "run_context": run_context, "session_id": session_id, + "detached": detached, "effective_parameters": effective_parameters, "gateway_policy": gateway_policy, } diff --git a/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.codex.json b/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.codex.json index 67d0f0e9b85..3ff4c653d5f 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.codex.json +++ b/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.codex.json @@ -46,5 +46,14 @@ "run": { "kind": "test" } - } + }, + "sandboxCredentials": [ + { + "binding": { + "kind": "environment", + "name": "GITHUB_TOKEN" + }, + "value": "github-secret" + } + ] } diff --git a/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.gateway_connection.json b/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.gateway_connection.json index 00434c4e23d..afebea0c649 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.gateway_connection.json +++ b/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.gateway_connection.json @@ -78,7 +78,7 @@ "permissions": { "default": "allow_reads" }, - "platformInstructions": "## Agenta platform\n\nYou are operating through Agenta. Use the documented tools and skills you receive, and never\ninvent tool results.\n\n## Connected integrations\n\nYou can reach your integrations with two tools: `search_tools` and `run_tool`.\nFor instance, some of the integrations you have: github. Others may exist, and this\nlist can go stale \u2014 `search_tools` is the source of truth for what is connected right now.\n\n- Search once per task, with a concrete description of what you want to do. Never repeat an\n equivalent query \u2014 a second search that means the same thing returns the same results.\n- A search returns at most 5 results. That is a cap, not the whole catalog \u2014 if none fit,\n narrow the description rather than concluding no such tool exists.\n- \"No configured tool matched this request.\" is not a failure. Refine the query ONCE and\n search again \u2014 that is what the message asks for \u2014 then report if it still finds nothing.\n- \"Tool search is temporarily unavailable.\" is a temporary failure: retry it once and no more.\n- Use only an integration and a tool key that a search result returned. Never invent one.\n Pass the BARE tool key, not a prefixed provider action id such as `GMAIL_FETCH_EMAILS`.\n- Copy the arguments from the input schema the search result returned.\n- Stop searching once a result is usable, and run it.\n- A run may pause for the user's approval or be refused outright: that is this agent's\n permission policy, not a bug. A refusal will not succeed on a retry or with reshaped\n arguments \u2014 report it instead of looping.", + "platformInstructions": "## Agenta platform\n\nYou are operating through Agenta. Use the documented tools and skills you receive, and never\ninvent tool results.\n\nUse configured credential variables only to authenticate the requested operation. Do not inspect,\nprint, enumerate, or include their values in messages or files. If a required credential is\nunavailable and `request_secret` is available, use it to open the secret setup flow. Otherwise\nexplain that the user must configure the credential in Agenta. Never ask the user to paste a\ncredential into chat. If the user cancels or declines secret setup, stop the affected operation\nand do not request that secret again unless the user asks to retry.\n\n## Connected integrations\n\nYou can reach your integrations with two tools: `search_tools` and `run_tool`.\nFor instance, some of the integrations you have: github. Others may exist, and this\nlist can go stale \u2014 `search_tools` is the source of truth for what is connected right now.\n\n- Search once per task, with a concrete description of what you want to do. Never repeat an\n equivalent query \u2014 a second search that means the same thing returns the same results.\n- A search returns at most 5 results. That is a cap, not the whole catalog \u2014 if none fit,\n narrow the description rather than concluding no such tool exists.\n- \"No configured tool matched this request.\" is not a failure. Refine the query ONCE and\n search again \u2014 that is what the message asks for \u2014 then report if it still finds nothing.\n- \"Tool search is temporarily unavailable.\" is a temporary failure: retry it once and no more.\n- Use only an integration and a tool key that a search result returned. Never invent one.\n Pass the BARE tool key, not a prefixed provider action id such as `GMAIL_FETCH_EMAILS`.\n- Copy the arguments from the input schema the search result returned.\n- Stop searching once a result is usable, and run it.\n- A run may pause for the user's approval or be refused outright: that is this agent's\n permission policy, not a bug. A refusal will not succeed on a retry or with reshaped\n arguments \u2014 report it instead of looping.", "gatewayPolicy": { "integrations": { "github": { diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_agent_composition_seam.py b/sdks/python/oss/tests/pytest/unit/agents/test_agent_composition_seam.py index d8171e326c0..5412f0d68ed 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_agent_composition_seam.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_agent_composition_seam.py @@ -95,6 +95,7 @@ def __init__(self, *, output: str = "hi") -> None: self.created_run_contexts: List[Any] = [] self.created_effective_parameters: List[Any] = [] self.created_gateway_policies: List[Any] = [] + self.created_detached: List[bool] = [] # The per-harness config the adapter built. Capturing it alongside neutral backend # arguments checks both sides of the composition boundary rather than one hop. self.created_configs: List[Any] = [] @@ -112,12 +113,14 @@ async def create_session( trace=None, run_context=None, session_id=None, + detached=False, effective_parameters=None, gateway_policy=None, ) -> _FakeSession: self.created_run_contexts.append(run_context) self.created_effective_parameters.append(effective_parameters) self.created_gateway_policies.append(gateway_policy) + self.created_detached.append(detached) self.created_configs.append(config) return _FakeSession(AgentResult(output=self._output, events=[], usage={})) @@ -143,6 +146,30 @@ def _params(harness="pi_core", *, model=None): return {"agent": template} +@pytest.mark.parametrize( + "flag, expected", [(True, True), (False, False), (None, False)] +) +async def test_invoke_detached_flag_reaches_the_backend_only_when_enabled( + flag, expected +): + backend = _FakeBackend() + handler = make_agent_handler( + AgentComposition( + select_backend=lambda template: backend, + resolve_connection=_no_connection, + ) + ) + flags = {} if flag is None else {"detached": flag} + + await handler( + request=WorkflowServiceRequest(flags=flags, session_id="session-1"), + messages=[{"role": "user", "content": "hi"}], + parameters=_params(), + ) + + assert backend.created_detached == [expected] + + # --------------------------------------------------------------------------- # # Drift 4: run_kind from `request.meta` must reach RunContext (not silently dropped) # --------------------------------------------------------------------------- # diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_dtos_agent_template.py b/sdks/python/oss/tests/pytest/unit/agents/test_dtos_agent_template.py index 7ad5cdd6a74..9c1c77c22bb 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_dtos_agent_template.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_dtos_agent_template.py @@ -14,6 +14,7 @@ from agenta.sdk.agents import ( AgentTemplate, + AgentTemplateShapeError, BuiltinToolConfig, InvalidPermissionDefaultError, ) @@ -281,3 +282,60 @@ def test_run_selection_honors_defaults(): config = AgentTemplate.from_params({}, defaults=defaults) assert config.harness == "claude" assert config.sandbox == "daytona" + + +def test_from_params_parses_sandbox_credentials(): + config = AgentTemplate.from_params( + { + "agent": { + "sandbox": { + "credentials": [ + { + "secret": {"slug": "github-token"}, + "binding": {"type": "env", "name": "GITHUB_TOKEN"}, + } + ] + } + } + } + ) + assert config.sandbox_credentials[0].secret.slug == "github-token" + assert config.sandbox_credentials[0].binding.name == "GITHUB_TOKEN" + + +def test_from_params_accepts_binding_without_type(): + config = AgentTemplate.from_params( + { + "agent": { + "sandbox": { + "credentials": [ + { + "secret": {"slug": "github-token"}, + "binding": {"name": "GITHUB_TOKEN"}, + } + ] + } + } + } + ) + assert config.sandbox_credentials[0].binding.type == "env" + assert config.sandbox_credentials[0].binding.name == "GITHUB_TOKEN" + + +def test_from_params_rejects_unknown_sandbox_credential_fields(): + with pytest.raises(AgentTemplateShapeError, match="sandbox.credentials is invalid"): + AgentTemplate.from_params( + { + "agent": { + "sandbox": { + "credentials": [ + { + "secret": {"slug": "x"}, + "binding": {"type": "env", "name": "X"}, + "value": "no", + } + ] + } + } + } + ) diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_harness_adapters.py b/sdks/python/oss/tests/pytest/unit/agents/test_harness_adapters.py index 1b5835069ec..a267e702b01 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_harness_adapters.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_harness_adapters.py @@ -20,11 +20,13 @@ HarnessKind, PiAgentTemplate, PiHarness, + ResolvedSandboxCredential, SessionConfig, ToolCallback, UnsupportedHarnessError, make_harness, ) +from agenta.sdk.agents.connections import EnvironmentCredentialBinding from agenta.sdk.agents.platform_instructions import ( AGENTA_PLATFORM_BASE, compose_platform_instructions, @@ -356,7 +358,16 @@ def test_platform_instructions_reach_every_harness(make_env, harness_cls, kind): The adapter emits one common string and keeps every author prompt field unchanged. """ harness = harness_cls(make_env(supported=[kind])) - config = _session_config(agent=_guidance_agent(), gateway_policy=_GATEWAY_POLICY) + config = _session_config( + agent=_guidance_agent(), + gateway_policy=_GATEWAY_POLICY, + sandbox_credentials=[ + ResolvedSandboxCredential( + binding=EnvironmentCredentialBinding(name="GITHUB_TOKEN"), + value="never-render-this-secret", + ) + ], + ) result = harness._to_harness_config(config) @@ -367,6 +378,12 @@ def test_platform_instructions_reach_every_harness(make_env, harness_cls, kind): # The configured integration names read as EXAMPLES, so a stale list stays honest. assert "github, slack" in instructions assert "Others may exist" in instructions + assert "`GITHUB_TOKEN`" in instructions + assert "never-render-this-secret" not in instructions + assert "Do not inspect or enumerate the environment" in instructions + assert ( + "do not request that secret again unless the user asks to retry" in instructions + ) assert result.agents_md == _AUTHOR_INSTRUCTIONS if isinstance(result, PiAgentTemplate): assert result.append_system == _AUTHOR_APPEND @@ -460,10 +477,17 @@ def test_gateway_guidance_is_absent_for_an_empty_policy(): def test_platform_instruction_composition_is_deterministic(): - expected = compose_platform_instructions(["github", "slack"]) - assert compose_platform_instructions(["slack", "github"]) == expected + expected = compose_platform_instructions( + ["github", "slack"], ["Z_TOKEN", "A_TOKEN"] + ) assert ( - expected == f"{AGENTA_PLATFORM_BASE}\n\n{gateway_guidance(['github', 'slack'])}" + compose_platform_instructions( + ["slack", "github"], ["A_TOKEN", "Z_TOKEN", "A_TOKEN"] + ) + == expected + ) + assert expected.index("`A_TOKEN`, `Z_TOKEN`") < expected.index( + "## Connected integrations" ) diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_redaction_scope.py b/sdks/python/oss/tests/pytest/unit/agents/test_redaction_scope.py index a7e438fccb9..bd9978ab54e 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_redaction_scope.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_redaction_scope.py @@ -97,6 +97,7 @@ async def create_session( trace=None, run_context=None, session_id=None, + detached=False, # Interface parity only; these tests assert on the redaction scope, not the wire. effective_parameters=None, gateway_policy=None, @@ -105,7 +106,13 @@ async def create_session( return _FakeSession(AgentResult(output=self._output, events=[], usage={})) -def _make_handler(secret: str, backend: _CapturingBackend): +def _make_handler( + secret: str, + backend: _CapturingBackend, + *, + binding_name: str = "OPENAI_API_KEY", + usage: str = "opaque_http", +): async def _resolve(*, model, context): return ResolvedConnection( provider="openai", @@ -114,9 +121,9 @@ async def _resolve(*, model, context): credential_mode="env", credentials=[ { - "binding": {"kind": "environment", "name": "OPENAI_API_KEY"}, + "binding": {"kind": "environment", "name": binding_name}, "value": secret, - "usage": "opaque_http", + "usage": usage, } ], endpoint={"base_url": "https://93.184.216.34/v1"}, @@ -147,6 +154,42 @@ def _knows(redactor: Redactor, secret: str) -> bool: return secret not in (redactor.redact_string(f"x {secret}", sink="test") or "") +async def test_path_credential_locator_does_not_enter_the_run_deny_set(): + path = "/run/secrets/service-account" + backend = _CapturingBackend() + + await _make_handler( + path, + backend, + binding_name="GOOGLE_APPLICATION_CREDENTIALS", + usage="local_use", + )( + request=WorkflowServiceRequest(), + messages=_messages(), + parameters=_params(), + ) + + assert not _knows(backend.captured_redactors[0], path) + + +async def test_pasted_credential_material_still_enters_the_run_deny_set(): + material = '{"private_key":"fake-private-key-DO-NOT-USE"}' + backend = _CapturingBackend() + + await _make_handler( + material, + backend, + binding_name="GOOGLE_APPLICATION_CREDENTIALS", + usage="local_use", + )( + request=WorkflowServiceRequest(), + messages=_messages(), + parameters=_params(), + ) + + assert _knows(backend.captured_redactors[0], material) + + # --------------------------------------------------------------------------- # # Sequential runs in ONE task/context: no accumulation, ambient restored # --------------------------------------------------------------------------- # diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_sandbox_credentials.py b/sdks/python/oss/tests/pytest/unit/agents/test_sandbox_credentials.py new file mode 100644 index 00000000000..975e9d59694 --- /dev/null +++ b/sdks/python/oss/tests/pytest/unit/agents/test_sandbox_credentials.py @@ -0,0 +1,96 @@ +from unittest.mock import AsyncMock + +import pytest + +from agenta.sdk.agents.connections import ResolvedConnection +from agenta.sdk.agents.dtos import SandboxCredentialConfig +from agenta.sdk.agents.sandbox_credentials import ( + RESERVED_SANDBOX_ENVIRONMENT_NAMES, + SandboxCredentialError, + resolve_sandbox_credentials, +) + + +def credential(slug="github-token", name="GITHUB_TOKEN"): + return SandboxCredentialConfig.model_validate( + {"secret": {"slug": slug}, "binding": {"type": "env", "name": name}} + ) + + +async def test_resolves_references_to_environment_wire_credentials(): + resolver = AsyncMock(return_value={"github-token": " token value "}) + result = await resolve_sandbox_credentials([credential()], resolver=resolver) + assert result[0].to_wire() == { + "binding": {"kind": "environment", "name": "GITHUB_TOKEN"}, + "value": " token value ", + } + resolver.assert_awaited_once_with(["github-token"]) + + +@pytest.mark.parametrize("name", ["9TOKEN", "BAD-NAME", "A B", ""]) +async def test_rejects_invalid_environment_names(name): + with pytest.raises(SandboxCredentialError): + await resolve_sandbox_credentials([credential(name=name)], resolver=AsyncMock()) + + +@pytest.mark.parametrize("name", sorted(RESERVED_SANDBOX_ENVIRONMENT_NAMES)) +async def test_rejects_reserved_environment_names(name): + with pytest.raises(SandboxCredentialError): + await resolve_sandbox_credentials([credential(name=name)], resolver=AsyncMock()) + + +async def test_resolution_is_all_or_nothing(): + resolver = AsyncMock(return_value={"one": "value"}) + with pytest.raises(SandboxCredentialError, match="1 configured"): + await resolve_sandbox_credentials( + [credential("one", "ONE"), credential("two", "TWO")], resolver=resolver + ) + + +async def test_rejects_duplicate_bindings_before_vault_read(): + resolver = AsyncMock() + with pytest.raises(SandboxCredentialError, match="duplicate"): + await resolve_sandbox_credentials( + [credential("one", "TOKEN"), credential("two", "TOKEN")], resolver=resolver + ) + resolver.assert_not_awaited() + + +async def test_rejects_collision_with_model_owned_environment(): + connection = ResolvedConnection( + provider="openai", + model="gpt", + credential_mode="none", + environment={"GITHUB_TOKEN": "public-config"}, + ) + with pytest.raises(SandboxCredentialError, match="already owned"): + await resolve_sandbox_credentials( + [credential()], resolved_connection=connection, resolver=AsyncMock() + ) + + +@pytest.mark.parametrize( + "name", + ["AGENTA_AGENT_FUTURE_CONTROL", "SANDBOX_AGENT_COMMAND", "PI_CODING_AGENT_FUTURE"], +) +async def test_rejects_runner_control_prefixes(name): + with pytest.raises(SandboxCredentialError, match="reserved by the runtime"): + await resolve_sandbox_credentials([credential(name=name)], resolver=AsyncMock()) + + +async def test_does_not_treat_mcp_http_headers_as_environment_collisions(): + from agenta.sdk.agents.mcp import MCPPolicy, ResolvedMCPServer + + server = ResolvedMCPServer( + name="server", + url="https://example.com/mcp", + headers={"Authorization": "public"}, + policy=MCPPolicy(), + ) + resolver = AsyncMock(return_value={"github-token": "secret"}) + resolved = await resolve_sandbox_credentials( + [credential(name="Authorization")], + mcp_servers=[server], + resolver=resolver, + ) + assert resolved[0].binding.name == "Authorization" diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py b/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py index a536213d1b7..75032ab5350 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py @@ -46,6 +46,8 @@ TraceContext, ) from agenta.sdk.agents.platform_instructions import compose_platform_instructions +from agenta.sdk.agents.connections import EnvironmentCredentialBinding +from agenta.sdk.agents.dtos import ResolvedSandboxCredential from agenta.sdk.agents.platform.gateway import _derived_tool_specs from agenta.sdk.agents.tools import ( CompiledTool, @@ -83,6 +85,7 @@ "harnessMode", "modelCapabilities", "modelConnection", + "sandboxCredentials", "messages", "context", "telemetry", @@ -100,6 +103,7 @@ "sandboxPermission", "harnessFiles", "turnId", + "detached", "projectId", "effectiveParameters", } @@ -284,6 +288,12 @@ def _codex_payload(): ], endpoint=Endpoint(base_url="https://api.openai.com/v1"), ), + sandbox_credentials=[ + ResolvedSandboxCredential( + binding=EnvironmentCredentialBinding(name="GITHUB_TOKEN"), + value="github-secret", + ) + ], ) return request_to_wire( harness=HarnessKind.CODEX, @@ -606,6 +616,27 @@ def test_request_to_wire_omits_turn_id_when_none(): assert "turnId" not in payload +def test_request_to_wire_carries_detached_only_for_a_session(): + detached = request_to_wire( + harness=HarnessKind.PI, + sandbox="local", + config=PiAgentTemplate(model="openai/gpt-5"), + messages=[], + session_id="sess-1", + detached=True, + ) + ad_hoc = request_to_wire( + harness=HarnessKind.PI, + sandbox="local", + config=PiAgentTemplate(model="openai/gpt-5"), + messages=[], + detached=True, + ) + + assert detached["detached"] is True + assert "detached" not in ad_hoc + + def test_request_to_wire_carries_project_id_when_set(): payload = request_to_wire( harness=HarnessKind.PI, diff --git a/sdks/python/oss/tests/pytest/unit/agents/tools/test_models.py b/sdks/python/oss/tests/pytest/unit/agents/tools/test_models.py index 1a0e05b10ad..ed6b71eb4a8 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/tools/test_models.py +++ b/sdks/python/oss/tests/pytest/unit/agents/tools/test_models.py @@ -22,9 +22,9 @@ def test_reference_tool_variant_call_ref_grammar(): ) # ref_by defaults to "variant". assert ReferenceToolConfig(slug="wf").ref_by == "variant" - # The model-visible name defaults to the slug when none is authored. + # The model-visible name IS the slug; a stored display name never reaches it (#6444). assert ReferenceToolConfig(slug="wf").tool_name == "wf" - assert ReferenceToolConfig(slug="wf", name="run").tool_name == "run" + assert ReferenceToolConfig(slug="wf", name="run").tool_name == "wf" def test_reference_tool_environment_call_ref_grammar(): diff --git a/sdks/python/oss/tests/pytest/unit/agents/tools/test_subagent_tool_name.py b/sdks/python/oss/tests/pytest/unit/agents/tools/test_subagent_tool_name.py index 514adee6f44..e886ff50100 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/tools/test_subagent_tool_name.py +++ b/sdks/python/oss/tests/pytest/unit/agents/tools/test_subagent_tool_name.py @@ -1,20 +1,25 @@ -"""Subagent tool names must satisfy the provider's tool-name pattern (E4). +"""A subagent's model-visible tool name is its workflow SLUG (E4, #6444). -THE BUG. A subagent's model-visible name is a DISPLAY name the user typed in the Subagents UI, and -it reached the provider verbatim. Every major provider requires `^[a-zA-Z0-9_.-]+$` for a tool -name and refuses the WHOLE `tools` array when any entry violates it, so adding a child called -"QA-v0.114.4 Helper" made every run of the parent fail with `Invalid 'tools[23].name'` until the -child was renamed. "Support Router" is an ordinary thing to type. +TWO BUGS, ONE DERIVATION. `ReferenceToolConfig.tool_name` used to return the display name the +author typed in the Subagents UI, and that name was wrong in two different ways. -The derivation itself is old — `ReferenceToolConfig.tool_name` has returned `self.name or -self.slug` since 2026-06-26 — but nothing put an authored display name in front of it until the -Subagents UI shipped, so the latent break became reachable. + IT WAS INVALID. Every major provider requires `^[a-zA-Z0-9_.-]+$` for a tool name and refuses + the WHOLE `tools` array when any entry violates it, so a child called "QA-v0.114.4 Helper" made + every run of the parent fail with `Invalid 'tools[23].name'`. + + IT WAS STALE. The name was a COPY, taken when the subagent was added, and renaming the child + never reached it. The model was told about an agent that no longer went by that name, and the + only cure was to remove the subagent and add it again. + +The slug fixes both at once: it already matches the provider pattern, and a rename never touches +it. Sanitizing stays, because a slug authored through the API rather than the UI need not match. Two properties are load-bearing beyond "it is valid now": STABILITY. The model sees this name. A name that changed between turns would strand a conversation mid-tool-call, so the mapping is deterministic and the collision discriminator is - derived from the tool's own identity rather than its position in the list. + derived from the tool's own identity rather than its position in the list. A rename during an + open conversation must not move it. DISTINCTNESS. Sanitizing can merge two different children onto one name, and a duplicate tool name silently SHADOWS the earlier tool rather than erroring — the second subagent would simply @@ -37,70 +42,74 @@ PROVIDER_TOOL_NAME = re.compile(r"^[a-zA-Z0-9_.-]+$") -@pytest.mark.parametrize( - "display_name", - [ - "QA-v0.114.4 Helper", # the live repro - "Support Router", # the ordinary case that bricks a parent - "Café Assistant", # non-ASCII - "billing/refunds", # a slash - "deploy (staging)", # brackets - " padded ", # leading and trailing space - "emoji 🚀 agent", # astral plane - "tabs\tand\nnewlines", - "a" * 200, # long, but every character legal - ], -) -def test_every_authored_display_name_produces_a_valid_tool_name(display_name): - name = ReferenceToolConfig(slug="wf", name=display_name).tool_name - assert PROVIDER_TOOL_NAME.match(name), f"{display_name!r} -> {name!r}" - - -def test_a_clean_name_passes_through_unchanged(): - # The common case must not be disfigured: a name already matching the pattern is the name. - for clean in ["summarizer", "Support_Router", "billing-v2", "agent.v1", "A1"]: - assert ReferenceToolConfig(slug="wf", name=clean).tool_name == clean - +class TestTheWireNameIsTheSlug: + def test_the_slug_is_the_tool_name(self): + assert ReferenceToolConfig(slug="support-router-k3f9").tool_name == ( + "support-router-k3f9" + ) -def test_the_spaced_repro_becomes_the_obvious_thing(): - assert ( - ReferenceToolConfig(slug="wf", name="QA-v0.114.4 Helper").tool_name - == "QA-v0.114.4_Helper" - ) - assert ( - ReferenceToolConfig(slug="wf", name="Support Router").tool_name - == "Support_Router" + @pytest.mark.parametrize( + "display_name", + [ + "QA-v0.114.4 Helper", # the live repro + "Support Router", # the ordinary case that bricked a parent + "Café Assistant", # non-ASCII + "billing/refunds", # a slash + "deploy (staging)", # brackets + "emoji 🚀 agent", # astral plane + "tabs\tand\nnewlines", + ], ) - - -def test_runs_of_disallowed_characters_collapse_to_one_separator(): - # "a___b" from "a b" would be noise; the model reads this name. - assert sanitize_tool_name("a b", fallback="wf") == "a_b" - assert sanitize_tool_name("a // b", fallback="wf") == "a_b" - - -def test_separators_are_trimmed_from_both_ends(): - assert sanitize_tool_name(" spaced ", fallback="wf") == "spaced" - assert sanitize_tool_name("...dots...", fallback="wf") == "dots" - assert sanitize_tool_name("---", fallback="wf") == "wf" - - -class TestFallback: - """A name that survives sanitization empty must still produce something callable.""" - - def test_a_symbol_only_name_falls_back_to_the_slug(self): - assert ReferenceToolConfig(slug="my-workflow", name="🚀🚀🚀").tool_name == ( - "my-workflow" - ) - assert ReferenceToolConfig(slug="my-workflow", name="///").tool_name == ( - "my-workflow" + def test_a_stored_display_name_cannot_reach_the_wire(self, display_name): + # Whatever a legacy configuration carries, the provider only ever sees the slug — so no + # authored name can produce `Invalid 'tools[N].name'` again. + config = ReferenceToolConfig(slug="wf", name=display_name) + assert config.tool_name == "wf" + assert PROVIDER_TOOL_NAME.match(config.tool_name) + + def test_renaming_the_child_does_not_move_the_name(self): + # The staleness bug and the stability property have the same answer: the slug. Two + # configs for the same child, saved either side of a rename, agree on the wire name. + before = ReferenceToolConfig(slug="helper-9f21", name="Helper One") + after = ReferenceToolConfig(slug="helper-9f21", name="Helper Two") + assert before.tool_name == after.tool_name == "helper-9f21" + + def test_a_slug_needing_sanitizing_is_sanitized(self): + # UI-made slugs already match the pattern; one authored through the API need not. + assert ReferenceToolConfig(slug="my workflow").tool_name == "my_workflow" + + def test_the_stored_name_itself_is_never_rewritten(self): + # Only the wire name is derived. Anything still reading `name` off a legacy config sees + # exactly what was saved. + config = ReferenceToolConfig(slug="wf", name="Support Router") + assert config.name == "Support Router" + + +class TestSanitizing: + """The pattern guard, exercised directly — `tool_name` is only its most important caller.""" + + def test_a_clean_name_passes_through_unchanged(self): + for clean in ["summarizer", "Support_Router", "billing-v2", "agent.v1", "A1"]: + assert sanitize_tool_name(clean, fallback="wf") == clean + + def test_the_spaced_repro_becomes_the_obvious_thing(self): + assert sanitize_tool_name("QA-v0.114.4 Helper", fallback="wf") == ( + "QA-v0.114.4_Helper" ) - def test_no_authored_name_uses_the_slug_as_before(self): - assert ReferenceToolConfig(slug="summarizer").tool_name == "summarizer" + def test_runs_of_disallowed_characters_collapse_to_one_separator(self): + # "a___b" from "a b" would be noise; the model reads this name. + assert sanitize_tool_name("a b", fallback="wf") == "a_b" + assert sanitize_tool_name("a // b", fallback="wf") == "a_b" - def test_a_slug_needing_sanitizing_is_sanitized_too(self): - assert sanitize_tool_name(None, fallback="my workflow") == "my_workflow" + def test_separators_are_trimmed_from_both_ends(self): + assert sanitize_tool_name(" spaced ", fallback="wf") == "spaced" + assert sanitize_tool_name("...dots...", fallback="wf") == "dots" + assert sanitize_tool_name("---", fallback="wf") == "wf" + + def test_a_symbol_only_input_falls_back(self): + assert sanitize_tool_name("🚀🚀🚀", fallback="my-workflow") == "my-workflow" + assert sanitize_tool_name("///", fallback="my-workflow") == "my-workflow" def test_a_last_resort_name_when_everything_sanitizes_empty(self): # Not reachable through the model today (`slug` has min_length=1 and is a slug), but the @@ -112,17 +121,17 @@ def test_a_last_resort_name_when_everything_sanitizes_empty(self): class TestCollisions: def test_two_names_that_sanitize_alike_stay_distinct(self): pairs = [ - ("workflow.variant.a", "Support_Router"), - ("workflow.variant.b", "Support_Router"), + ("workflow.variant.a", "support_router"), + ("workflow.variant.b", "support_router"), ] resolved = disambiguate_tool_names(pairs) assert resolved["workflow.variant.a"] != resolved["workflow.variant.b"] for name in resolved.values(): assert PROVIDER_TOOL_NAME.match(name) - assert name.startswith("Support_Router") + assert name.startswith("support_router") def test_a_name_with_no_collision_is_left_alone(self): - # Only the colliding names are decorated, so the common case keeps the name the user + # Only the colliding names are decorated, so the common case keeps the slug the user # recognizes from the UI. pairs = [ ("workflow.variant.a", "summarizer"), @@ -160,40 +169,30 @@ def test_an_unrelated_sibling_does_not_get_decorated_by_a_collision(self): assert resolved["workflow.variant.c"] == "unique" -def test_the_display_name_itself_is_never_rewritten(): - # Only the wire name changes. The UI, the config, and anything else reading `name` must still - # see exactly what the user typed. - config = ReferenceToolConfig(slug="wf", name="Support Router") - assert config.name == "Support Router" - assert config.tool_name == "Support_Router" - - class TestResolverInteraction: - """Sanitizing must not make the resolver reject a configuration that is actually fine.""" + """The declared-name pass must not reject a configuration that is actually fine.""" - def test_two_display_names_that_sanitize_alike_are_not_a_duplicate_error(self): + def test_two_slugs_that_sanitize_alike_are_not_a_duplicate_error(self): # The early declared-name pass runs BEFORE the adapter disambiguates, so it would see two - # `Support_Router` entries. Rejecting there would turn the fix into a different outage: + # `support_router` entries. Rejecting there would turn the fix into a different outage: # the user could no longer save the pair at all. from agenta.sdk.agents.tools.resolver import _validate_declared_config_names _validate_declared_config_names( [ - ReferenceToolConfig(slug="a", name="Support Router"), - ReferenceToolConfig(slug="b", name="Support/Router"), + ReferenceToolConfig(slug="support router"), + ReferenceToolConfig(slug="support/router"), ] ) def test_a_reference_tool_may_still_not_shadow_a_builtin(self): # The other half of that pass is about a custom tool silently replacing a harness - # built-in, which sanitizing does nothing to excuse. + # built-in, which deriving from the slug does nothing to excuse. from agenta.sdk.agents.tools.errors import ReservedToolNameError from agenta.sdk.agents.tools.resolver import _validate_declared_config_names with pytest.raises(ReservedToolNameError): - _validate_declared_config_names( - [ReferenceToolConfig(slug="wf", name="read")] - ) + _validate_declared_config_names([ReferenceToolConfig(slug="read")]) def test_a_genuine_duplicate_among_other_tool_kinds_still_raises(self): from agenta.sdk.agents.tools import ClientToolConfig diff --git a/sdks/python/oss/tests/pytest/unit/test_batch_fold_stream_contract_routing.py b/sdks/python/oss/tests/pytest/unit/test_batch_fold_stream_contract_routing.py index b5562e95a05..a5f7e22b4a3 100644 --- a/sdks/python/oss/tests/pytest/unit/test_batch_fold_stream_contract_routing.py +++ b/sdks/python/oss/tests/pytest/unit/test_batch_fold_stream_contract_routing.py @@ -140,6 +140,7 @@ async def create_session( trace=None, run_context=None, session_id=None, + detached=False, effective_parameters=None, gateway_policy=None, ) -> _FakeSession: diff --git a/sdks/python/oss/tests/pytest/unit/test_invoke_real_handlers_negotiation_routing.py b/sdks/python/oss/tests/pytest/unit/test_invoke_real_handlers_negotiation_routing.py index c177237ade8..edee6ae64ff 100644 --- a/sdks/python/oss/tests/pytest/unit/test_invoke_real_handlers_negotiation_routing.py +++ b/sdks/python/oss/tests/pytest/unit/test_invoke_real_handlers_negotiation_routing.py @@ -144,6 +144,7 @@ async def create_session( trace=None, run_context=None, session_id=None, + detached=False, effective_parameters=None, gateway_policy=None, ) -> _FakeSession: diff --git a/sdks/python/oss/tests/pytest/unit/test_redaction_seed.py b/sdks/python/oss/tests/pytest/unit/test_redaction_seed.py index bec282b4b5e..bbac2fed9ff 100644 --- a/sdks/python/oss/tests/pytest/unit/test_redaction_seed.py +++ b/sdks/python/oss/tests/pytest/unit/test_redaction_seed.py @@ -81,6 +81,18 @@ def test_default_prefix_list_is_empty_so_prefix_lookalikes_are_not_seeded( f"{name} must not be seeded with the default (empty) prefix list" ) + def test_google_application_credentials_path_is_not_seeded(self, monkeypatch): + path = "/run/secrets/service-account" + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", path) + values = curated_env_secret_values() + assert path not in values + + def test_google_application_credentials_material_is_still_seeded(self, monkeypatch): + material = '{"private_key":"fake-private-key-DO-NOT-USE"}' + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", material) + values = curated_env_secret_values() + assert material in values + def test_aws_bearer_token_bedrock_is_seeded_via_default_blocklist( self, monkeypatch ): diff --git a/sdks/python/oss/tests/pytest/unit/test_workflow_request_flags_running.py b/sdks/python/oss/tests/pytest/unit/test_workflow_request_flags_running.py index 5fe4a6c01f0..ddb3781db3a 100644 --- a/sdks/python/oss/tests/pytest/unit/test_workflow_request_flags_running.py +++ b/sdks/python/oss/tests/pytest/unit/test_workflow_request_flags_running.py @@ -51,7 +51,7 @@ def test_invoke_request_flags_dict_parses_via_accessor(): # `format` is HTTP-only, not a running-level flag (see test_workflow_format_routing.py) def test_format_is_not_a_request_flag(): - """format is http-only; the command flags are stream/trim/force/resolve.""" + """format is HTTP-only; detached is a running-level command flag.""" from agenta.sdk.models.workflows import WorkflowInvokeRequestFlags assert "format" not in WorkflowInvokeRequestFlags.model_fields @@ -60,4 +60,5 @@ def test_format_is_not_a_request_flag(): "trim", "force", "resolve", + "detached", } diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml index 4c2caf7ad91..9ad4713f066 100644 --- a/sdks/python/pyproject.toml +++ b/sdks/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agenta" -version = "0.114.4" +version = "0.115.1" description = "Agenta is the open-source workspace for your agents. Build agents through chat, improve them with feedback, and share them with your team." readme = "README.md" requires-python = ">=3.11,<3.14" diff --git a/sdks/python/uv.lock b/sdks/python/uv.lock index 703970e659e..1c39c691c98 100644 --- a/sdks/python/uv.lock +++ b/sdks/python/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.11, <3.14" [[package]] name = "agenta" -version = "0.114.4" +version = "0.115.1" source = { editable = "." } dependencies = [ { name = "agenta-client" }, @@ -85,7 +85,7 @@ dev = [ [[package]] name = "agenta-client" -version = "0.114.4" +version = "0.115.1" source = { editable = "../../clients/python" } dependencies = [ { name = "httpx" }, diff --git a/services/oss/tests/pytest/unit/agent/conftest.py b/services/oss/tests/pytest/unit/agent/conftest.py index 18309dd1366..bab173b7d82 100644 --- a/services/oss/tests/pytest/unit/agent/conftest.py +++ b/services/oss/tests/pytest/unit/agent/conftest.py @@ -101,6 +101,7 @@ def __init__( self.created_session_ids: list[Optional[str]] = [] self.created_secrets: list[Optional[Mapping[str, str]]] = [] self.created_run_contexts: list = [] + self.created_detached: list = [] async def setup(self) -> None: self.setup_calls += 1 @@ -121,6 +122,7 @@ async def create_session( trace=None, run_context=None, session_id=None, + detached=False, # Interface parity: the SDK passes this through on every session run. These tests # assert on the config and run context, not on the stamped parameters. effective_parameters=None, @@ -130,6 +132,7 @@ async def create_session( self.created_session_ids.append(session_id) self.created_secrets.append(secrets) self.created_run_contexts.append(run_context) + self.created_detached.append(detached) return _FakeSession(self._result) diff --git a/services/oss/tests/pytest/unit/agent/test_invoke_handler.py b/services/oss/tests/pytest/unit/agent/test_invoke_handler.py index bd9fafcbf7d..e7532a61405 100644 --- a/services/oss/tests/pytest/unit/agent/test_invoke_handler.py +++ b/services/oss/tests/pytest/unit/agent/test_invoke_handler.py @@ -44,15 +44,19 @@ def _first_allowed_provider(harness): return HARNESS_CONNECTION_CAPABILITIES[harness].providers[0] -def _request(*, stream=None, session_id=None): +def _request(*, stream=None, session_id=None, detached=None): """Build the request `_agent` reads stream/session_id off of. `_agent` now sources the stream decision from `request.flags.stream` and the session id from `request.session_id` (both set at the route/normalizer edge), instead of receiving them as handler params. """ - flags = {"stream": stream} if stream is not None else None - return WorkflowServiceRequest(flags=flags, session_id=session_id) + flags = {} + if stream is not None: + flags["stream"] = stream + if detached is not None: + flags["detached"] = detached + return WorkflowServiceRequest(flags=flags or None, session_id=session_id) def _patch_handler(monkeypatch, backend, *, tool_specs=(), tool_callback=None): @@ -247,6 +251,24 @@ async def test_messages_session_id_reaches_session_config(patched): ) assert backend.created_session_ids == ["sess_request"] + assert backend.created_detached == [False] + + +async def test_detached_flag_reaches_the_backend_session(patched): + """The shared-delivery flag rides the same edge as the session id. + + Nothing else in the service asserts it at this boundary, so a handler that stopped + forwarding `flags.detached` would still pass every other invoke test. + """ + backend, _ = patched + + await app._agent( + request=_request(session_id="sess_detached", detached=True), + messages=[{"role": "user", "content": "hi"}], + parameters={"agent": {"harness": {"kind": "pi_core"}}}, + ) + + assert backend.created_detached == [True] async def test_invoke_cross_harness_same_body_divergent_configs( diff --git a/services/pyproject.toml b/services/pyproject.toml index 254c64cf9f3..c077b94cebf 100644 --- a/services/pyproject.toml +++ b/services/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "services" -version = "0.114.4" +version = "0.115.1" description = "Agenta Services (Chat & Completion)" requires-python = ">=3.11,<3.14" authors = [ diff --git a/services/runner/patches/sandbox-agent@0.4.2.patch b/services/runner/patches/sandbox-agent@0.4.2.patch index 610ead15d1e..b69a898d7f0 100644 --- a/services/runner/patches/sandbox-agent@0.4.2.patch +++ b/services/runner/patches/sandbox-agent@0.4.2.patch @@ -1,5 +1,5 @@ diff --git a/dist/chunk-TVCDKGSM.js b/dist/chunk-TVCDKGSM.js -index 29a0a22210d39ae9d886c0ccd7059cd9af0e26f0..7d4585271c700e6222d24bd391604e7007e81b99 100644 +index 29a0a22210d39ae9d886c0ccd7059cd9af0e26f0..60becacde92447d50c0d29609953b93be245d40d 100644 --- a/dist/chunk-TVCDKGSM.js +++ b/dist/chunk-TVCDKGSM.js @@ -738,6 +738,7 @@ var LiveAcpConnection = class _LiveAcpConnection { @@ -123,7 +123,18 @@ index 29a0a22210d39ae9d886c0ccd7059cd9af0e26f0..7d4585271c700e6222d24bd391604e70 const updated = { ...existing, agentSessionId: recreated.sessionId, -@@ -2504,6 +2545,25 @@ function normalizeSessionInit(value, cwdShorthand, providerDefaultCwd) { +@@ -1363,6 +1404,10 @@ var SandboxAgent = class _SandboxAgent { + } + return this.createSession(request); + } ++ async cancelSession(id) { ++ this.cancelPendingPermissionsForSession(id); ++ await this.sendSessionMethodInternal(id, SESSION_CANCEL_METHOD, {}, {}, true); ++ } + async destroySession(id) { + this.cancelPendingPermissionsForSession(id); + try { +@@ -2504,6 +2549,25 @@ function normalizeSessionInit(value, cwdShorthand, providerDefaultCwd) { mcpServers: value.mcpServers ?? [] }; } @@ -149,6 +160,18 @@ index 29a0a22210d39ae9d886c0ccd7059cd9af0e26f0..7d4585271c700e6222d24bd391604e70 function mapSessionParams(params, agentSessionId) { return { ...params, +diff --git a/dist/index.d.ts b/dist/index.d.ts +index e67d588032a085d28adc82252199e389722b86d2..c75a8efa3ad663abc4497e6853c0b97835549cf8 100644 +--- a/dist/index.d.ts ++++ b/dist/index.d.ts +@@ -3174,6 +3174,7 @@ declare class SandboxAgent { + createSession(request: SessionCreateRequest): Promise; + resumeSession(id: string): Promise; + resumeOrCreateSession(request: SessionResumeOrCreateRequest): Promise; ++ cancelSession(id: string): Promise; + destroySession(id: string): Promise; + setSessionMode(sessionId: string, modeId: string): Promise<{ + session: Session; diff --git a/dist/providers/local.js b/dist/providers/local.js index 3e68d70c340ded6b2f99cf3142e951b804a4a8a2..103851397d0f575f103644002a94ab46306034d5 100644 --- a/dist/providers/local.js diff --git a/services/runner/pnpm-lock.yaml b/services/runner/pnpm-lock.yaml index a6ddf79637c..9e58cdd64b1 100644 --- a/services/runner/pnpm-lock.yaml +++ b/services/runner/pnpm-lock.yaml @@ -23,7 +23,7 @@ patchedDependencies: hash: e30d9db3a9981a7f844a83795d7ceef6d86919eede6e2adf8a9bf0024d5ff49c path: patches/pi-acp@0.0.29.patch sandbox-agent@0.4.2: - hash: ade0985e7ab79fab885a4cd818c0790d5cf319730931bd0a049775c7fbdeb7a4 + hash: 91ccdae91dc68390329197a105ecd8cf394f680dd3be2a26731ebe1c2bd58c3f path: patches/sandbox-agent@0.4.2.patch importers: @@ -74,7 +74,7 @@ importers: version: 0.0.29(patch_hash=e30d9db3a9981a7f844a83795d7ceef6d86919eede6e2adf8a9bf0024d5ff49c) sandbox-agent: specifier: 0.4.2 - version: 0.4.2(patch_hash=ade0985e7ab79fab885a4cd818c0790d5cf319730931bd0a049775c7fbdeb7a4)(@daytona/sdk@0.198.0(ws@8.21.0))(zod@4.4.3) + version: 0.4.2(patch_hash=91ccdae91dc68390329197a105ecd8cf394f680dd3be2a26731ebe1c2bd58c3f)(@daytona/sdk@0.198.0(ws@8.21.0))(zod@4.4.3) undici: specifier: 8.9.0 version: 8.9.0 @@ -4985,7 +4985,7 @@ snapshots: safer-buffer@2.1.2: {} - sandbox-agent@0.4.2(patch_hash=ade0985e7ab79fab885a4cd818c0790d5cf319730931bd0a049775c7fbdeb7a4)(@daytona/sdk@0.198.0(ws@8.21.0))(zod@4.4.3): + sandbox-agent@0.4.2(patch_hash=91ccdae91dc68390329197a105ecd8cf394f680dd3be2a26731ebe1c2bd58c3f)(@daytona/sdk@0.198.0(ws@8.21.0))(zod@4.4.3): dependencies: '@sandbox-agent/cli-shared': 0.4.2 acp-http-client: 0.4.2(patch_hash=a673c410af2021d9bb5f05c899522b66e6bcbe67134a92f506f0aef23fcf090d)(zod@4.4.3) diff --git a/services/runner/src/engines/sandbox_agent/acp-fetch.ts b/services/runner/src/engines/sandbox_agent/acp-fetch.ts index dcee85d9b4d..e3df15b4b7c 100644 --- a/services/runner/src/engines/sandbox_agent/acp-fetch.ts +++ b/services/runner/src/engines/sandbox_agent/acp-fetch.ts @@ -1,5 +1,7 @@ import { Agent, fetch as undiciFetch } from "undici"; +import { sandboxGoneReason } from "./sandbox-gone.ts"; + /** * HITL pauses keep the ACP HTTP connection open for human-timescale delays: when a tool call needs * approval, the runner holds the in-flight `prompt` request while it waits for the human to @@ -52,12 +54,50 @@ export function createAcpDispatcher(): Agent { }); } +export interface AcpFetchOptions { + /** + * Called when a response proves the sandbox is gone (see `sandbox-gone.ts`). + * + * This fetch is the socket the turn runs on, so it sees a deleted remote sandbox seconds before + * any poll can. It cannot end the turn itself: the ACP transport swallows the failure (it errors + * its readable, and the protocol SDK's read loop never rejects the pending `session/prompt`), so + * the promise the turn awaits stays pending forever. Reporting it here is what lets the liveness + * probe end the turn at once instead of one probe interval later, or never. + */ + onSandboxGone?: (reason: string) => void; +} + +/** + * Wrap a `fetch` so a response that names the sandbox as gone is reported once. + * + * The response is passed through untouched, body included: this wrapper reads only the status and + * the headers, because draining the body here would break every caller. With no + * `onSandboxGone` it is the identity, so nothing is inspected on a path that cannot act on it. + */ +export function withSandboxGoneReport( + inner: typeof fetch, + options: AcpFetchOptions = {}, +): typeof fetch { + const report = options.onSandboxGone; + if (!report) return inner; + return (async (input: any, init?: any) => { + const response = await inner(input, init); + const reason = sandboxGoneReason(response); + if (reason) report(reason); + return response; + }) as unknown as typeof fetch; +} + /** * A `fetch` for the ACP HTTP client backed by {@link createAcpDispatcher}. We use undici's own * `fetch` so the `dispatcher` option is honored regardless of how the global dispatcher is set. * The `sandbox-agent` SDK accepts a custom `fetch`; we hand it this one on every path. */ -export function createAcpFetch(dispatcher: Agent = createAcpDispatcher()): typeof fetch { - return ((input: any, init?: any) => +export function createAcpFetch( + dispatcher: Agent = createAcpDispatcher(), + options: AcpFetchOptions = {}, +): typeof fetch { + const bound = ((input: any, init?: any) => undiciFetch(input, { ...init, dispatcher })) as unknown as typeof fetch; + return withSandboxGoneReport(bound, options); } diff --git a/services/runner/src/engines/sandbox_agent/agent-mount.ts b/services/runner/src/engines/sandbox_agent/agent-mount.ts index 41ca4767ec8..7a63378cacb 100644 --- a/services/runner/src/engines/sandbox_agent/agent-mount.ts +++ b/services/runner/src/engines/sandbox_agent/agent-mount.ts @@ -16,6 +16,7 @@ import { type SandboxExec, type SignMountDeps, } from "./mount.ts"; +import { throwIfAcquireAborted } from "../../environment/acquire-abort.ts"; export const AGENT_MOUNT_ENV_VAR = "AGENTA_AGENT_MOUNT_DIR"; export const AGENT_README_NAME = "README.md"; @@ -47,6 +48,10 @@ export async function signAgentMountCredentials( ): Promise { const log = deps.log ?? defaultLog; const doFetch = deps.fetchImpl ?? fetch; + const timeoutSignal = AbortSignal.timeout(10_000); + const signal = deps.signal + ? AbortSignal.any([deps.signal, timeoutSignal]) + : timeoutSignal; const url = `${deps.apiBase}/mounts/agents/sign?artifact_id=${encodeURIComponent(artifactId)}&name=${encodeURIComponent(name)}`; try { const res = await doFetch(url, { @@ -57,7 +62,7 @@ export async function signAgentMountCredentials( }, // Bound the sign so a hung endpoint fails open (null mount) instead of // stalling environment acquisition on the agent mount forever. - signal: AbortSignal.timeout(10_000), + signal, }); if (!res.ok) { log( @@ -98,6 +103,7 @@ export async function signAgentMountCredentials( : undefined, }; } catch (err) { + throwIfAcquireAborted(deps.signal); log( `sign failed artifact=${artifactId}: ${String(err instanceof Error ? err.message : err).slice(0, 160)}`, ); diff --git a/services/runner/src/engines/sandbox_agent/cancel-turn.ts b/services/runner/src/engines/sandbox_agent/cancel-turn.ts new file mode 100644 index 00000000000..72e773adb33 --- /dev/null +++ b/services/runner/src/engines/sandbox_agent/cancel-turn.ts @@ -0,0 +1,155 @@ +/** + * Cancel the harness turn so the sandbox can be PARKED instead of deleted. + * + * WHAT THIS FIXES. A user Stop aborts the run signal. The turn then ends with + * `stopReason: "cancelled"`, and `shouldPark` used to answer `false` for every aborted run, so + * the sandbox was deleted and the next message paid a cold start. The abort alone never told the + * harness anything: it only made the runner stop waiting. The harness kept its prompt open, + * possibly with a tool still running, and the only thing that ever stopped it was the teardown + * that was already deleting the sandbox. + * + * WHAT THIS DOES INSTEAD. Send the ACP `session/cancel` notification for the live session, then + * wait a bounded time for the harness to answer the open `session/prompt`. ACP requires the agent + * to end that prompt with `stopReason: "cancelled"` after a cancel, so a settled prompt promise is + * the harness saying "I am idle again". Only a settled cancel may park. A cancel that cannot be + * sent, or that the harness never answers in time, leaves the environment in an unknown state, and + * unknown means delete. + * + * WHY THE CLIENT NEEDS A PATCH. `sandbox-agent`'s `SandboxAgent` refuses a manual `session/cancel` + * ("Manual session/cancel calls are not allowed. Use destroySession(sessionId) instead."). The + * guard is in the TypeScript client only; the daemon inside the sandbox proxies ACP and holds no + * such rule. The existing pnpm patch adds `cancelSession(id)`, which sends the same managed cancel + * `destroySession` sends but does NOT mark the session record destroyed. The `?.` below keeps this + * module honest against an unpatched client: no method, no clean cancel, no park. + * + * WHY IT DOES NOT ABORT THE ENVIRONMENT'S MCP CONTROLLER. `env.mcpAbort` belongs to the + * ENVIRONMENT, not the turn. Aborting it kills the tool-MCP server for every later turn, which is + * exactly what a parked environment must keep. The approval-park path already skips it for the + * same reason (see `run-turn.ts`, the `approvalParkMode` early return). The turn's own tool relay + * is stopped separately, and a teardown that does happen still aborts the controller through + * `teardownRuntimeInFlight`. + */ + +import { envTimerMs } from "../../env.ts"; + +export const CANCEL_SETTLE_TIMEOUT_ENV = + "AGENTA_RUNNER_HARNESS_CANCEL_SETTLE_MS"; + +/** + * How long to wait for the harness to answer the cancelled prompt. + * + * Ten seconds is a starting value, not a measured one. It has to cover the adapter aborting the + * tool it is running and writing its partial turn, and it has to stay well under the user's + * patience for a second message. Raise it only with a measurement that shows a harness needing + * more; every extra second is a second the Stop looks unfinished. + */ +export const DEFAULT_CANCEL_SETTLE_MS = 10_000; + +export interface CancelHarnessTurnInput { + /** The live sandbox client. `cancelSession` is absent on an unpatched `sandbox-agent`. */ + sandbox: { cancelSession?: (id: string) => Promise } | undefined; + /** The harness session id to cancel. */ + sessionId: string | undefined; + /** The still-open `session/prompt` promise for this turn. */ + promptPromise: Promise | undefined; + timeoutMs?: number; + log: (message: string) => void; + /** Test seam. Defaults to a real timer. */ + wait?: (ms: number) => Promise; + now?: () => number; +} + +export interface CancelHarnessTurnResult { + /** True only when the cancel was sent AND the harness answered the prompt in time. */ + settled: boolean; + /** True when the cancel notification left the runner, whatever the harness did next. */ + requested: boolean; + /** Milliseconds from sending the cancel to the harness answering, when it answered. */ + elapsedMs: number; +} + +export function resolveCancelSettleMs(): number { + return envTimerMs(CANCEL_SETTLE_TIMEOUT_ENV, DEFAULT_CANCEL_SETTLE_MS, { + min: 1, + }); +} + +/** + * Ask the harness to stop the current prompt and wait for it to say it did. + * + * Never throws. Every failure answers `settled: false`, which the caller reads as "destroy". + */ +export async function cancelHarnessTurn( + input: CancelHarnessTurnInput, +): Promise { + const unsettled = { settled: false, requested: false, elapsedMs: 0 }; + const cancelSession = input.sandbox?.cancelSession; + if (!cancelSession || !input.sessionId || !input.promptPromise) { + input.log( + "stage=harness_cancel sent=false reason=" + + (!cancelSession + ? "client-has-no-cancelSession" + : !input.sessionId + ? "no-session" + : "no-open-prompt"), + ); + return unsettled; + } + + const now = input.now ?? (() => Date.now()); + const startedAt = now(); + const timeoutMs = input.timeoutMs ?? resolveCancelSettleMs(); + const wait = + input.wait ?? + ((ms: number) => + new Promise((resolve) => { + const handle = setTimeout(resolve, ms); + handle.unref?.(); + })); + const TIMED_OUT = Symbol("cancel-settle-timeout"); + + try { + const requested = await Promise.race([ + cancelSession.call(input.sandbox, input.sessionId).then(() => true), + wait(timeoutMs).then(() => TIMED_OUT), + ]); + if (requested === TIMED_OUT) { + input.log( + `stage=harness_cancel sent=false reason=request-timeout budget_ms=${timeoutMs}`, + ); + return unsettled; + } + } catch (error) { + input.log( + "stage=harness_cancel sent=false error=" + + (error instanceof Error ? error.message : String(error)).slice(0, 160), + ); + return unsettled; + } + + // A RESOLVED prompt is the harness reporting its own `stopReason`. A REJECTED one means the + // prompt died on the transport instead, which says nothing about whether the harness stopped, + // so it counts as unsettled and the environment is destroyed. + const settledOk = await Promise.race([ + input.promptPromise.then( + () => true, + () => false, + ), + wait(timeoutMs).then(() => TIMED_OUT), + ]); + const elapsedMs = now() - startedAt; + + if (settledOk === true) { + input.log( + `stage=harness_cancel sent=true settled=true elapsed_ms=${elapsedMs}`, + ); + return { settled: true, requested: true, elapsedMs }; + } + input.log( + `stage=harness_cancel sent=true settled=false elapsed_ms=${elapsedMs} ` + + (settledOk === TIMED_OUT + ? `reason=timeout budget_ms=${timeoutMs}` + : "reason=prompt-rejected"), + ); + return { settled: false, requested: true, elapsedMs }; +} diff --git a/services/runner/src/engines/sandbox_agent/credential-preflight.ts b/services/runner/src/engines/sandbox_agent/credential-preflight.ts index f628985f05d..6f9facba853 100644 --- a/services/runner/src/engines/sandbox_agent/credential-preflight.ts +++ b/services/runner/src/engines/sandbox_agent/credential-preflight.ts @@ -11,31 +11,60 @@ * fresh sandboxes (target eu); production showed ~3% over an earlier window, so the rate * varies. Waiting therefore cannot help; only a fresh sandbox can. * - * THE MECHANISM. Right after the sandbox is created, probe the credential's own endpoint - * from INSIDE the sandbox: POST `${baseUrl}/chat/completions` with the key env var as the - * bearer. Several consecutive raw-placeholder echoes convict the sandbox as STUCK; any other - * response means the header was substituted (a 400 for the junk body, a real 401 for a - * genuinely bad key) and the run may proceed. The preflight runs CONCURRENTLY with the rest - * of acquire (mounts, workspace, session open, ~10s), so a healthy sandbox pays nothing. - * - * ONLY A MASKED ECHO CONVICTS, AND THAT DISTINCTION IS THE WHOLE INSTRUMENT. A bare `dtn_` - * in the body proves nothing: Daytona's egress proxy also SCRUBS responses, rewriting real - * credential values back into `dtn_secret_` before they reach the sandbox. So an endpoint - * that echoes the Authorization header verbatim returns the full placeholder shape on a - * perfectly HEALTHY sandbox, and convicting on that would destroy both acquire attempts and - * fail a first turn whose real model call would have worked. What scrubbing cannot forge is a - * MASKED placeholder: a provider masks the key it received (LiteLLM's - * "Virtual Key expected. Received=dtn_****", OpenAI's "Incorrect API key provided: - * dtn_secr*****"), and a masked string no longer contains the real value for the scrubber to - * match — so a masked `dtn_` can only mean the raw placeholder really went out. This is the - * same correction that invalidated the first probe run; see the "CORRECTED" section of - * `docs/design/daytona-secret-propagation/README.md`. Every incident observed in production - * and in the 20-sandbox probe carried a masked echo, so the narrower signature costs no - * detection. - * - * WHAT A "STUCK" VERDICT DOES. The acquire path destroys the environment and retries ONCE - * with a brand-new sandbox, because the twin experiment proved a new sandbox on the same - * Secret works. The user sees a slower first turn instead of a failed one. + * THE MECHANISM. Right after the sandbox is created, call the credential's own endpoint from + * INSIDE the sandbox with the key env var as the credential. Several consecutive placeholder + * readings convict the sandbox as STUCK; anything else means the header was substituted, or + * means nothing this module can judge, and the run proceeds. The preflight runs CONCURRENTLY + * with the rest of acquire (mounts, workspace, session open, ~10s), so a healthy sandbox pays + * nothing. + * + * TWO INSTRUMENTS READ THAT PROBE, BECAUSE ONE OF THEM IS BLIND ON HALF THE PROVIDERS. + * + * 1. THE MASKED ECHO, for a provider that quotes back what it received. A bare `dtn_` in the + * body proves nothing: Daytona's egress proxy also SCRUBS responses, rewriting real credential + * values back into `dtn_secret_` before they reach the sandbox. So an endpoint that echoes + * the Authorization header verbatim returns the full placeholder shape on a perfectly HEALTHY + * sandbox, and convicting on that would destroy both acquire attempts and fail a first turn + * whose real model call would have worked. What scrubbing cannot forge is a MASKED placeholder: + * a provider masks the key it received (LiteLLM's "Virtual Key expected. Received=dtn_****", + * OpenAI's "Incorrect API key provided: dtn_secr*****"), and a masked string no longer contains + * the real value for the scrubber to match — so a masked `dtn_` can only mean the raw + * placeholder really went out. This is the same correction that invalidated the first probe + * run; see the "CORRECTED" section of `docs/design/daytona-secret-propagation/README.md`. + * + * 2. THE DIFFERENTIAL, for a provider that echoes nothing. OpenRouter answers a bad bearer with + * a plain 401 that never names the key, and so do Anthropic and OpenAI on their auth endpoints. + * Instrument 1 sees nothing there and fails open, so every stuck sandbox on those connections + * became a failed first turn: 10 user-visible failures on the direct OpenRouter connection in + * production over 2026-09-01..02 (AGE-4249). The runner itself holds the real key at this + * point, because it created the Secret from it. So it calls the SAME auth endpoint itself, + * concurrently with the sandbox probe, and the pair of answers is the reading. + * + * WHAT THE DIFFERENTIAL ACTUALLY MEASURES, STATED PLAINLY. It is a difference between two + * REQUEST CONTEXTS — one request from inside the sandbox, one from the runner process — and it + * attributes that difference to the credential. That attribution is an assumption, not a proof. + * Any other environment difference between the two contexts would convict a healthy sandbox: + * an IP allowlist on the provider account, a WAF or bot rule that treats the sandbox's egress + * range differently, a regional block, a provider-side per-source throttle. Three things bound + * the damage. Only a positive, documented auth answer counts as accepted (HTTP 200 from a + * non-generation auth endpoint), so an environment difference that produces anything else is + * unknown and fails open. A wrong verdict costs a rebuild, not a failed run, because acquire + * retries with a fresh sandbox and the retry ladder is bounded. And the rule applies only to + * the three direct providers whose auth endpoint is documented and pinned below; every other + * connection, custom gateways included, keeps masked-echo-only conviction. + * + * ONLY A NON-GENERATION AUTH ENDPOINT, READ BY STATUS ALONE. Each differential provider has a + * documented endpoint whose entire job is to answer whether a key is good: OpenRouter's + * `GET /key`, Anthropic's `GET /v1/models`, OpenAI's `GET /models`. 200 means accepted, 401 + * means the key is refused, and every other status — 403, 3xx, 404, 405, 429, 5xx, a timeout — + * is unknown and fails open. Reading the status of a purpose-built endpoint, rather than + * inferring acceptance from "not a refusal" on a generation endpoint, is what keeps a + * misrouted or rate-limited call from being read as proof that a key works. + * + * WHAT A "STUCK" VERDICT DOES. The acquire path destroys the environment and retries with a + * brand-new sandbox built on the SAME Daytona Secret, because that is exactly what the twin + * experiment proved works. The Secret is kept across the rebuild; see `acquireEnvironment`. + * The user sees a slower first turn instead of a failed one. * * THE GRACE IS 10 SECONDS, A DELIBERATE CHOICE BELOW DAYTONA'S ~30s BOUND. Their support * (2026-08-31, confirming our report) said a sandbox may still start working within ~30s @@ -47,24 +76,50 @@ * ever shows up in the preflight logs (it would log "substitution confirmed after N * probes" with N > 1). * - * SCOPE. Only a freshly created Daytona sandbox whose MODEL credential rides a Daytona - * Secret and whose connection declares an endpoint base URL (the custom OpenAI-compatible - * shape — every observed incident). A plaintext-env run has no placeholder; a reconnected - * sandbox already proved itself. + * AND IT IS ONE HARD DEADLINE. Every curl timeout, every exec timeout, every poll, and the + * runner's own call are capped by what is left of the grace; a probe is never started once the + * grace is gone; and a poll that would land exactly on the deadline convicts instead of + * sleeping. The runner's call carries its own timer as well, because its abort signal alone + * fires only when the preflight has already stopped waiting, and a provider that never answers + * would otherwise hold the whole acquire open. + * + * SCOPE. Only a freshly created Daytona sandbox whose MODEL credential rides a Daytona Secret + * and whose connection declares an endpoint base URL. Within that, the differential covers + * exactly three direct providers (openrouter, anthropic, openai) AT THEIR CANONICAL BASE URL — + * `deployment: "direct"` does not by itself say which host the connection points at, so the + * base URL is compared against a pinned table (`CANONICAL_DIRECT_BASE_URLS`). Every other + * connection — a custom gateway such as the LiteLLM credits proxy, a self-hosted gateway + * labelled with a known provider family, and any direct provider not named above — keeps the + * OpenAI-compatible chat probe and masked-echo-only conviction, which is what has been + * convicting stuck sandboxes on the credits proxy since this module shipped. Gemini is not + * covered at all: its auth rides a query parameter rather than a header, so it needs its own + * shape. A plaintext-env run has no placeholder; a reconnected sandbox already proved itself. * - * AMBIGUITY FAILS OPEN. A probe that errors, returns nothing judgeable, or returns an - * UNMASKED placeholder-shaped echo returns "ok" and the run proceeds: the worst outcome is + * THE KEY VALUE NEVER LEAVES THIS MODULE. The sandbox command carries the env var name, which + * the sandbox shell expands, and the runner's own call carries the value in a request header + * only. Every log line goes through a redactor, so no future message can leak it either. + * + * AMBIGUITY FAILS OPEN. Any of these returns "ok" and the run proceeds: a probe that errors, a + * body with nothing judgeable in it, an UNMASKED placeholder-shaped echo, a sandbox status that + * is not 401, a sandbox 401 on a connection without a differential shape, and a runner call + * that was refused, errored, timed out, or answered any status but 200. The worst outcome is * the pre-existing behavior, classified honestly by `classifyRunError`. Only consecutive, - * unambiguous masked raw-placeholder echoes convict. + * unambiguous readings convict: a masked raw-placeholder echo, or a sandbox 401 beside a + * runner call that the provider's own auth endpoint answered with 200. */ +import { + throwIfAcquireAborted, + waitForAcquire, +} from "../../environment/acquire-abort.ts"; + /** * Does this acquire deliver the run's MODEL credential as a Daytona Secret? * * The condition the preflight gates on, minus the endpoint — and the condition that arms the * classifier's credential-race reading. Those two must not drift: the preflight can only SEE the - * race where the provider echoes what it received, but the race EXISTS wherever a model key rides - * a Secret on a fresh sandbox. Naming it once keeps that difference deliberate instead of + * race on a provider whose shape it knows, but the race EXISTS wherever a model key rides a + * Secret on a fresh sandbox. Naming it once keeps that difference deliberate instead of * accidental. * * A reconnect is excluded because the sandbox already proved itself, a local run because there is @@ -92,6 +147,26 @@ export interface PreflightSandbox { }): Promise<{ exitCode?: number | null; stdout: string } | undefined>; } +/** One call the runner makes itself, with the real key, to the provider's auth endpoint. */ +export interface ControlProbeRequest { + method: "GET" | "POST"; + url: string; + headers: Record; + body?: string; + timeoutMs: number; + /** Aborted as soon as the preflight stops needing the answer. */ + signal: AbortSignal; +} + +/** What that call answered. `status` is absent when nothing could be read. */ +export interface ControlProbeResponse { + status?: number; +} + +export type ControlProbe = ( + request: ControlProbeRequest, +) => Promise; + /** The preflight's answer: proceed, or this sandbox will never substitute. */ export type CredentialPreflightVerdict = "ok" | "stuck"; @@ -99,36 +174,72 @@ export type CredentialPreflightVerdict = "ok" | "stuck"; export class SubstitutionStuckError extends Error { constructor() { super( - "This sandbox never received its credential-substitution wiring (raw placeholder " + - "echoed on every probe); a fresh sandbox is required.", + "This sandbox never received its credential-substitution wiring: every probe from " + + "inside it either echoed the raw placeholder back or was refused by the provider " + + "while the same key succeeded from the runner. A fresh sandbox is required.", ); this.name = "SubstitutionStuckError"; } } -/** Total acquire attempts when a sandbox is convicted stuck: the original plus one retry. */ -export const STUCK_ACQUIRE_ATTEMPTS = 2; +/** + * Total acquire attempts when a sandbox is convicted stuck: the original plus two rebuilds. + * + * RAISED FROM 2 TO 3 (production runner logs, 2026-09-01..02). The single retry was stuck again + * in 4 of 7 observed rebuilds, and every one of those rebuilds also churned the Secret, which is + * the real defect (see `acquireEnvironment`). A rebuild on the SAME Secret is the case Daytona + * support confirmed works, so the extra attempt is a cheap safety net behind that fix rather than + * a substitute for it. Each attempt costs one sandbox create, so the ceiling stays low. + * + * WHAT THE 4/7 DOES NOT SAY. Every one of those seven rebuilds changed BOTH the sandbox and the + * Secret, so the number measures new-sandbox-plus-new-Secret retries. It is not an estimate of + * how often a same-Secret rebuild is still stuck, and nobody has that number yet. 3 is therefore + * a temporary safety net, not a tuned value. Re-measure it from the + * "rebuilding fresh on the same Secret (attempt N/M)" lines once the fix has run in production, + * and lower it to 2 if the second attempt almost always succeeds. + */ +export const STUCK_ACQUIRE_ATTEMPTS = 3; export interface CredentialPreflightInput { sandbox: PreflightSandbox; - /** The custom connection's endpoint base URL (`modelConnection.endpoint.baseUrl`). */ + /** The connection's endpoint base URL (`modelConnection.endpoint.baseUrl`). */ baseUrl: string; /** The env var name holding the key in the sandbox (the Secret's placeholder). */ apiKeyVar: string; + /** The connection's provider family (`modelConnection.provider`). */ + provider?: string; + /** How that provider is reached (`modelConnection.deployment`): `direct`, `custom`, ... */ + deployment?: string; + /** + * The real key value, used ONLY as the runner call's credential header. + * + * Without it the differential is unavailable and a bare 401 fails open, which is the + * behavior that shipped before the differential existed. + */ + controlKey?: string; log: (message: string) => void; /** Total budget from first probe to giving up (fail open). */ budgetMs?: number; /** Delay between probes. */ pollMs?: number; - /** Injectable clock/sleep for tests. */ + /** The acquire's cancellation signal; aborts the runner's own call with the run. */ + signal?: AbortSignal; + /** Injectable clock/sleep/transport for tests. */ now?: () => number; sleep?: (ms: number) => Promise; + controlProbe?: ControlProbe; + fetchImpl?: typeof fetch; } /** See the module doc: 10s, deliberately below Daytona's ~30s keep-or-recreate bound. */ const DEFAULT_BUDGET_MS = 10_000; const DEFAULT_POLL_MS = 2_000; -const CURL_TIMEOUT_S = 8; +const PROBE_TIMEOUT_S = 8; +const CONTROL_TIMEOUT_MS = 8_000; +/** Pinned by Anthropic's API, which refuses a versionless call. */ +const ANTHROPIC_VERSION = "2023-06-01"; +/** A shell would expand anything else in the command string. */ +const SHELL_SAFE_ENV_VAR = /^[A-Za-z_][A-Za-z0-9_]*$/; /** * The only echo that proves the raw placeholder went out: one the provider MASKED. @@ -148,79 +259,496 @@ const CURL_TIMEOUT_S = 8; const MASKED_PLACEHOLDER_ECHO = /dtn_[A-Za-z0-9_-]*\*|virtual key expected.*received=\s*dtn_/i; +/** + * The request this preflight sends, in the shape the provider understands. + * + * `buildHeaders` takes the credential and returns the headers, so the same shape builds the + * sandbox command (which gets a shell expansion of the env var) and the runner's own request + * (which gets the real value). `differential` says whether a bare 401 from the sandbox may be + * judged against the runner's answer; see the module doc for why only three providers set it. + */ +interface ProbeShape { + method: "GET" | "POST"; + url: string; + buildHeaders: (credential: string) => Record; + body?: string; + differential: boolean; +} + +/** + * The chat probe: a POST of an empty JSON body to the OpenAI-compatible chat path. + * + * The default for every connection without a documented auth endpoint here. An auth-first + * gateway answers it without running a model, and the LiteLLM credits proxy answers it with the + * masked refusal that has been convicting stuck sandboxes since this module shipped. It carries + * no differential: a 401 from a generation endpoint has too many causes to attribute. + */ +function chatProbeShape(base: string): ProbeShape { + return { + method: "POST", + url: `${base}/chat/completions`, + buildHeaders: (credential) => ({ + "Content-Type": "application/json", + Authorization: `Bearer ${credential}`, + }), + body: "{}", + differential: false, + }; +} + +/** + * The one base URL each differential provider is reached at. + * + * `deployment: "direct"` is NOT enough on its own to know the host. A vault custom-provider + * record naming a known provider family is labelled `direct` by the resolver while keeping its + * own configured URL (`sdks/python/agenta/sdk/agents/platform/connections.py`, pinned by + * `test_known_direct_custom_provider_uses_direct_deployment`), and an explicit endpoint always + * overrides the family default (`endpoints.py`). Without this table the preflight would send + * the real key to a tenant gateway's `/models` and read whatever it answered as a verdict. + */ +const CANONICAL_DIRECT_BASE_URLS: Record = { + openrouter: "https://openrouter.ai/api/v1", + anthropic: "https://api.anthropic.com", + openai: "https://api.openai.com/v1", +}; + +/** + * Reduce a base URL to scheme, host and path for an exact comparison, or return undefined. + * + * Undefined for anything unparseable, and for a URL carrying a query, a fragment, or embedded + * credentials: those parts would be lost by the comparison while still being part of the real + * request, so a URL that has them is simply not one of the canonical bases. + * + * The query and fragment are rejected on the RAW string, not on `url.search` and `url.hash`. + * Those two are empty strings for a URL ending in a bare `?` or `#`, so reading them would let + * `https://api.openai.com/v1?` through as canonical while the connection's real requests carry + * that character. A `?` or a `#` anywhere in the input is enough to disqualify it. + */ +function normalizeBaseUrl(baseUrl: string): string | undefined { + const trimmed = baseUrl.trim(); + if (trimmed.includes("?") || trimmed.includes("#")) return undefined; + let url: URL; + try { + url = new URL(trimmed); + } catch { + return undefined; + } + if (url.username || url.password) return undefined; + const path = url.pathname.replace(/\/+$/, ""); + return `${url.protocol.toLowerCase()}//${url.host.toLowerCase()}${path}`; +} + +/** + * Pick the request shape from the connection's provider family, how it is reached, and its host. + * + * A differential shape needs all three to agree: a known provider, a `direct` deployment, and a + * base URL that IS that provider's canonical one. The URL check is the load-bearing part, for + * the reason spelled out on `CANONICAL_DIRECT_BASE_URLS`. When it matches, the probe URL is + * built from the canonical base rather than the request's spelling, so the two are the same + * request by construction. + */ +function probeShapeFor( + baseUrl: string, + provider?: string, + deployment?: string, +): ProbeShape { + const family = provider?.trim().toLowerCase() ?? ""; + const canonical = CANONICAL_DIRECT_BASE_URLS[family]; + if ( + deployment?.trim().toLowerCase() !== "direct" || + !canonical || + normalizeBaseUrl(baseUrl) !== canonical + ) { + return chatProbeShape(baseUrl.replace(/\/+$/, "")); + } + switch (family) { + case "openrouter": + // OpenRouter's documented "get current key" endpoint. + return { + method: "GET", + url: `${canonical}/key`, + buildHeaders: (credential) => ({ + Authorization: `Bearer ${credential}`, + }), + differential: true, + }; + case "anthropic": + // `limit=1` so the answer stays small; the status is all this reads. + return { + method: "GET", + url: `${canonical}/v1/models?limit=1`, + buildHeaders: (credential) => ({ + "x-api-key": credential, + "anthropic-version": ANTHROPIC_VERSION, + }), + differential: true, + }; + default: + return { + method: "GET", + url: `${canonical}/models`, + buildHeaders: (credential) => ({ + Authorization: `Bearer ${credential}`, + }), + differential: true, + }; + } +} + +/** + * The curl the sandbox runs. `-w` appends the HTTP status on its own line, which is the + * differential's whole input: the body alone cannot tell a refusal from a substituted call on a + * provider that echoes nothing. The env var is expanded by the sandbox shell, so the key value + * never appears in any runner-side string. + */ +function sandboxProbeScript( + shape: ProbeShape, + apiKeyVar: string, + timeoutSeconds: number, +): string { + // Headers stay DOUBLE-quoted on purpose: `$VAR` has to reach the shell unquoted enough to + // expand, because expanding it is how the key stays out of this string. Their names and + // values are this module's own constants plus a binding name already checked against + // `SHELL_SAFE_ENV_VAR`, so nothing here is caller-shaped. + const headers = Object.entries(shape.buildHeaders(`$${apiKeyVar}`)) + .map(([name, value]) => `-H "${name}: ${value}" `) + .join(""); + const method = shape.method === "POST" ? "-X POST " : ""; + const body = shape.body === undefined ? "" : `-d ${shellQuote(shape.body)} `; + return ( + `curl -s -m ${timeoutSeconds} -w '\\n%{http_code}' ` + + method + + headers + + body + + shellQuote(shape.url) + ); +} + +/** + * Quote one operand for `sh -c`. + * + * `JSON.stringify` is not shell quoting. Inside double quotes a shell still expands `$VAR`, + * `$(...)` and a backtick, and the URL is built from a base URL the request supplies. A base + * URL carrying any of those would be rewritten before curl ever saw it, so the probe would + * call some other host and this instrument would go silently blind while still reporting a + * verdict. Single quotes suppress every expansion; a single quote inside the value is closed, + * escaped, and reopened. + */ +function shellQuote(value: string): string { + return `'${value.split("'").join(`'\\''`)}'`; +} + +/** Split curl's output into the response body and the status `-w` appended. */ +export function parseCurlProbeOutput(stdout: string | undefined): { + body: string; + status?: number; +} { + if (!stdout) return { body: "" }; + const lastNewline = stdout.lastIndexOf("\n"); + if (lastNewline < 0) return { body: stdout }; + const tail = stdout.slice(lastNewline + 1).trim(); + if (!/^\d{3}$/.test(tail)) return { body: stdout }; + const status = Number(tail); + // curl writes 000 when the request never completed. That is not a provider answer. + if (status === 0) return { body: stdout.slice(0, lastNewline) }; + return { body: stdout.slice(0, lastNewline), status }; +} + +/** What the runner's own call to the provider's auth endpoint said about the key. */ +type RunnerAuthProbeResult = + | { kind: "accepted"; status: number } + | { kind: "refused"; status: number } + | { kind: "unknown"; detail: string }; + +/** + * Read the auth endpoint's answer. Only a 200 is acceptance and only a 401 is refusal; every + * other status is a different fact about the request, not about the key. See the module doc. + */ +function readRunnerAuthStatus( + status: number | undefined, +): RunnerAuthProbeResult { + if (status === undefined) return { kind: "unknown", detail: "no status" }; + if (status === 200) return { kind: "accepted", status }; + if (status === 401) return { kind: "refused", status }; + return { kind: "unknown", detail: `HTTP ${status}` }; +} + +/** + * The runner's own call, over `fetch`. + * + * `redirect: "error"` so a 3xx is never followed: the auth endpoint is pinned, and a redirect + * would send the real key to whatever host the response named. The body is cancelled once the + * status is read, because nothing here reads it and an unread body holds the connection. + */ +function createFetchControlProbe(fetchImpl: typeof fetch): ControlProbe { + return async (request) => { + // `request.signal` alone cannot end a call the provider never answers: nothing fires it + // until the preflight is already done waiting. Without this timer an unanswered fetch + // holds `await control` open past the grace and past the preflight's own cleanup. + const timeout = new AbortController(); + const timer = setTimeout( + () => timeout.abort(new Error("runner auth call timed out")), + request.timeoutMs, + ); + try { + const response = await fetchImpl(request.url, { + method: request.method, + headers: request.headers, + ...(request.body === undefined ? {} : { body: request.body }), + redirect: "error", + signal: AbortSignal.any([request.signal, timeout.signal]), + }); + await response.body?.cancel().catch(() => {}); + return { status: response.status }; + } finally { + clearTimeout(timer); + } + }; +} + /** * Resolve "ok" when the sandbox's model credential substitutes on the wire (or nothing can be - * judged — fail open), and "stuck" after enough consecutive raw-placeholder echoes. Never - * throws. + * judged — fail open), and "stuck" after enough consecutive placeholder readings. Never throws. */ export async function awaitCredentialSubstitution( input: CredentialPreflightInput, ): Promise { + const signal = input.signal; + throwIfAcquireAborted(signal); const now = input.now ?? Date.now; const sleep = input.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); const budgetMs = input.budgetMs ?? DEFAULT_BUDGET_MS; const pollMs = input.pollMs ?? DEFAULT_POLL_MS; - const url = `${input.baseUrl.replace(/\/+$/, "")}/chat/completions`; - // The env var is expanded by the sandbox shell, so the placeholder value never appears in - // any runner-side string. `-d "{}"` makes an auth-first endpoint answer without a model call. - const script = - `curl -s -m ${CURL_TIMEOUT_S} -X POST ` + - `-H "Content-Type: application/json" ` + - `-H "Authorization: Bearer $${input.apiKeyVar}" ` + - `-d "{}" ${JSON.stringify(url)}`; + // Last line of defence: no message this module writes may carry the key, whatever a future + // edit or a provider error string puts in it. Any non-empty value is redacted. + const controlKey = input.controlKey; + const log = (message: string) => + input.log( + controlKey ? message.split(controlKey).join("[redacted]") : message, + ); + + // The env var name is interpolated into a shell command, so it must be a name and nothing + // else. A value that is not one is a programming error upstream, not a stuck sandbox. + if (!SHELL_SAFE_ENV_VAR.test(input.apiKeyVar)) { + log( + `[credential-preflight] refusing to probe: the credential's binding name is not a ` + + `shell-safe environment variable name; proceeding`, + ); + return "ok"; + } + const shape = probeShapeFor(input.baseUrl, input.provider, input.deployment); const startedAt = now(); - for (let attempt = 1; ; attempt++) { - let body: string | undefined; - try { - const result = await input.sandbox.runProcess({ - command: "sh", - args: ["-c", script], - timeoutMs: (CURL_TIMEOUT_S + 4) * 1000, - }); - body = result?.stdout; - } catch (error) { - // The exec channel itself failed (sandbox tearing down, daemon hiccup): fail open. - input.log( - `[credential-preflight] probe errored, proceeding: ${String( - error instanceof Error ? error.message : error, - ).slice(0, 120)}`, + const deadlineAt = startedAt + budgetMs; + const remainingMs = () => Math.max(0, deadlineAt - now()); + + // ONE controller for the runner's call, aborted in the `finally` below so every exit path + // (verdict, throw, caller cancellation) releases it. Composed with the acquire's own signal + // so a cancelled run does not leave a request in flight. + const controlAbort = new AbortController(); + const controlSignal = input.signal + ? AbortSignal.any([input.signal, controlAbort.signal]) + : controlAbort.signal; + + // Started HERE so it runs concurrently with probe 1, and awaited only when a bare 401 makes + // it the deciding evidence. One call per preflight, and none at all on a connection whose + // shape carries no differential. It never rejects, so an unawaited rejection cannot escape + // when the sandbox answers cleanly. + // + // The remaining-time check is part of the gate, not an optimization. This runs BEFORE the + // loop's own deadline check, so without it a preflight with no grace left would still send + // the real key to the provider for an answer nothing would ever read. + const control: Promise | undefined = + controlKey && shape.differential && remainingMs() > 0 + ? ( + input.controlProbe ?? + createFetchControlProbe(input.fetchImpl ?? fetch) + )({ + method: shape.method, + url: shape.url, + headers: shape.buildHeaders(controlKey), + ...(shape.body === undefined ? {} : { body: shape.body }), + // The same one deadline the sandbox probes answer to. No floor: a caller who gives + // the preflight almost no budget gets almost no wait, not a fixed second of one. + timeoutMs: Math.min(CONTROL_TIMEOUT_MS, remainingMs()), + signal: controlSignal, + }).then( + (response) => readRunnerAuthStatus(response.status), + (error): RunnerAuthProbeResult => ({ + kind: "unknown", + detail: String( + error instanceof Error ? error.message : error, + ).slice(0, 120), + }), + ) + : undefined; + + try { + for (let attempt = 1; ; attempt++) { + const leftMs = remainingMs(); + if (leftMs <= 0) { + // The grace is gone and nothing has convicted. Starting another probe would spend + // time the caller did not give, so the answer is the fail-open one. + log( + `[credential-preflight] grace spent after ${attempt - 1} probes with no verdict; ` + + `proceeding`, + ); + return "ok"; + } + // Every probe is capped by what is left of the one deadline, so a slow sandbox cannot + // spend more than the grace no matter how long its exec channel takes to answer. The + // floor is one second because `curl -m 0` means no timeout at all, not an instant one. + const probeSeconds = Math.max( + 1, + Math.min(PROBE_TIMEOUT_S, Math.floor(leftMs / 1000)), ); - return "ok"; - } - const elapsedMs = now() - startedAt; - if (!body || !MASKED_PLACEHOLDER_ECHO.test(body)) { - // Substituted, or the endpoint gave nothing this preflight can judge by — fail open - // either way. An unmasked placeholder shape lands here on purpose: scrubbing produces - // it from a healthy key too, so it is not evidence. Log it, because a stuck sandbox - // behind an echoing endpoint now passes the preflight and surfaces as the 401 instead. - if (body?.includes("dtn_")) { - input.log( - `[credential-preflight] unmasked placeholder-shaped echo (probe ${attempt}, ` + - `+${(elapsedMs / 1000).toFixed(1)}s): scrubbing produces this from a REAL key ` + - `too, so it convicts nothing; proceeding`, + const script = sandboxProbeScript(shape, input.apiKeyVar, probeSeconds); + let stdout: string | undefined; + try { + const result = await waitForAcquire( + () => + input.sandbox.runProcess({ + command: "sh", + args: ["-c", script], + // Capped by the same deadline. The exec channel gets its usual slack over curl's + // own ceiling only while the grace can pay for it. + timeoutMs: Math.max( + 1, + Math.min((probeSeconds + 4) * 1000, leftMs), + ), + }), + signal, ); - } else if (attempt > 1) { - input.log( - `[credential-preflight] substitution confirmed after ${attempt} probes ` + - `(${(elapsedMs / 1000).toFixed(1)}s)`, + stdout = result?.stdout; + } catch (error) { + throwIfAcquireAborted(signal); + // The exec channel itself failed (sandbox tearing down, daemon hiccup): fail open. + log( + `[credential-preflight] probe errored, proceeding: ${String( + error instanceof Error ? error.message : error, + ).slice(0, 120)}`, ); + return "ok"; } - return "ok"; - } - if (elapsedMs + pollMs > budgetMs) { - input.log( - `[credential-preflight] STUCK: raw placeholder on all ${attempt} probes ` + - `(${(elapsedMs / 1000).toFixed(1)}s); this sandbox will never substitute`, + const { body, status } = parseCurlProbeOutput(stdout); + const masked = MASKED_PLACEHOLDER_ECHO.test(body); + // Awaited BEFORE the clock is read, so a slow runner call spends the same budget every + // other step spends. Otherwise a call that took most of the grace would leave the loop + // thinking it still had time for several more probes. + const reading = + !masked && status === 401 && control ? await control : undefined; + const elapsedMs = now() - startedAt; + const elapsed = (elapsedMs / 1000).toFixed(1); + + // Evidence that the raw placeholder went out. Each instrument words its own two lines: + // the per-probe line while the grace still runs, and the conviction line at the end. + let evidence: { probeLine: string; stuckLine: string } | undefined; + if (masked) { + evidence = { + probeLine: `raw placeholder echoed (probe ${attempt}, +${elapsed}s)`, + stuckLine: `STUCK: raw placeholder on all ${attempt} probes (${elapsed}s)`, + }; + } else if (status === 401 && shape.differential) { + // The provider refused without naming what it received. Only the runner's own call to + // the same auth endpoint can say whether the key is bad or the delivery is. + if (!reading) { + log( + `[credential-preflight] sandbox answered 401 with no echo (probe ${attempt}, ` + + `+${elapsed}s) and the runner holds no key to check it against; proceeding`, + ); + return "ok"; + } + if (reading.kind === "refused") { + log( + `[credential-preflight] sandbox answered 401 with no echo (probe ${attempt}, ` + + `+${elapsed}s), and the provider's auth endpoint refused the same key from ` + + `the runner (HTTP ${reading.status}): the key itself is being rejected, not ` + + `its delivery; proceeding`, + ); + return "ok"; + } + if (reading.kind === "unknown") { + log( + `[credential-preflight] sandbox answered 401 with no echo (probe ${attempt}, ` + + `+${elapsed}s), but the runner's own call to the auth endpoint gave no ` + + `verdict (${reading.detail}); proceeding`, + ); + return "ok"; + } + evidence = { + probeLine: + `bare 401 with no echo (probe ${attempt}, +${elapsed}s); the provider's auth ` + + `endpoint accepted the same key from the runner (HTTP ${reading.status})`, + stuckLine: + `STUCK: bare 401 with no echo on all ${attempt} probes (${elapsed}s) while ` + + `the provider's auth endpoint accepted the same key from the runner ` + + `(HTTP ${reading.status})`, + }; + } + + if (!evidence) { + // Substituted, or the endpoint gave nothing this preflight can judge by — fail open + // either way. An unmasked placeholder shape lands here on purpose: scrubbing produces + // it from a healthy key too, so it is not evidence. Log it, because a stuck sandbox + // behind an echoing endpoint now passes the preflight and surfaces as the 401 instead. + if (body.includes("dtn_")) { + log( + `[credential-preflight] unmasked placeholder-shaped echo (probe ${attempt}, ` + + `+${elapsed}s): scrubbing produces this from a REAL key ` + + `too, so it convicts nothing; proceeding`, + ); + } else if (attempt > 1) { + log( + `[credential-preflight] substitution confirmed after ${attempt} probes ` + + `(${elapsed}s)`, + ); + } + return "ok"; + } + // `>=`, not `>`: sleeping exactly onto the deadline and then starting another probe + // spends time the grace does not have. A poll that would land on the deadline convicts. + if (elapsedMs + pollMs >= budgetMs) { + log( + `[credential-preflight] ${evidence.stuckLine}; this sandbox will never substitute`, + ); + return "stuck"; + } + log(`[credential-preflight] ${evidence.probeLine}`); + await waitForAcquire( + () => sleep(Math.min(pollMs, remainingMs())), + signal, ); - return "stuck"; } - input.log( - `[credential-preflight] raw placeholder echoed (probe ${attempt}, ` + - `+${(elapsedMs / 1000).toFixed(1)}s)`, - ); - await sleep(pollMs); + } finally { + // Nothing reads the runner's call after this point, on any exit path. + controlAbort.abort(); } } + +/** + * Build the preflight's input from the acquire path's pieces. + * + * It exists so the kickoff's wiring is testable: `acquireEnvironment` cannot be driven without a + * live provider, and the one property worth pinning is that the candidate's real value has + * exactly one destination, the runner's own call. Everything the sandbox sees is a variable + * name. + */ +export function buildCredentialPreflightInput(input: { + baseUrl: string; + candidate: { binding: { name: string }; value: string }; + provider?: string; + deployment?: string; +}): Pick< + CredentialPreflightInput, + "baseUrl" | "apiKeyVar" | "provider" | "deployment" | "controlKey" +> { + return { + baseUrl: input.baseUrl.trim(), + apiKeyVar: input.candidate.binding.name, + ...(input.provider ? { provider: input.provider } : {}), + ...(input.deployment ? { deployment: input.deployment } : {}), + controlKey: input.candidate.value, + }; +} diff --git a/services/runner/src/engines/sandbox_agent/daytona-secret-provider.ts b/services/runner/src/engines/sandbox_agent/daytona-secret-provider.ts index 0e6336cec0f..57a679df417 100644 --- a/services/runner/src/engines/sandbox_agent/daytona-secret-provider.ts +++ b/services/runner/src/engines/sandbox_agent/daytona-secret-provider.ts @@ -8,6 +8,7 @@ import { DaytonaReconnectTerminalError } from "./daytona-provider.ts"; import { planSlotKeys, type DaytonaSecretPlan } from "./daytona-secret-plan.ts"; import { allocateDaytonaSecrets, + DaytonaSecretLease, deleteDaytonaSecrets, isDaytonaNotFound, type DaytonaSecretAllocation, @@ -23,7 +24,8 @@ export interface DaytonaProviderLike { } interface RegistryEntry { - allocation: DaytonaSecretAllocation; + /** The claim on this sandbox's Secrets. `attached` for as long as the entry lives. */ + lease: DaytonaSecretLease; plan: DaytonaSecretPlan; createFingerprint: string; generation: number; @@ -35,6 +37,20 @@ export interface ProcessLocalDaytonaSecretProvider extends DaytonaProviderLike { materializeMcpServers( servers: McpServerConfig[] | undefined, ): McpServerConfig[] | undefined; + /** + * Keep THIS sandbox's Secrets when it is destroyed, rather than deleting them. + * + * Called by the acquire path when the credential preflight convicts the sandbox. The destroy + * runs through the sandbox-agent handle, so the intent cannot ride on the destroy call itself. + * It is keyed by sandbox id so it can only ever affect the cleanup of the named sandbox, and + * the key is consumed by that cleanup. + * + * Takes either id shape: the raw provider id, or the sandbox-agent handle's + * `"/"`, which is the one the acquire path holds. See `namesSandbox`. + */ + retainSecretsOnDestroy(sandboxId: string): void; + /** The lease a retained destroy handed back. Returned once; undefined if nothing was kept. */ + takeSecretLease(): DaytonaSecretLease | undefined; /** * How a rotated credential reaches THIS sandbox without rebuilding it. * @@ -58,6 +74,15 @@ export interface ProcessLocalSecretDependencies { createFingerprint: string; /** Capability override for the delivery port. See `DaytonaCredentialDeliveryDeps`. */ credentialCapabilities?: CredentialDeliveryCapabilities; + /** + * A detached lease on Secrets an earlier sandbox of this run kept. `create` mounts them as-is. + * + * This is how a sandbox convicted by the credential preflight is rebuilt on the SAME Secret, + * which is the case Daytona support confirmed works. With no inherited lease, `create` allocates + * a fresh one, exactly as it always did. A lease this run cannot use, because its slot set does + * not match the plan or because it is no longer detached, is released and replaced. + */ + inheritedLease?: DaytonaSecretLease; cleanupDelayMilliseconds: number; setCleanupTimer?: typeof setTimeout; clearCleanupTimer?: typeof clearTimeout; @@ -66,6 +91,24 @@ export interface ProcessLocalSecretDependencies { const processLocalRegistry = new Map(); +/** The name this facade reports, and therefore the prefix in the handle's `"/"`. */ +const PROVIDER_NAME = "daytona"; + +/** + * Whether `requested` names the sandbox a cleanup is about. + * + * TWO ID SHAPES, ONE SANDBOX. This registry is keyed by the RAW id the provider returned from + * create, but the sandbox-agent handle exposes `"/"` and that prefixed form is + * what the runner stores, reconnects with, and therefore has in hand. Accepting both is what lets + * `retainSecretsOnDestroy` be called with the id the caller actually holds, instead of making it + * reach into the vendored client for a private raw-id field. + */ +function namesSandbox(requested: string, sandboxId: string): boolean { + return ( + requested === sandboxId || requested === `${PROVIDER_NAME}/${sandboxId}` + ); +} + function plansMatch(entry: RegistryEntry, createFingerprint: string): boolean { return entry.createFingerprint === createFingerprint; } @@ -178,17 +221,84 @@ export function daytonaWithProcessLocalSecrets( const cancel = dependencies.clearCleanupTimer ?? clearTimeout; const log = dependencies.log ?? (() => {}); const createFingerprint = dependencies.createFingerprint; + const inheritedLease = dependencies.inheritedLease; let provider: T | undefined; let currentAllocation: DaytonaSecretAllocation | undefined; // The sandbox the current allocation belongs to, so the delivery port can name the environment // it serializes on. Cleared wherever the allocation is. let currentSandboxId: string | undefined; + // The sandbox whose Secrets the next cleanup must keep. See `retainSecretsOnDestroy`. + let retainSecretsForSandbox: string | undefined; + let retainedLease: DaytonaSecretLease | undefined; const providerFor = (attachments: Record): T => { provider ??= buildProvider(attachments); return provider; }; + /** + * Drop any pending cleanup for this entry, and invalidate one that already fired. + * + * CALLED TWICE BY EVERY LIFECYCLE OPERATION: once before it queues, and again after it owns the + * serialized lock. The second call is not redundant, and the bug it closes is easy to miss. A + * cleanup that was already running when the operation queued clears its own timer handle before + * it awaits the destroy, so the pre-lock call sees nothing to cancel. If that destroy then + * fails, the cleanup arms a REPLACEMENT timer while the operation is still waiting. Cancelling + * again here, where nothing else can arm behind us, is what keeps that replacement from later + * destroying a sandbox this operation just reconnected or parked. + * + * The generation bump invalidates a callback that fired but has not entered its own serialized + * section yet; cancelling alone cannot reach one of those. + */ + const dropPendingCleanup = (entry: RegistryEntry): void => { + if (entry.cleanupTimer) { + cancel(entry.cleanupTimer); + entry.cleanupTimer = undefined; + } + entry.generation += 1; + }; + + /** + * Give a failed cleanup a later attempt, instead of forgetting the sandbox and its Secrets. + * + * A cleanup can fail in two places and both used to end the same way: the caller swallowed the + * rejection, the environment was already marked destroyed and dropped from the in-flight map, + * and nothing was left holding the pair. The registry entry survives either failure, so the + * same timer the park path uses can run the whole cleanup again. Reuses the entry's generation + * counter, so a later explicit destroy, pause, or reconnect cancels this retry and wins. + */ + const armCleanupRetry = ( + sandboxId: string, + entry: RegistryEntry, + activeProvider: T, + reason: string, + error: unknown, + ): void => { + log( + `process-local Daytona cleanup failed sandbox=${sandboxId} reason=${reason}: ` + + `${String(error instanceof Error ? error.message : error).slice(0, 200)}; ` + + `retrying in ${dependencies.cleanupDelayMilliseconds}ms`, + ); + if (entry.cleanupTimer) return; + entry.generation += 1; + const scheduledGeneration = entry.generation; + entry.cleanupTimer = schedule(() => { + void serialize(entry, async () => { + if ( + registry.get(sandboxId) !== entry || + entry.generation !== scheduledGeneration + ) { + return; + } + entry.cleanupTimer = undefined; + await cleanupAfterSandbox(sandboxId, entry, activeProvider); + }).catch(() => { + // The retry arms its own next attempt, so there is nothing to add here. + }); + }, dependencies.cleanupDelayMilliseconds); + entry.cleanupTimer.unref?.(); + }; + const cleanupAfterSandbox = async ( sandboxId: string, entry: RegistryEntry, @@ -196,31 +306,88 @@ export function daytonaWithProcessLocalSecrets( ): Promise => { // A Secret remains mounted until Daytona confirms the sandbox is absent. Never reverse this // order, including timer cleanup and create compensation after an id was returned. - await destroySandboxIdempotently(activeProvider, sandboxId); - await deleteDaytonaSecrets(entry.allocation, api, log); + try { + await destroySandboxIdempotently(activeProvider, sandboxId); + } catch (error) { + // Absence is UNPROVEN, so the Secrets stay and the lease stays attached: the sandbox may + // still be running with them mounted. Keep the entry and try the whole cleanup again. + armCleanupRetry(sandboxId, entry, activeProvider, "destroy", error); + throw error; + } + // The sandbox is gone, so the lease no longer belongs to it either way. What differs is who + // deletes: a retained cleanup hands the detached lease to the caller, and every other cleanup + // releases it here. + entry.lease.detach(); + if ( + retainSecretsForSandbox !== undefined && + namesSandbox(retainSecretsForSandbox, sandboxId) + ) { + retainSecretsForSandbox = undefined; + retainedLease = entry.lease; + } else { + try { + await entry.lease.release(); + } catch (error) { + // The sandbox is confirmed gone and the lease is detached, so it is still releasable. + // Keep the entry so the timer can retry the delete; the retried destroy answers 404 and + // is treated as success, which leaves the release as the only work left to do. + armCleanupRetry( + sandboxId, + entry, + activeProvider, + "secret-delete", + error, + ); + throw error; + } + } if (registry.get(sandboxId) === entry) registry.delete(sandboxId); - if (currentAllocation === entry.allocation) { + if (currentAllocation === entry.lease.allocation) { currentAllocation = undefined; currentSandboxId = undefined; } }; const facade: ProcessLocalDaytonaSecretProvider = { - name: "daytona", + name: PROVIDER_NAME, async create(...args: unknown[]): Promise { - const allocation = await allocateDaytonaSecrets( - plan, - api, - undefined, - log, - ); + // An inherited lease is mounted as-is: no api.create, no new placeholder, and the same + // Secret behind the new sandbox. That is the whole point of the rebuild path. + // + // It is usable only while it is DETACHED and its slots are exactly the ones this plan asks + // for. A slot mismatch would fail later in `materializeMcpServers` with a message about a + // missing placeholder rather than about the real cause, so it is caught here and the lease + // is released instead. The same identities-only comparison guards reconnect below. + const usable = + inheritedLease !== undefined && + inheritedLease.state === "detached" && + slotSetsMatch(inheritedLease.allocation, plan); + if (inheritedLease && !usable) { + log( + `[daytona-secrets] inherited lease unusable state=${inheritedLease.state} ` + + `slots=${slotSetsMatch(inheritedLease.allocation, plan) ? "match" : "mismatch"}; ` + + "releasing it and allocating fresh", + ); + // A delete failure here must not fail the create. The lease stays detached, and the + // caller that still holds it retries the delete when it releases. + await inheritedLease.release().catch(() => {}); + } + const lease = usable + ? inheritedLease + : new DaytonaSecretLease( + await allocateDaytonaSecrets(plan, api, undefined, log), + api, + log, + ); + const allocation = lease.allocation; try { provider = buildProvider(allocation.attachments); } catch (cause) { // buildProvider is synchronous and failed before any remote create call, so absence is - // proven and compensation may safely remove the newly allocated Secrets. + // proven and compensation may safely remove the Secrets. The lease is left DETACHED + // either way, so whoever holds it deletes exactly once. try { - await deleteDaytonaSecrets(allocation, api, log); + await lease.release(); } catch (cleanupError) { throw new AggregateError( [cause, cleanupError], @@ -232,13 +399,14 @@ export function daytonaWithProcessLocalSecrets( try { const sandboxId = await provider.create(...args); const entry: RegistryEntry = { - allocation, + lease, plan, createFingerprint, generation: 0, operation: Promise.resolve(), }; registry.set(sandboxId, entry); + lease.attach(); currentAllocation = allocation; currentSandboxId = sandboxId; return sandboxId; @@ -246,6 +414,9 @@ export function daytonaWithProcessLocalSecrets( // The vendored provider creates the remote sandbox before it starts the daemon and only // returns the id after both succeed. A rejection therefore cannot prove remote absence. // Retain Secrets rather than deleting records that a partially-created sandbox may mount. + // The lease says so out loud, which is what stops an inherited one from being deleted by + // the caller that is still holding it. + lease.markIndeterminate(); if (allocation.created.length > 0) { log( "Daytona create failed before remote absence could be confirmed; retaining " + @@ -267,13 +438,8 @@ export function daytonaWithProcessLocalSecrets( "missing-process-local-secret-allocation", ); } - if (entry.cleanupTimer) { - cancel(entry.cleanupTimer); - entry.cleanupTimer = undefined; - } - // Invalidate a timer callback that fired but has not entered its serialized operation yet. // If cleanup already owns the operation, reconnect waits and observes the deleted entry. - entry.generation += 1; + dropPendingCleanup(entry); await serialize(entry, async () => { if (registry.get(sandboxId) !== entry) { throw new DaytonaReconnectTerminalError( @@ -281,6 +447,9 @@ export function daytonaWithProcessLocalSecrets( "missing-process-local-secret-allocation", ); } + // Again, now that this reconnect owns the lock. A cleanup that failed while this call + // waited has armed a replacement timer the call above could not have seen. + dropPendingCleanup(entry); if (!plansMatch(entry, createFingerprint)) { await cleanupAfterSandbox(sandboxId, entry, activeProvider); throw new DaytonaReconnectTerminalError( @@ -300,14 +469,14 @@ export function daytonaWithProcessLocalSecrets( // So the identities are reconciled here and FAIL CLOSED, which is what the split promised: // immutable topology rebuilds, mutable state reconciles on reconnect or gives up. Slot // identities carry no values (consumer, binding, host), so comparing them logs nothing. - if (!slotSetsMatch(entry.allocation, plan)) { + if (!slotSetsMatch(entry.lease.allocation, plan)) { await cleanupAfterSandbox(sandboxId, entry, activeProvider); throw new DaytonaReconnectTerminalError( sandboxId, "process-local-secret-slot-set-mismatch", ); } - currentAllocation = entry.allocation; + currentAllocation = entry.lease.allocation; currentSandboxId = sandboxId; try { await activeProvider.reconnect?.(sandboxId); @@ -331,11 +500,12 @@ export function daytonaWithProcessLocalSecrets( await activeProvider.pause?.(sandboxId); return; } - if (entry.cleanupTimer) cancel(entry.cleanupTimer); - entry.cleanupTimer = undefined; - entry.generation += 1; + dropPendingCleanup(entry); await serialize(entry, async () => { if (registry.get(sandboxId) !== entry) return; + // Again under the lock, or a replacement timer armed by a cleanup that failed while this + // pause waited would delete the sandbox it is about to park. + dropPendingCleanup(entry); await activeProvider.pause?.(sandboxId); const scheduledGeneration = entry.generation; entry.cleanupTimer = schedule(() => { @@ -366,16 +536,23 @@ export function daytonaWithProcessLocalSecrets( await destroySandboxIdempotently(activeProvider, sandboxId); return; } - if (entry.cleanupTimer) { - cancel(entry.cleanupTimer); - entry.cleanupTimer = undefined; - } - entry.generation += 1; + dropPendingCleanup(entry); await serialize(entry, async () => { if (registry.get(sandboxId) !== entry) return; + // No second drop here: this call runs the cleanup itself, so a replacement armed while it + // waited is either superseded by its success (the entry leaves the registry) or is the + // very retry its own failure would have armed. await cleanupAfterSandbox(sandboxId, entry, activeProvider); }); }, + retainSecretsOnDestroy(sandboxId: string): void { + retainSecretsForSandbox = sandboxId; + }, + takeSecretLease(): DaytonaSecretLease | undefined { + const lease = retainedLease; + retainedLease = undefined; + return lease; + }, credentialDeliveryPort(): CredentialDeliveryPort | undefined { // No allocation, no reference to rotate. A plan with no hideable candidates lands here too: // its values were passed to Daytona directly and live in the daemon environment, where only @@ -441,6 +618,42 @@ export function daytonaCredentialDeliveryPort( return undefined; } +/** + * Ask this provider to keep its Secrets when the sandbox is destroyed. No-op for any other one. + * + * Duck-typed for the same reason as `daytonaCredentialDeliveryPort`: the acquire path holds a + * provider whose concrete type it deliberately does not know. A provider without the method has + * no Secrets to keep, so doing nothing is the honest answer and the caller keeps today's behavior. + */ +export function retainDaytonaSecretsOnDestroy( + provider: unknown, + sandboxId: string, +): void { + if ( + typeof provider === "object" && + provider !== null && + "retainSecretsOnDestroy" in provider && + typeof provider.retainSecretsOnDestroy === "function" + ) { + provider.retainSecretsOnDestroy(sandboxId); + } +} + +/** The lease a retained destroy handed back, or undefined. Duck-typed like the setter above. */ +export function takeDaytonaSecretLease( + provider: unknown, +): DaytonaSecretLease | undefined { + if ( + typeof provider === "object" && + provider !== null && + "takeSecretLease" in provider && + typeof provider.takeSecretLease === "function" + ) { + return provider.takeSecretLease() as DaytonaSecretLease | undefined; + } + return undefined; +} + export function materializeDaytonaMcpServers( provider: unknown, servers: McpServerConfig[] | undefined, diff --git a/services/runner/src/engines/sandbox_agent/daytona-secrets.ts b/services/runner/src/engines/sandbox_agent/daytona-secrets.ts index e56cd458fdb..c2be8a1fe88 100644 --- a/services/runner/src/engines/sandbox_agent/daytona-secrets.ts +++ b/services/runner/src/engines/sandbox_agent/daytona-secrets.ts @@ -57,6 +57,118 @@ export interface DaytonaSecretAllocation { bySlot: ReadonlyMap; } +/** + * Who owns one allocation right now, and therefore whether `release` may delete it. + * + * - `detached` No sandbox holds these Secrets. A release deletes them. + * - `attached` A live sandbox was registered against them. Its teardown deletes them, so a + * release from anywhere else must do nothing. + * - `indeterminate` A sandbox create failed WITHOUT proving remote absence. Daytona may hold a + * partially created sandbox that mounts these Secrets, so a release must not + * delete them. This is the same fail-safe the fresh-allocation create path has + * always applied, named instead of implied. + * - `released` The Secrets were deleted. Terminal. + */ +export type DaytonaSecretLeaseState = + | "detached" + | "attached" + | "indeterminate" + | "released"; + +/** + * A move-only claim on one Secret allocation, so exactly one owner can delete it. + * + * WHY THIS EXISTS. A sandbox the credential preflight convicts is destroyed while its Secrets are + * KEPT, because a new sandbox on the same Secret works and a new Secret often does not. The + * allocation therefore outlives its sandbox and moves to the next one, and "who deletes this, and + * when" stops being answerable from any single object's own fields. The lease answers it: the + * provider moves the state as ownership moves, and every other holder just calls `release`, which + * deletes only from `detached`. + * + * The state is the ONLY ownership signal. Do not infer ownership from the registry, from a + * sandbox id, or from call order. + */ +export class DaytonaSecretLease { + private leaseState: DaytonaSecretLeaseState = "detached"; + + constructor( + readonly allocation: DaytonaSecretAllocation, + private readonly api: DaytonaSecretApi, + private readonly log: (message: string) => void = () => {}, + ) {} + + get state(): DaytonaSecretLeaseState { + return this.leaseState; + } + + /** A sandbox now holds these Secrets. Called by the provider when it registers the sandbox. */ + attach(): void { + this.leaseState = "attached"; + } + + /** The sandbox that held these Secrets is gone, and nothing has claimed them yet. */ + detach(): void { + this.leaseState = "detached"; + } + + /** A create failed without proving remote absence. Nothing may delete these Secrets. */ + markIndeterminate(): void { + this.leaseState = "indeterminate"; + } + + /** + * Delete the Secrets if this lease still owns them. + * + * Safe to call on every exit path, safe to call twice, and safe to call CONCURRENTLY. Two + * things make that true, and both matter: + * + * - The state advances to `released` only after the delete resolves, so a failed delete leaves + * the lease releasable and a later call retries it. The error is re-raised so the caller can + * log it. + * - Overlapping callers share the one in-flight delete. Without that, the second caller would + * read a state that is still `detached` (the first has not finished) and issue a second + * delete of the same records. Both would then be racing the same provider ids, and the + * loser's 404 would be swallowed as success, which is a wrong answer arrived at by luck. + */ + release(): Promise { + if (this.leaseState === "attached" || this.leaseState === "released") { + return Promise.resolve(); + } + if (this.leaseState === "indeterminate") { + // Once, however many callers ask. The refusal is one fact about one allocation, and the + // create catch has already said the same thing; repeating it per release would make a + // retried teardown look like several separate leaks. + if (this.allocation.created.length > 0 && !this.refusalLogged) { + this.refusalLogged = true; + const hosts = [ + ...new Set(this.allocation.created.flatMap((s) => s.hosts ?? [])), + ]; + this.log( + `[daytona-secrets] retained n=${this.allocation.created.length} ` + + `hosts=[${hosts.join(",")}] reason=create-outcome-unknown`, + ); + } + return Promise.resolve(); + } + this.pendingRelease ??= this.deleteAndMarkReleased(); + return this.pendingRelease; + } + + private pendingRelease?: Promise; + /** Whether the `indeterminate` refusal has already been said. See `release`. */ + private refusalLogged = false; + + /** The one delete every overlapping `release` awaits. Clears itself so a failure can retry. */ + private async deleteAndMarkReleased(): Promise { + try { + await deleteDaytonaSecrets(this.allocation, this.api, this.log); + this.leaseState = "released"; + } finally { + this.pendingRelease = undefined; + } + } +} + /** * True when a Daytona failure means "the resource is already gone": the SDK's typed * not-found error, or any 404-shaped error object. The one absence predicate shared by diff --git a/services/runner/src/engines/sandbox_agent/daytona.ts b/services/runner/src/engines/sandbox_agent/daytona.ts index 973f5d3f915..fccc7f9e80d 100644 --- a/services/runner/src/engines/sandbox_agent/daytona.ts +++ b/services/runner/src/engines/sandbox_agent/daytona.ts @@ -1,6 +1,10 @@ import { join } from "node:path"; -import { createAcpFetch } from "./acp-fetch.ts"; +import { + createAcpFetch, + withSandboxGoneReport, + type AcpFetchOptions, +} from "./acp-fetch.ts"; import { resolvePiToolSpecsDelivery, uploadPiExtensionToSandbox, @@ -293,11 +297,17 @@ export async function prepareDaytonaPiAssets({ * required" / 502. The sandbox-agent SDK accepts a custom fetch, so we hand it this one. * * It layers on {@link createAcpFetch} (the long-timeout ACP dispatcher) so a paused HITL turn - * over Daytona is not reaped by undici's default `headersTimeout` either. + * over Daytona is not reaped by undici's default `headersTimeout` either, and so `options` (the + * sandbox-gone report) reaches the one place that inspects every ACP response. Daytona is the + * provider whose proxy answers for a deleted sandbox, so this is the path that needs it most. */ export function createCookieFetch( - inner: typeof fetch = createAcpFetch(), + inner?: typeof fetch, + options: AcpFetchOptions = {}, ): typeof fetch { + const base = inner + ? withSandboxGoneReport(inner, options) + : createAcpFetch(undefined, options); const jar = new Map>(); // host -> (name -> "name=value") return async (input: any, init?: any) => { const url = new URL(typeof input === "string" ? input : input.url); @@ -312,7 +322,7 @@ export function createCookieFetch( if (existing) merged.unshift(existing); headers.set("cookie", merged.join("; ")); } - const response = await inner(input, { ...init, headers }); + const response = await base(input, { ...init, headers }); const setCookies = typeof (response.headers as any).getSetCookie === "function" ? (response.headers as any).getSetCookie() diff --git a/services/runner/src/engines/sandbox_agent/engine.ts b/services/runner/src/engines/sandbox_agent/engine.ts index 671add8328d..569a73fa7c8 100644 --- a/services/runner/src/engines/sandbox_agent/engine.ts +++ b/services/runner/src/engines/sandbox_agent/engine.ts @@ -3,6 +3,7 @@ import { type AgentRunResult, type EmitEvent, } from "../../protocol.ts"; +import { isUserStopAbort } from "../../sessions/stop-signal.ts"; import { acquireEnvironment } from "./environment.ts"; import { runCredential } from "./runtime-policy.ts"; import { loadDurableDecisions } from "../../sessions/interactions.ts"; @@ -13,18 +14,53 @@ import { } from "./runtime-contracts.ts"; /** - * Whether a completed turn's environment may be parked: never on abort, client disconnect, - * pause, or failure. Session-owned streams survive disconnect WITHOUT aborting the run signal - * (server policy), so the disconnect check needs the separate `clientGone` flag. A wedged - * sandbox that failed its turn must be destroyed, not reconnected on the next one. + * Whether a completed turn's environment may be parked: never on client disconnect, pause, or + * failure. Session-owned streams survive disconnect WITHOUT aborting the run signal (server + * policy), so the disconnect check needs the separate `clientGone` flag. A wedged sandbox that + * failed its turn must be destroyed, not reconnected on the next one. + * + * A USER STOP IS THE ONE ABORT THAT MAY PARK. Stop and Delete are different operations: Stop + * keeps the session, the sandbox, and the harness session resumable. Three things must all be + * true, and each answers a different question: + * + * - `isUserStopAbort(signal)` — WAS this abort a cooperative Stop? The signal is labelled at + * the one call site that means it (`server.ts`, the heartbeat interrupt). Reading + * `signal.aborted` alone cannot answer this, and inferring it from the stop reason would let + * any future `controller.abort()` park a sandbox nobody checked. See `sessions/stop-signal.ts`. + * - `result.stopReason === "cancelled"` — did the TURN actually end as a cancel? + * - `result.cancelSettled` — did the HARNESS confirm it stopped? See `cancel-turn.ts`. + * + * Every other abort leaves the environment in an unknown state and still destroys. + * + * A SETTLED USER STOP IS CHECKED BEFORE `clientGone`, AND THAT ORDER IS THE WHOLE POINT. + * `clientGone` used to be read first, which read well and broke the product on every real Stop. + * The browser's Stop button aborts its own chat stream in the SAME tick it sends the durable + * cancel command (`web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts`, + * `handleStop`), so the disconnect and the labelled abort always arrive together. With the + * disconnect read first, every Stop fell into the destroy branch: the sandbox was deleted, the + * native harness session went with it, and the next message replayed cold. Observed on the + * increment-6 stack on 2026-09-04, three Stops, three evictions, no warm park. + * + * The disconnect rule loses nothing it was written for. It exists so an UNATTENDED session is + * never kept warm on a guess, and a Stop is not a guess: it is an authenticated command the API + * recorded durably, from a user who is still on the page and about to type. Every other + * disconnect — mid-turn tab close, a dropped connection, a failed turn — still destroys, and the + * parked entry still expires on its own TTL. */ export function shouldPark( result: AgentRunResult, signal: AbortSignal | undefined, clientGone: (() => boolean) | undefined, ): boolean { - if (signal?.aborted) return false; // aborted run: destroy, do not park + // The harness is idle and the sandbox is worth keeping warm, whatever the stream did. + const settledUserStop = + isUserStopAbort(signal) && + result.ok === true && + result.stopReason === "cancelled" && + result.cancelSettled === true; + if (settledUserStop) return true; if (clientGone?.()) return false; // client disconnected mid-turn: destroy, do not park + if (signal?.aborted) return false; // any other abort: unknown state, destroy if (!result.ok) return false; // failed turn: teardown as today if (result.stopReason === "paused") return false; // a plain pause never parks return true; @@ -55,6 +91,7 @@ export async function runSandboxAgent( try { result = await runTurn(env, request, emit, signal, { loaded: env.loadedFromContinuity, + nativeHistoryVerified: env.nativeHistoryVerified, ...turnOptions, // After the spread so a caller-supplied set wins, and short-circuited so we never CLAIM // rows the spread would then discard — a claimed row is spent even if it is thrown away. @@ -76,7 +113,10 @@ export async function runSandboxAgent( shouldPark(result, signal, undefined); await env.destroy({ reason: cleanResumable - ? "clean-resumable" + ? // A settled Stop parks under its own reason, so the log says WHY the sandbox survived. + result?.stopReason === "cancelled" + ? "cancelled" + : "clean-resumable" : signal?.aborted ? "aborted" : "failed-turn", diff --git a/services/runner/src/engines/sandbox_agent/environment-setup.ts b/services/runner/src/engines/sandbox_agent/environment-setup.ts index 75003877d9a..fc2519fbddd 100644 --- a/services/runner/src/engines/sandbox_agent/environment-setup.ts +++ b/services/runner/src/engines/sandbox_agent/environment-setup.ts @@ -64,6 +64,7 @@ export async function prepareEnvironmentSetup( request: AgentRunRequest, deps: SandboxAgentDeps = {}, presignedMount?: MountCredentials | null, + signal?: AbortSignal, ) { const logger = deps.log ?? defaultLog; const acquireStartedAt = Date.now(); @@ -116,6 +117,7 @@ export async function prepareEnvironmentSetup( apiBase: apiBase(), authorization: runCred, log: logger, + signal, }) : null; // A session-owned run expects a durable session cwd mount. When signing returns nothing the run @@ -136,6 +138,7 @@ export async function prepareEnvironmentSetup( apiBase: apiBase(), authorization: runCred, log: logger, + signal, }) : null; // A workflow-artifact run expects an agent mount; same structured degrade signal when unsigned. @@ -390,6 +393,10 @@ export async function prepareEnvironmentSetup( mountProjectId: mountCreds?.projectId, projectScopeId: projectScopeFor(request, mountCreds?.projectId)?.id, loadedFromContinuity: false, + nativeHistoryVerified: false, + // Daytona keeps its established per-harness transcript mounts. Local becomes durable only + // after its cwd mount succeeds; the Pi transcript directory lives underneath that cwd. + nativeHistoryDurable: plan.isDaytona, resumable: false, continuityTurnIndex: undefined, sessionDestroyRequested: false, diff --git a/services/runner/src/engines/sandbox_agent/environment.ts b/services/runner/src/engines/sandbox_agent/environment.ts index fa92543e922..5b6410e2f71 100644 --- a/services/runner/src/engines/sandbox_agent/environment.ts +++ b/services/runner/src/engines/sandbox_agent/environment.ts @@ -34,6 +34,8 @@ import { mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { apiBase } from "../../apiBase.ts"; +import { abortableSandboxProvider } from "../../environment/abortable-sandbox-provider.ts"; +import { throwIfAcquireAborted } from "../../environment/acquire-abort.ts"; import { InMemorySessionPersistDriver, @@ -49,6 +51,7 @@ import { } from "../../protocol.ts"; import { advertisedToolSpecs } from "../../tools/public-spec.ts"; import { createAcpFetch } from "./acp-fetch.ts"; +import { createSandboxGoneLatch } from "./sandbox-gone.ts"; import { assert, assertRequiredCapabilities, @@ -61,9 +64,10 @@ import { prepareDaytonaPiAssets, } from "./daytona.ts"; import { applyCodexMode, resolveCodexMode } from "./codex-mode.ts"; -import { conciseError } from "./errors.ts"; +import { classifyRunError, conciseError, type RunErrorCode } from "./errors.ts"; import { awaitCredentialSubstitution, + buildCredentialPreflightInput, deliversModelSecretOnCreate, STUCK_ACQUIRE_ATTEMPTS, SubstitutionStuckError, @@ -72,7 +76,10 @@ import { PI_MODEL_PROVIDER_OVERRIDE_ENV } from "../../extensions/model-provider- import { daytonaCredentialDeliveryPort, materializeDaytonaMcpServers, + retainDaytonaSecretsOnDestroy, + takeDaytonaSecretLease, } from "./daytona-secret-provider.ts"; +import type { DaytonaSecretLease } from "./daytona-secrets.ts"; import { buildSessionMcpServers, validateUserMcpServers } from "./mcp.ts"; import { applyModel } from "./model.ts"; import { @@ -307,29 +314,104 @@ export async function acquireEnvironment( emit?: EmitEvent, ): Promise { // A sandbox the preflight convicts as stuck (no Secret substitution wiring, a permanent - // per-sandbox fault) is already destroyed by the failure path; a FRESH sandbox on the same - // Secret works, so one retry converts a would-be failed first turn into a slower one. - for (let attempt = 1; ; attempt++) { - const result = await acquireEnvironmentOnce( - request, - deps, - signal, - presignedMount, - emit, - ); - if ( - result.ok || - !result.stuckSubstitution || - attempt >= STUCK_ACQUIRE_ATTEMPTS || - signal?.aborted - ) { - return result; + // per-sandbox fault) is already destroyed by the failure path. + // + // THE REBUILD KEEPS THE SECRET (production runner logs, 2026-09-01..02). Daytona support + // confirmed that a new sandbox on the SAME Secret works. The runner used to delete the stuck + // sandbox's Secret and allocate a new one within a second, so every rebuild tested a brand-new + // Secret instead, and 4 of 7 observed rebuilds were stuck again. The convicted sandbox's + // allocation is therefore kept as a LEASE and handed to the next attempt, which mounts it. + // + // Ownership lives in the lease state, not here (see `DaytonaSecretLease`). This loop only holds + // the lease and releases it once on the way out. The release deletes when the lease is still + // detached, does nothing when a live sandbox attached it, and refuses when a create failed + // without proving the remote sandbox is absent. + let lease: DaytonaSecretLease | undefined; + try { + for (let attempt = 1; ; attempt++) { + const result = await acquireEnvironmentOnce( + request, + deps, + signal, + presignedMount, + emit, + lease, + ); + if (!result.ok && result.lease) lease = result.lease; + if ( + result.ok || + !result.stuckSubstitution || + attempt >= STUCK_ACQUIRE_ATTEMPTS || + signal?.aborted + ) { + return publishAcquireResult(result, emit); + } + process.stderr.write( + `[sandbox-agent] stuck-substitution sandbox destroyed; rebuilding fresh ` + + `on the same Secret (attempt ${attempt + 1}/${STUCK_ACQUIRE_ATTEMPTS})\n`, + ); } - process.stderr.write( - `[sandbox-agent] stuck-substitution sandbox destroyed; rebuilding fresh ` + - `(attempt ${attempt + 1}/${STUCK_ACQUIRE_ATTEMPTS})\n`, - ); + } finally { + // Never throws: a failed Secret delete must not replace the acquire's own answer. The lease + // stays releasable after a failed delete, so nothing is silently marked done. + await lease?.release().catch((error: unknown) => { + process.stderr.write( + `[sandbox-agent] retained Daytona Secret cleanup failed: ` + + `${String(error instanceof Error ? error.message : error).slice(0, 200)}\n`, + ); + }); + } +} + +/** + * One attempt's answer, including the Secret lease the loop threads between attempts. + * + * PRIVATE ON PURPOSE. The lease is an ownership token: whoever holds it may delete a live + * sandbox's credentials. Only the loop above holds one, and `publishAcquireResult` strips it + * before the result reaches any caller, so no consumer of `acquireEnvironment` can reach it. + */ +type AcquireAttemptResult = + | { ok: true; env: SessionEnvironment } + | { + ok: false; + error: string; + /** The failure class, for the error event the loop emits. See `publishAcquireResult`. */ + errorCode?: RunErrorCode; + stuckSubstitution?: boolean; + lease?: DaytonaSecretLease; + }; + +/** + * Build the caller-facing result, and tell the client what class of failure this was. + * + * ACQUIRE IS A USER-FACING FAILURE SURFACE, and it used to be a silent one. A turn that fails + * inside `runTurn` emits a typed `error` event, so the client can offer the right next step. A + * turn that never got an environment emitted nothing, so the same failure reached the person as + * the SDK's generic `agent_run_failed` with whatever internal sentence the runner raised. The + * doubly stuck sandbox is the case that made this visible: a credential-delivery failure the + * client already knows how to offer a retry for, arriving with no code to recognize it by. + * + * The event is emitted only for a NAMED class. A generic `runner_error` keeps today's behavior + * exactly, so this widens what the client can act on without changing what it already sees. + * Emitted here rather than per attempt, because a stuck attempt that is rebuilt successfully is + * not a failure the user should ever hear about. + * + * The result itself stays minimal: `ok`, `error`, and `stuckSubstitution`. The code rides the + * event, and the lease never leaves the loop. + */ +function publishAcquireResult( + result: AcquireAttemptResult, + emit?: EmitEvent, +): AcquireEnvironmentResult { + if (result.ok) return result; + if (result.errorCode && result.errorCode !== "runner_error") { + emit?.({ type: "error", message: result.error, code: result.errorCode }); } + return { + ok: false, + error: result.error, + ...(result.stuckSubstitution ? { stuckSubstitution: true } : {}), + }; } async function acquireEnvironmentOnce( @@ -338,14 +420,23 @@ async function acquireEnvironmentOnce( signal?: AbortSignal, presignedMount?: MountCredentials | null, emit?: EmitEvent, -): Promise { + /** A detached lease from a sandbox the preflight convicted. See `acquireEnvironment`. */ + inheritedLease?: DaytonaSecretLease, +): Promise { emit?.({ type: "data", name: "agent-status", data: { phase: "environment_starting" }, transient: true, }); - const setup = await prepareEnvironmentSetup(request, deps, presignedMount); + throwIfAcquireAborted(signal); + const setup = await prepareEnvironmentSetup( + request, + deps, + presignedMount, + signal, + ); + throwIfAcquireAborted(signal); if (!setup.ok) return setup; const { acquireStartedAt, @@ -377,6 +468,11 @@ async function acquireEnvironmentOnce( } = setup; let runAgentDir = setup.runAgentDir; + // The credential preflight is kicked off mid-acquire and awaited at the very end, so an + // acquire that fails in between would leave it running with nothing observing it. This is + // how the failure path ends it; see the kickoff and the catch below. + const preflightAbort = new AbortController(); + // ---- MountLifecycle ------------------------------------------------------------------ // // The six mount helpers moved to `environment/mount-lifecycle.ts`. They used to be mutually // recursive closures over this scope; they now take `ctx` and capture nothing. `ctx` is the @@ -506,6 +602,7 @@ async function acquireEnvironmentOnce( signMount, signAgentMount, daytonaPiDir: DAYTONA_PI_DIR, + signal, }; const mountLocalDurableCwd = (reason: string) => mountLocalDurableCwdUnit(ctx, mountDeps, reason); @@ -519,6 +616,10 @@ async function acquireEnvironmentOnce( const remountLocalCwdAfterRuntimeEnotconn = (event: unknown) => remountLocalCwdAfterRuntimeEnotconnUnit(ctx, mountDeps, event); + // Declared out here so the catch below can read the Secrets a stuck sandbox kept. The provider + // is opaque on purpose (local or Daytona), and the two Secret helpers duck-type it. + let sandboxProvider: unknown; + try { // Fail loud before any sandbox/mount infra spins up: an applicable-but-incomplete // OpenAI-compatible custom request is a hard error, never a silent fall-back (Decision 5). @@ -560,24 +661,49 @@ async function acquireEnvironmentOnce( // mount-success path add guidance/env atomically, while a failed mount starts a normal // scratch-only harness with no false durable-storage signal. if (environment.mountCreds && !plan.isDaytona) { - await mountLocalDurableCwd("initial"); + const mounted = await mountLocalDurableCwd("initial"); + if (mounted && piSessionDir) environment.nativeHistoryDurable = true; + throwIfAcquireAborted(signal); } if (environment.agentMountCreds && !plan.isDaytona) { await mountLocalAgentCwd(); + throwIfAcquireAborted(signal); } // INVARIANT 1: the provider takes `env` and `piExtEnv` BY REFERENCE and hands them to the // daemon, after which the daemon environment is fixed. Every local mount had to land above // this line. From here a `writeDaemonEnv` is a programming-order bug and throws. ctx.freezeDaemonEnv(); - const sandboxProvider = (deps.buildSandboxProvider ?? buildSandboxProvider)( - plan.sandboxId, - env, - binaryPath, - piExtEnv, - plan.credentials.modelEnvironment, - plan.sandboxPermission, - plan.credentials.daytonaSecretPlan, + sandboxProvider = abortableSandboxProvider( + (deps.buildSandboxProvider ?? buildSandboxProvider)( + plan.sandboxId, + env, + binaryPath, + piExtEnv, + plan.credentials.modelEnvironment, + plan.sandboxPermission, + plan.credentials.daytonaSecretPlan, + inheritedLease ? { inheritedLease } : {}, + ), + signal, + logger, ); + // The turn's own socket is the first thing to learn that a remote sandbox was deleted, and it + // cannot end a turn by itself (the ACP transport swallows the failure and the pending prompt + // never settles). It notes the death here; `run-turn.ts` hands this latch to the liveness + // probe, which ends the turn. See `sandbox-gone.ts`. + // + // ARMED ONLY AFTER ACQUIRE. The same fetch also carries the SDK's health wait, which polls a + // sandbox that is still coming up and tolerates a provider error by design. On a warm resume + // the provider's proxy can lag its own control plane and answer for a sandbox it has not + // finished re-exposing. A report during acquire would latch a HEALTHY sandbox as dead and kill + // its first turn, and the latch is one-way, so the window has to be closed before it rather + // than reasoned about after. Acquire already has its own failure path for a sandbox that + // genuinely never comes up. + const sandboxGone = createSandboxGoneLatch(); + environment.sandboxGone = sandboxGone; + const acpFetchOptions = { + onSandboxGone: (reason: string) => sandboxGone.note(reason), + }; const startOptions = { sandbox: sandboxProvider, persist, @@ -587,8 +713,11 @@ async function acquireEnvironmentOnce( // Long-timeout undici dispatcher so a paused HITL turn is not reaped by undici's default // headersTimeout; Daytona additionally carries the per-sandbox auth cookie. fetch: plan.isDaytona - ? (deps.createCookieFetch ?? createCookieFetch)() - : (deps.createAcpFetch ?? createAcpFetch)(), + ? (deps.createCookieFetch ?? createCookieFetch)( + undefined, + acpFetchOptions, + ) + : (deps.createAcpFetch ?? createAcpFetch)(undefined, acpFetchOptions), }; // SandboxLifecycle owns the reconnect ladder, the fresh-create fallback, and both // `sandbox_start` timing marks. See `environment/sandbox-lifecycle.ts`. @@ -612,7 +741,11 @@ async function acquireEnvironmentOnce( }, ); environment.sandbox = acquiredSandbox.sandbox; + throwIfAcquireAborted(signal); environment.resumable = acquiredSandbox.resumable; + // The sandbox is up and the reconnect ladder is done, so a "sandbox not found" from here on is + // a real death rather than a proxy that has not caught up. See the latch above. + sandboxGone.arm(); // Read AFTER the sandbox is acquired, because the port is bound to a sandbox: the provider has // no allocation to deliver against until create (or reconnect) has settled. Undefined for // every provider that cannot deliver a credential to a live sandbox, which is what routes a @@ -634,8 +767,8 @@ async function acquireEnvironmentOnce( const preflightBaseUrl = request.modelConnection?.endpoint?.baseUrl?.trim(); // Record the delivery moment for the 401 classifier. Same condition as the preflight below, // minus the endpoint: the race exists wherever a model key rides a Secret on a fresh sandbox, - // but the preflight can only SEE it where the provider echoes what it received. A direct - // Anthropic endpoint echoes nothing, so on that path the classifier is the only guard. + // but the preflight can only SEE it on a provider whose request shape it knows. Gemini is + // not one of those, so on that path the classifier is still the only guard. if ( deliversModelSecretOnCreate({ isDaytona: plan.isDaytona, @@ -652,11 +785,38 @@ async function acquireEnvironmentOnce( preflightBaseUrl ? (deps.awaitCredentialSubstitution ?? awaitCredentialSubstitution)({ sandbox: environment.sandbox, - baseUrl: preflightBaseUrl, - apiKeyVar: modelSecretCandidate.binding.name, + // The candidate's real value rides in as the credential for the runner's own + // auth call and nowhere else. See `buildCredentialPreflightInput`. + ...buildCredentialPreflightInput({ + baseUrl: preflightBaseUrl, + candidate: modelSecretCandidate, + ...(request.modelConnection?.provider + ? { provider: request.modelConnection.provider } + : {}), + ...(request.modelConnection?.deployment + ? { deployment: request.modelConnection.deployment } + : {}), + }), + // Cancel the runner's own auth call with the run, and with an acquire that fails + // before the await below ever runs. + signal: signal + ? AbortSignal.any([signal, preflightAbort.signal]) + : preflightAbort.signal, log: logger, + }).catch((error: unknown) => { + // `awaitCredentialSubstitution` is written not to throw. If it ever does, the + // acquire must not inherit the rejection from a promise nobody is awaiting yet. + // The error's name only: nothing from a credential path is interpolated here. + logger( + `[credential-preflight] preflight itself failed (` + + `${error instanceof Error ? error.name : "unknown"}); proceeding`, + ); + return "ok" as const; }) : undefined; + // The preflight runs concurrently with the rest of acquire. Attach a rejection observer now + // so an early Stop cannot become an unhandled rejection before the final await reaches it. + void credentialPreflight?.catch(() => {}); // On Daytona, push the harness login, the extension, and AGENTS.md into the remote sandbox. // For a non-Pi harness with executable tools, also push the in-sandbox stdio MCP shim @@ -749,6 +909,7 @@ async function acquireEnvironmentOnce( ? undefined : ((await (deps.discoverTunnelEndpoint ?? discoverTunnelEndpoint)({ log: logger, + signal, })) ?? undefined); const refusal = mountRefusal(storeEndpoint, endpoint); const canMount = !refusal; @@ -771,6 +932,7 @@ async function acquireEnvironmentOnce( { endpoint, log: logger, + signal, }, )) ) { @@ -798,6 +960,7 @@ async function acquireEnvironmentOnce( apiBase: apiBase(), authorization: runCred, log: logger, + signal, }, ); } @@ -818,6 +981,7 @@ async function acquireEnvironmentOnce( ? undefined : ((await (deps.discoverTunnelEndpoint ?? discoverTunnelEndpoint)({ log: logger, + signal, })) ?? undefined); const refusal = mountRefusal(storeEndpoint, endpoint); const canMount = !refusal; @@ -840,7 +1004,7 @@ async function acquireEnvironmentOnce( environment.sandbox, mountPath, environment.agentMountCreds, - { endpoint, log: logger }, + { endpoint, log: logger, signal }, )) ) { environment.agentMountedPath = mountPath; @@ -860,6 +1024,7 @@ async function acquireEnvironmentOnce( logger(`remote agent mount active for artifact=${artifactId}`); } } catch (err) { + throwIfAcquireAborted(signal); logger( `remote agent mount failed artifact=${artifactId}: ${conciseError(err, plan.harness)}`, ); @@ -869,6 +1034,12 @@ async function acquireEnvironmentOnce( } const prepareWorkspaceStartedAt = Date.now(); + emit?.({ + type: "data", + name: "agent-status", + data: { phase: "preparing_workspace" }, + transient: true, + }); // The instructions file is the fourth guidance channel, and the only one every harness reads. // It is composed HERE rather than in `run-plan.ts` because the mount arm needs mount state: // both agent-mount paths above run before this point (local at `mountLocalAgentCwd`, Daytona @@ -1094,6 +1265,14 @@ async function acquireEnvironmentOnce( // (see `appendSessionTurn` call in `runTurn`), not a separate pre-turn pointer PUT: the // turns table is append-only, so there is nothing to overwrite mid-conversation. // HarnessSessionLifecycle owns both open modes and both `create_session` timing marks. + + // Longest stage of a cold acquire by far (19.2s of 24.5s), so it gets its own phase. + emit?.({ + type: "data", + name: "agent-status", + data: { phase: "opening_session" }, + transient: true, + }); const opened = await openHarnessSession({ sandbox: environment.sandbox, persist, @@ -1102,6 +1281,7 @@ async function acquireEnvironmentOnce( cwd: plan.workspace.cwd, sessionInit, priorAgentSessionId, + nativeHistoryDurable: environment.nativeHistoryDurable, localSessionId, continuitySessionKey, log: logger, @@ -1109,6 +1289,7 @@ async function acquireEnvironmentOnce( }); environment.session = opened.session; environment.loadedFromContinuity = opened.loadedFromContinuity; + environment.nativeHistoryVerified = opened.nativeHistoryVerified; // The reopen capability, captured here because this is the only scope holding the persist // driver, the session-init payload and the local session key together. Same pattern as // `destroy`: the environment carries a closure rather than the ingredients. @@ -1121,6 +1302,7 @@ async function acquireEnvironmentOnce( cwd: plan.workspace.cwd, sessionInit, priorAgentSessionId: environment.session?.agentSessionId, + nativeHistoryDurable: environment.nativeHistoryDurable, localSessionId, continuitySessionKey, log: logger, @@ -1131,6 +1313,7 @@ async function acquireEnvironmentOnce( if (result.ok) { environment.session = result.session; environment.loadedFromContinuity = result.loadedFromContinuity; + environment.nativeHistoryVerified = result.nativeHistoryVerified; } return result; }; @@ -1191,11 +1374,22 @@ async function acquireEnvironmentOnce( const preflightAwaitStartedAt = Date.now(); const verdict = await credentialPreflight; timingLog("credential_preflight", preflightAwaitStartedAt); - // Throwing takes the shared teardown below (sandbox destroyed, Secrets deleted), and the - // catch marks the result so the acquire wrapper retries once with a fresh sandbox. - if (verdict === "stuck") throw new SubstitutionStuckError(); + if (verdict === "stuck") { + // Keep the Secrets. The teardown below destroys the sandbox, and the next attempt + // creates its sandbox against this same allocation, which is the case Daytona support + // confirmed works. The destroy runs through the sandbox-agent handle, so the intent has + // to be set on the provider here rather than passed to the destroy call. It is keyed by + // THIS sandbox's id, so it cannot change what any other cleanup does. + const convictedSandboxId = environment.sandbox?.sandboxId; + if (convictedSandboxId) { + retainDaytonaSecretsOnDestroy(sandboxProvider, convictedSandboxId); + } + throw new SubstitutionStuckError(); + } } + throwIfAcquireAborted(signal); + timingLog("acquire_total", acquireStartedAt); emit?.({ type: "data", @@ -1205,6 +1399,10 @@ async function acquireEnvironmentOnce( }); return { ok: true, env: environment }; } catch (err) { + // End the preflight first. Acquire failed somewhere between its kickoff and its await, so + // nothing downstream will ever read it, and its runner-side auth call must not outlive the + // acquire that started it. + preflightAbort.abort(); // DELIBERATELY WITHOUT `daytonaCredentialFresh`, unlike the two call sites in `run-turn.ts`. // Acquire INSTALLS the model credential but never exercises it: the first model call belongs // to the turn. The one credential-shaped failure this path can raise is the preflight's @@ -1212,19 +1410,35 @@ async function acquireEnvironmentOnce( // Wiring the predicate here would also need the once-per-session counter, which lives in the // turn path — without it a genuinely bad key could loop. If a model-touching step is ever // added to acquire, this site needs BOTH the predicate and that counter. - const error = conciseError( + // The CLASS as well as the line, because acquire is now a user-facing failure surface: the + // loop turns a classified code into the error event the client renders a retry state from. + const classified = classifyRunError( err, plan.harness, request.modelConnection?.provider, { authFault: () => describeCodexSubscriptionAuthFault(plan) }, ); + const error = classified.message; // Mirror today's shared teardown: no otel exists yet during acquire, so there is no partial // trace to flush — just run the incrementally-registered finalizers and surface the error. await environment.destroy({ reason: "failed-turn" }); if (err instanceof SubstitutionStuckError) { - return { ok: false, error, stuckSubstitution: true }; + // The internal sentence names probes and placeholders. It belongs in the operator log, and + // the user reads the standard credential-delivery copy instead. + logger(`acquire failed: ${err.message}`); + // Read AFTER the destroy above: the lease is only handed back once Daytona has confirmed + // the sandbox is absent, which keeps the delete-order invariant intact. A destroy that + // failed for any other reason hands back nothing, so the next attempt allocates fresh. + const retainedLease = takeDaytonaSecretLease(sandboxProvider); + return { + ok: false, + error, + errorCode: classified.code, + stuckSubstitution: true, + ...(retainedLease ? { lease: retainedLease } : {}), + }; } - return { ok: false, error }; + return { ok: false, error, errorCode: classified.code }; } } diff --git a/services/runner/src/engines/sandbox_agent/errors.ts b/services/runner/src/engines/sandbox_agent/errors.ts index bee78aeb639..35265ac6d77 100644 --- a/services/runner/src/engines/sandbox_agent/errors.ts +++ b/services/runner/src/engines/sandbox_agent/errors.ts @@ -1,3 +1,5 @@ +import { SubstitutionStuckError } from "./credential-preflight.ts"; + /** Map a provider family to its human-facing vault key label, for the credit/auth hint. */ const PROVIDER_KEY_LABELS: Record = { openai: "OpenAI", @@ -55,13 +57,45 @@ function keyHintFor( * `runner_error` is the catch-all every unclassified failure keeps, matching what the SDK stamped * on runner-reported errors before the runner had a say. */ +/** + * Markers the runner puts in an error message so `classifyRunError` can set the class. + * + * Both are strings only this runner produces, so a match needs no corroboration. They live + * here, next to the codes they map to, and are imported by the modules that raise them. + */ +export const SANDBOX_GONE_MARKER = "sandbox is gone"; +export const ABANDONED_TURN_MARKER = "execution abandoned"; + +/** The line the user reads when the machine running their turn disappeared. */ +export const SANDBOX_GONE_MESSAGE = + "The sandbox running this session stopped responding, so the run was ended. " + + "Send the message again to start a fresh sandbox."; + +/** The line the user reads when the run never produced an outcome of its own. */ +export const EXECUTION_LOST_MESSAGE = + "The agent stopped responding and the run was closed. Send the message again to retry."; + export type RunErrorCode = | "runner_error" | "starter_credits_exhausted" | "starter_credits_program_paused" | "starter_credits_unavailable" | "credential_delivery_failed" - | "rate_limited"; + | "rate_limited" + // Not a failure: the turn was REFUSED before it started because another turn already owns + // this session. Nothing ran, nothing was destroyed, and the user's message was never sent. + // Clients render it as a "not sent, try again" state and keep the text, never as a run error. + // Produced by `sessions/admission.ts`, not by this module's classifier. + | "session_turn_in_use" + // The sandbox died under a running turn: its liveness probe stopped answering, so the turn + // was ended rather than left holding a machine that no longer exists. See + // `sandbox-liveness.ts`. + | "sandbox_gone" + // The execution never produced an outcome of its own, so one was written for it. Two + // producers: this runner, when a turn will not unwind after its abort (`sessions/ + // turn-settle.ts`), and the platform's execution watchdog, when the runner itself is gone + // (`api/oss/src/tasks/asyncio/sessions/orphan_sweep.py`). + | "execution_lost"; /** One failed run, condensed: the line the user reads plus the class a client can act on. */ export interface ClassifiedRunError { @@ -86,7 +120,7 @@ const PROVIDER_RATE_LIMITED_MESSAGE = "Too many requests to the model provider right now. Try again in a moment."; const STARTER_CREDITS_UNAVAILABLE_MESSAGE = "Agenta credits are temporarily unavailable. Try again in a moment."; -const CREDENTIAL_DELIVERY_FAILED_MESSAGE = +export const CREDENTIAL_DELIVERY_FAILED_MESSAGE = "A temporary issue kept this run's credentials from reaching the model. Send the message again."; /* @@ -323,6 +357,25 @@ export function classifyRunError( const raw = err instanceof Error ? err.message : String(err); const msg = raw.split("\n")[0].trim(); const keyHint = keyHintFor(provider, harness, options.connection); + // FIRST, and matched on the ERROR CLASS rather than on any text. Every sandbox this run built + // was convicted by the credential preflight, which means the model key never reached the model: + // the same failure class as the two placeholder branches below, arrived at by proof instead of + // by pattern. Its own message names probes and placeholders and is written for the runner log, + // so it must not be what the person in the chat reads. See `credential-preflight.ts`. + if (err instanceof SubstitutionStuckError) { + return { + message: CREDENTIAL_DELIVERY_FAILED_MESSAGE, + code: "credential_delivery_failed", + }; + } + // First, and self-evidencing: this marker is produced by our own liveness probe and by + // nothing else, so it needs no corroboration and must not be re-read as a provider fault. + if (raw.includes(SANDBOX_GONE_MARKER)) { + return { message: SANDBOX_GONE_MESSAGE, code: "sandbox_gone" }; + } + if (raw.includes(ABANDONED_TURN_MARKER)) { + return { message: EXECUTION_LOST_MESSAGE, code: "execution_lost" }; + } // A budget refusal is checked first: it is the most specific reading of a 429, and its body also // trips the rate-limit and quota matchers below. if (BUDGET_REFUSAL.test(raw)) { diff --git a/services/runner/src/engines/sandbox_agent/harness-trace-port.ts b/services/runner/src/engines/sandbox_agent/harness-trace-port.ts index 44d176c9c2a..4a5ba705bbb 100644 --- a/services/runner/src/engines/sandbox_agent/harness-trace-port.ts +++ b/services/runner/src/engines/sandbox_agent/harness-trace-port.ts @@ -1,7 +1,11 @@ import { randomBytes } from "node:crypto"; import type { AgentRunRequest } from "../../protocol.ts"; -import { sandboxVisibleSecretValues, type Redactor } from "../../redaction.ts"; +import { + modelEnvironmentSecretValues, + sandboxVisibleSecretValues, + type Redactor, +} from "../../redaction.ts"; import type { createSandboxAgentOtel } from "../../tracing/otel.ts"; import { createPiTraceTurnExport } from "../../tracing/pi-trace-turn-export.ts"; import { @@ -143,13 +147,16 @@ function piTracePort(options: { content: request.telemetry?.capture?.content?.enabled !== false, }, skills: plan.workspace.skillDirs.map((skill) => skill.name), - // Only values visible inside the sandbox cross this boundary. In particular, the runner - // OTLP authorization never enters the control file. + // Only secret values visible inside the sandbox cross this boundary. Approved public + // model configuration and the runner OTLP authorization never enter the control file. redaction: { knownValues: [ ...new Set( [ - ...Object.values(request.modelConnection?.environment ?? {}), + ...(request.sandboxCredentials ?? []).map((credential) => credential.value), + ...modelEnvironmentSecretValues( + request.modelConnection?.environment, + ), ...sandboxVisibleSecretValues(env), ].filter((value): value is string => !!value), ), diff --git a/services/runner/src/engines/sandbox_agent/mount.ts b/services/runner/src/engines/sandbox_agent/mount.ts index 974775ea773..c3588389ee8 100644 --- a/services/runner/src/engines/sandbox_agent/mount.ts +++ b/services/runner/src/engines/sandbox_agent/mount.ts @@ -17,6 +17,11 @@ import { execFile, spawn } from "node:child_process"; import { promisify } from "node:util"; +import { + throwIfAcquireAborted, + waitForAcquire, +} from "../../environment/acquire-abort.ts"; + const pExecFile = promisify(execFile); /** POSIX single-quote escaping for values interpolated into `sh -c` strings. */ @@ -51,6 +56,7 @@ export interface SignMountDeps { /** Injectable for tests; defaults to global fetch. */ fetchImpl?: typeof fetch; log?: (msg: string) => void; + signal?: AbortSignal; } function defaultLog(msg: string): void { @@ -81,6 +87,7 @@ export async function signSessionMountCredentials( "content-type": "application/json", authorization: deps.authorization, }, + signal: deps.signal, }); if (!res.ok) { // 503 = storage not configured (mounts disabled). Any non-2xx → run without this mount. @@ -124,6 +131,7 @@ export async function signSessionMountCredentials( : undefined, }; } catch (err) { + throwIfAcquireAborted(deps.signal); log( `sign failed session=${sessionId}: ${String(err instanceof Error ? err.message : err).slice(0, 160)}`, ); @@ -286,6 +294,7 @@ export interface MountStorageDeps { /** Injectable command/probe seams while retaining production unmountStorage behavior. */ unmountDeps?: UnmountStorageDeps; log?: (msg: string) => void; + signal?: AbortSignal; } /** @@ -304,6 +313,8 @@ export async function mountStorage( ): Promise { const log = deps.log ?? defaultLog; const checkMounted = deps.checkMounted ?? ((c: string) => isMounted(c, log)); + const signal = deps.signal; + throwIfAcquireAborted(signal); log( `mountStorage begin cwd=${cwd} bucket=${creds.bucket} prefix=${creds.prefix} ` + @@ -311,7 +322,7 @@ export async function mountStorage( `expiresAt=${creds.expiresAt ?? "(none)"}`, ); - if (await checkMounted(cwd)) { + if (await waitForAcquire(() => checkMounted(cwd), signal)) { log(`already mounted (verified alive): ${cwd}`); return true; } @@ -322,6 +333,7 @@ export async function mountStorage( ...deps.unmountDeps, log, }); + throwIfAcquireAborted(signal); if (!staleMountDetached) { throw new Error( "pre-mount detach could not be confirmed for " + @@ -388,11 +400,16 @@ export async function mountStorage( let failure: unknown; try { log(`geesefs mount argv: ${args.join(" ")}`); - const started = await run(args, env); + const started = await waitForAcquire(() => run(args, env), signal, { + onLateSuccess: async (lateAttempt) => { + await lateAttempt?.stop(); + await unmountStorage(cwd, { ...deps.unmountDeps, log }); + }, + }); attempt = started || undefined; // Confirm the new mount actually serves I/O — a still-not-alive cwd means geesefs failed // to mount (invalid STS creds, store unreachable) or did not come up within the poll window. - if (!(await checkMounted(cwd))) { + if (!(await waitForAcquire(() => checkMounted(cwd), signal))) { failure = new Error( `mount reported success but cwd is NOT alive ${creds.bucket}:${creds.prefix} -> ${cwd} ` + `— likely expired/invalid STS creds or store unreachable`, @@ -405,6 +422,18 @@ export async function mountStorage( failure = err; } + if (signal?.aborted) { + // Cleanup must not hold the Stop response open. A late `runGeesefs` result has its own hook + // above; an already-returned attempt is stopped here, and both paths confirm the detach. + void Promise.resolve() + .then(async () => { + await attempt?.stop(); + await unmountStorage(cwd, { ...deps.unmountDeps, log }); + }) + .catch(() => {}); + throwIfAcquireAborted(signal); + } + // Never detach/fallback while a failed geesefs attempt may still attach later. await attempt?.stop(); const detached = await unmountStorage(cwd, { ...deps.unmountDeps, log }); @@ -502,6 +531,7 @@ export interface TunnelDeps { ngrokApi?: string; fetchImpl?: typeof fetch; log?: (msg: string) => void; + signal?: AbortSignal; } /** @@ -523,7 +553,7 @@ export async function discoverTunnelEndpoint( process.env.AGENTA_MOUNTS_TUNNEL_API ?? "http://ngrok:4040"; try { - const res = await doFetch(`${api}/api/tunnels`); + const res = await doFetch(`${api}/api/tunnels`, { signal: deps.signal }); if (!res.ok) { log(`tunnel discovery HTTP ${res.status}`); return null; @@ -539,6 +569,7 @@ export async function discoverTunnelEndpoint( const any = tunnels.find((t) => !!t.public_url)?.public_url; return https ?? any ?? null; } catch (err) { + throwIfAcquireAborted(deps.signal); log( `tunnel discovery failed: ${String(err instanceof Error ? err.message : err).slice(0, 160)}`, ); @@ -569,6 +600,7 @@ export interface MountStorageRemoteDeps { */ aliveAttempts?: number; log?: (msg: string) => void; + signal?: AbortSignal; } /** @@ -586,25 +618,35 @@ async function remoteMountAlive( sandbox: SandboxExec, cwd: string, attempts: number, + signal?: AbortSignal, ): Promise { let consecutiveThrows = 0; for (let i = 0; i < attempts; i++) { + throwIfAcquireAborted(signal); try { - const res = await sandbox.runProcess({ - command: "sh", - args: [ - "-c", - `mountpoint -q ${shellQuote(cwd)} && ls ${shellQuote(cwd)} >/dev/null 2>&1`, - ], - timeoutMs: 5_000, - }); + const res = await waitForAcquire( + () => + sandbox.runProcess({ + command: "sh", + args: [ + "-c", + `mountpoint -q ${shellQuote(cwd)} && ls ${shellQuote(cwd)} >/dev/null 2>&1`, + ], + timeoutMs: 5_000, + }), + signal, + ); consecutiveThrows = 0; if (res?.exitCode === 0) return true; } catch { + throwIfAcquireAborted(signal); consecutiveThrows += 1; if (consecutiveThrows >= 2) break; } - await new Promise((r) => setTimeout(r, 500)); + await waitForAcquire( + () => new Promise((resolve) => setTimeout(resolve, 500)), + signal, + ); } return false; } @@ -649,28 +691,45 @@ export async function mountStorageRemote( deps: MountStorageRemoteDeps, ): Promise { const log = deps.log ?? defaultLog; + throwIfAcquireAborted(deps.signal); try { // A reattached running sandbox may still hold a FUSE mount with expired credentials. Detach // it before remounting; on a fresh sandbox this is one fast best-effort no-op. - await unmountRemoteDeadMount(sandbox, cwd, log); + await waitForAcquire( + () => unmountRemoteDeadMount(sandbox, cwd, log), + deps.signal, + ); // Ensure the directory exists before mounting. - await sandbox.runProcess({ - command: "sh", - args: ["-c", `mkdir -p ${shellQuote(cwd)}`], - timeoutMs: 30_000, - }); + await waitForAcquire( + () => + sandbox.runProcess({ + command: "sh", + args: ["-c", `mkdir -p ${shellQuote(cwd)}`], + timeoutMs: 30_000, + }), + deps.signal, + ); // Background geesefs with its logs to a file so the RPC returns immediately. const args = geesefsArgs(creds, cwd, deps.endpoint, false); const logFile = "/tmp/geesefs-mount.log"; const quotedArgs = args.map(shellQuote).join(" "); const geefsCmd = `geesefs --log-file ${shellQuote(logFile)} ${quotedArgs} >>${shellQuote(logFile)} 2>&1 &`; log(`remote geesefs argv: ${args.join(" ")}`); - const res = await sandbox.runProcess({ - command: "sh", - args: ["-c", geefsCmd], - env: credEnv(creds), - timeoutMs: deps.mountTimeoutMs ?? 60_000, - }); + const res = await waitForAcquire( + () => + sandbox.runProcess({ + command: "sh", + args: ["-c", geefsCmd], + env: credEnv(creds), + timeoutMs: deps.mountTimeoutMs ?? 60_000, + }), + deps.signal, + { + onLateSuccess: async () => { + await unmountRemoteDeadMount(sandbox, cwd, log); + }, + }, + ); if (res?.exitCode !== 0) { log( `remote mount exit=${res?.exitCode}: ${String(res?.stderr).slice(-300)}`, @@ -678,12 +737,23 @@ export async function mountStorageRemote( return false; } // The daemon backgrounds before the FUSE channel serves I/O, so wait for it. - if (!(await remoteMountAlive(sandbox, cwd, deps.aliveAttempts ?? 12))) { - const tail = await sandbox.runProcess({ - command: "sh", - args: ["-c", "tail -5 /tmp/geesefs-mount.log 2>/dev/null"], - timeoutMs: 10_000, - }); + if ( + !(await remoteMountAlive( + sandbox, + cwd, + deps.aliveAttempts ?? 12, + deps.signal, + )) + ) { + const tail = await waitForAcquire( + () => + sandbox.runProcess({ + command: "sh", + args: ["-c", "tail -5 /tmp/geesefs-mount.log 2>/dev/null"], + timeoutMs: 10_000, + }), + deps.signal, + ); log( `remote mount not alive ${creds.bucket}:${creds.prefix} -> ${cwd}` + `; geesefs: ${String(tail?.result ?? tail?.stderr ?? "").slice(-400)}`, @@ -698,6 +768,10 @@ export async function mountStorageRemote( ); return true; } catch (err) { + if (deps.signal?.aborted) { + void unmountRemoteDeadMount(sandbox, cwd, log); + throwIfAcquireAborted(deps.signal); + } log( `remote mount failed: ${String(err instanceof Error ? err.message : err).slice(0, 200)}`, ); @@ -715,6 +789,7 @@ export interface MountHarnessSessionDirsDeps { log?: (msg: string) => void; signSessionMountCredentials?: typeof signSessionMountCredentials; mountStorageRemote?: typeof mountStorageRemote; + signal?: AbortSignal; } /** @@ -747,6 +822,7 @@ export async function mountHarnessSessionDirs( authorization: deps.authorization, fetchImpl: deps.fetchImpl, log, + signal: deps.signal, }, dir.name, ); @@ -761,6 +837,7 @@ export async function mountHarnessSessionDirs( await mountRemote(sandbox, dir.path, creds, { endpoint: tunnelEndpoint, log, + signal: deps.signal, }); } } diff --git a/services/runner/src/engines/sandbox_agent/provider.ts b/services/runner/src/engines/sandbox_agent/provider.ts index ccda8ba8958..f312c23aa30 100644 --- a/services/runner/src/engines/sandbox_agent/provider.ts +++ b/services/runner/src/engines/sandbox_agent/provider.ts @@ -18,11 +18,41 @@ import { daytonaWithLifecycle, } from "./daytona-provider.ts"; import { daytonaWithProcessLocalSecrets } from "./daytona-secret-provider.ts"; +import type { DaytonaSecretLease } from "./daytona-secrets.ts"; import { assertDaytonaOpaqueSecretsEnabled, type DaytonaSecretPlan, } from "./daytona-secret-plan.ts"; +/** The port the Daytona provider passes to `sandbox-agent server`. */ +export const DAYTONA_SANDBOX_AGENT_PORT = 3_000; + +/** + * Recover the daemon port from the public sandbox handle id. + * + * Local ids are the daemon's `host:port`; Daytona ids are opaque, so use the explicit port this + * module gives that provider. Unknown providers stay undefined rather than borrowing a port. + */ +export function sandboxAgentServerPort( + sandboxId: string | undefined, +): number | undefined { + if (!sandboxId) return undefined; + const separator = sandboxId.indexOf("/"); + if (separator <= 0) return undefined; + const provider = sandboxId.slice(0, separator); + if (provider === "daytona") return DAYTONA_SANDBOX_AGENT_PORT; + if (provider !== "local") return undefined; + + try { + const port = Number(new URL(`http://${sandboxId.slice(separator + 1)}`).port); + return Number.isInteger(port) && port > 0 && port <= 65_535 + ? port + : undefined; + } catch { + return undefined; + } +} + /** * Translate the Layer 2 network policy into Daytona create fields. Daytona enforces egress * at the sandbox boundary: `networkBlockAll` blocks all outbound, `networkAllowList` is a @@ -143,7 +173,17 @@ export const PLANNED_SANDBOX_IDS = ["e2b"] as const; * `buildRunPlan` rejects restricted policies the local provider cannot enforce before this is * reached. A known-but-disabled provider is refused here too (defense-in-depth for callers that * bypass `buildRunPlan`). + * + * `options.inheritedLease` carries the Secret lease of a sandbox the credential preflight convicted + * as stuck. The rebuild is created against that same allocation, which is the case Daytona support + * confirmed works. See `acquireEnvironment`. */ +export interface BuildSandboxProviderOptions { + /** A detached lease from a sandbox this run already convicted. See `acquireEnvironment`. */ + inheritedLease?: DaytonaSecretLease; + config?: RunnerConfig; +} + export function buildSandboxProvider( sandboxId: string, env: Record, @@ -152,8 +192,9 @@ export function buildSandboxProvider( modelEnvironment: Record, sandboxPermission?: SandboxPermission, daytonaSecretPlan?: DaytonaSecretPlan, - config: RunnerConfig = loadRunnerConfig(), + options: BuildSandboxProviderOptions = {}, ) { + const config = options.config ?? loadRunnerConfig(); if ( (KNOWN_SANDBOX_PROVIDER_IDS as readonly string[]).includes(sandboxId) && !config.providers.enabled.includes(sandboxId as SandboxProviderId) @@ -179,6 +220,7 @@ export function buildSandboxProvider( daytonaWithLifecycle( { ...(image ? { image } : {}), + agentPort: DAYTONA_SANDBOX_AGENT_PORT, create: { ...createFields, ...(Object.keys(secretAttachments).length > 0 @@ -216,6 +258,11 @@ export function buildSandboxProvider( client.secret, { createFingerprint, + // Set only when the credential preflight convicted the previous sandbox of this run. + // The rebuild then mounts the SAME Secret instead of allocating a new one. + ...(options.inheritedLease + ? { inheritedLease: options.inheritedLease } + : {}), // Run slightly after Daytona's own auto-delete backstop. The timer first issues an // idempotent sandbox delete, then removes Secrets, preserving the hard deletion order. cleanupDelayMilliseconds: diff --git a/services/runner/src/engines/sandbox_agent/reap-exec.ts b/services/runner/src/engines/sandbox_agent/reap-exec.ts new file mode 100644 index 00000000000..fb489cad88e --- /dev/null +++ b/services/runner/src/engines/sandbox_agent/reap-exec.ts @@ -0,0 +1,313 @@ +/** + * Kill the shell command a STOPPED Codex turn left running inside the parked sandbox. + * + * WHY THIS EXISTS. `cancel-turn.ts` makes a Stop keep the sandbox warm. Parking is what makes + * this leak visible: before it, every Stop deleted the sandbox, and the delete killed whatever + * the turn had started. Measured on the integration stack, local sandbox provider, 2026-09-03: + * + * | Harness | ACP `session/cancel` answered | The shell child after the Stop | + * | --- | --- | --- | + * | Pi (`pi_core`) | yes | gone inside 0.2 s | + * | Claude Code | yes | gone inside 0.2 s | + * | Codex | yes, in 48 ms | STILL RUNNING, until the park window closed at 60 s | + * + * WHY CODEX DIFFERS, AND WHY THE FIX CANNOT LIVE IN THE BRIDGE. Pi and Claude run their shell + * tool inside a process the ACP adapter owns, so the adapter holds the child's pid and kills it + * when the run's `AbortSignal` fires. Codex does not: `@agentclientprotocol/codex-acp` is a thin + * JavaScript bridge over a Rust `codex app-server` subprocess, the shell child is a DIRECT child + * of that Rust process, and the bridge's `cancel()` only sends the `turn/interrupt` JSON-RPC + * request. Measured parent chain of the leaked child: + * + * python3 -c ... <- the leak + * codex app-server <- the Rust core, spawns and abandons it + * node .../codex.js <- the JS launcher + * node .../codex-acp <- the ACP bridge, holds NO pid for the shell + * sandbox-agent server <- the daemon + * + * The interrupt itself works: the prompt settles `cancelled` in about 48 ms. What the Rust core + * does not do is kill the exec it started, and that core is a stripped vendored binary we pin + * rather than build. A patch to the JS bridge would have to do the same `/proc` walk this module + * does, in a bundle that is installed into the sandbox image and therefore needs a Daytona + * SNAPSHOT REBUILD to ship. This module does it from the runner instead, through the sandbox + * daemon's one-off process API, so it ships in the runner image alone and behaves identically on + * the local and the Daytona provider. + * + * WHY IT IS SAFE FOR A WARM SESSION. The reap never touches the daemon, the ACP bridge, or the + * `codex app-server` itself, so the native harness session survives exactly as it did before. Two + * rules keep it off anything else the app-server legitimately owns, an stdio MCP server most of + * all: only DESCENDANTS of the app-server are candidates, and only those younger than the turn + * that was just stopped. An MCP server starts when the session is created, before the prompt, so + * it is always older than the turn and is never selected. + * + * WHY A FAILURE DESTROYS. A parked sandbox must not retain a command from the stopped turn. Only a + * successful kill or a successful inspection that finds nothing to reap proves parking is safe. + */ + +/** One row of `ps -eo pid=,ppid=,etimes=,args=`. */ +export interface ProcRow { + pid: number; + ppid: number; + /** Seconds since the process started. */ + etimes: number; + args: string; +} + +export const PS_ARGS = ["-eo", "pid=,ppid=,etimes=,args="]; + +/** + * How many processes one reap may kill. A Stop leaks one command; anything near this number means + * the anchor matched something it should not have, so the reap gives up rather than guessing. + */ +export const MAX_REAPED = 32; + +/** Parse `ps -eo pid=,ppid=,etimes=,args=`. An unparseable line is dropped, never guessed at. */ +export function parseProcessTable(stdout: string): ProcRow[] { + const rows: ProcRow[] = []; + for (const line of stdout.split("\n")) { + const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S.*)$/.exec(line); + if (!match) continue; + rows.push({ + pid: Number(match[1]), + ppid: Number(match[2]), + etimes: Number(match[3]), + args: match[4], + }); + } + return rows; +} + +/** Find exactly one `sandbox-agent server` whose `--port` value is this sandbox's port. */ +export function findSandboxAgentServerPid( + rows: ProcRow[], + port: number | undefined, +): number | undefined { + if (!Number.isInteger(port) || (port ?? 0) <= 0) return undefined; + const expectedPort = String(port); + const matches = rows.filter((row) => { + const [executable, ...rest] = row.args.split(/\s+/); + if (!executable) return false; + const basename = executable.split("/").pop(); + const portIndex = rest.indexOf("--port"); + return ( + basename === "sandbox-agent" && + rest.includes("server") && + portIndex >= 0 && + rest[portIndex + 1] === expectedPort + ); + }); + return matches.length === 1 ? matches[0].pid : undefined; +} + +/** + * Find the `codex app-server` process beneath this sandbox's daemon. + * + * The match is deliberately narrow: the executable's basename must be exactly `codex` AND the + * command must carry the `app-server` subcommand. The JS launcher (`node .../codex.js app-server`) + * also carries the subcommand, which is why the basename check is on the executable rather than + * anywhere in the string. Returns `undefined` when there is not exactly one match below the + * daemon, because killing on a guess is worse than leaving a `sleep` running for the park window. + */ +export function findAppServerPid( + rows: ProcRow[], + sandboxAgentPid: number, +): number | undefined { + const childrenOf = new Map(); + for (const row of rows) { + const siblings = childrenOf.get(row.ppid); + if (siblings) siblings.push(row); + else childrenOf.set(row.ppid, [row]); + } + + const descendants: ProcRow[] = []; + const seen = new Set([sandboxAgentPid]); + const queue = [sandboxAgentPid]; + while (queue.length > 0) { + const parent = queue.shift() as number; + for (const child of childrenOf.get(parent) ?? []) { + if (seen.has(child.pid) || child.pid <= 1) continue; + seen.add(child.pid); + queue.push(child.pid); + descendants.push(child); + } + } + + const matches = descendants.filter((row) => { + const [executable, ...rest] = row.args.split(/\s+/); + if (!executable) return false; + const basename = executable.split("/").pop(); + return basename === "codex" && rest.includes("app-server"); + }); + return matches.length === 1 ? matches[0].pid : undefined; +} + +/** + * The pids a settled Codex Stop may kill. + * + * A candidate must be a descendant of the `codex app-server` process and must have started no + * earlier than the stopped turn. Everything else, the app-server included, is left alone. + */ +export function selectLeakedExecPids( + rows: ProcRow[], + input: { appServerPid: number; turnElapsedSeconds: number }, +): number[] { + const childrenOf = new Map(); + for (const row of rows) { + const siblings = childrenOf.get(row.ppid); + if (siblings) siblings.push(row); + else childrenOf.set(row.ppid, [row]); + } + + const selected: number[] = []; + const seen = new Set([input.appServerPid]); + const queue = [input.appServerPid]; + while (queue.length > 0) { + const parent = queue.shift() as number; + for (const child of childrenOf.get(parent) ?? []) { + if (seen.has(child.pid) || child.pid <= 1) continue; + seen.add(child.pid); + queue.push(child.pid); + // `etimes` is whole seconds, so a child started in the same second as the prompt reads + // equal to the turn's elapsed time. `<=` keeps that child; anything OLDER than the turn + // predates the prompt and belongs to the session, not to the turn that was stopped. + if (child.etimes <= input.turnElapsedSeconds) selected.push(child.pid); + } + } + return selected; +} + +export interface ReapSandbox { + runProcess?: (request: { + command: string; + args?: string[]; + timeoutMs?: number; + maxOutputBytes?: number; + }) => Promise<{ stdout: string; exitCode?: number | null }>; +} + +export interface ReapLeakedExecInput { + sandbox: ReapSandbox | undefined; + /** Port passed to this sandbox's `sandbox-agent server --port`. */ + sandboxAgentPort: number | undefined; + /** Milliseconds from the prompt being issued to the cancel settling. */ + turnElapsedMs: number; + log: (message: string) => void; + timeoutMs?: number; +} + +export interface ReapResult { + /** How many processes the reap killed. */ + killed: number; + /** Why nothing was killed, when nothing was. */ + skipped?: + | "no-run-process" + | "ps-failed" + | "no-app-server" + | "nothing-to-reap" + | "too-many" + | "kill-failed"; +} + +/** True when best-effort cleanup needs QA follow-up. */ +export function reapResultHasCleanupMiss( + result: ReapResult | undefined, +): boolean { + return ( + !result || (result.killed === 0 && result.skipped !== "nothing-to-reap") + ); +} + +/** + * Best effort. Never throws, and every outcome is one log line the release gate can assert on. + */ +export async function reapLeakedExecChildren( + input: ReapLeakedExecInput, +): Promise { + const runProcess = input.sandbox?.runProcess; + if (!runProcess) { + input.log("stage=harness_reap killed=0 skipped=no-run-process"); + return { killed: 0, skipped: "no-run-process" }; + } + const timeoutMs = input.timeoutMs ?? 2_000; + + let rows: ProcRow[]; + try { + const listing = await runProcess.call(input.sandbox, { + command: "ps", + args: PS_ARGS, + timeoutMs, + maxOutputBytes: 256 * 1024, + }); + rows = parseProcessTable(listing.stdout ?? ""); + if (rows.length === 0) throw new Error("no parseable rows"); + } catch (error) { + // A sandbox image without a compatible `ps` cannot prove that parking is safe. + input.log( + "stage=harness_reap killed=0 skipped=ps-failed error=" + + (error instanceof Error ? error.message : String(error)).slice(0, 120), + ); + return { killed: 0, skipped: "ps-failed" }; + } + + const sandboxAgentPid = findSandboxAgentServerPid( + rows, + input.sandboxAgentPort, + ); + const appServerPid = + sandboxAgentPid === undefined + ? undefined + : findAppServerPid(rows, sandboxAgentPid); + if (appServerPid === undefined) { + input.log("stage=harness_reap killed=0 skipped=no-app-server"); + return { killed: 0, skipped: "no-app-server" }; + } + + // FLOOR, not round or ceil. Every rounding error must make the reap kill LESS. On a cold first + // turn the session's own helpers (Codex clones its plugin repo) start barely a second before + // the prompt, so one second of generosity here is one second of overlap with processes the + // session owns. A child born in the first second of a turn is not physically possible: the + // model has to emit a tool call first. + const turnElapsedSeconds = Math.floor( + Math.max(0, input.turnElapsedMs) / 1000, + ); + const pids = selectLeakedExecPids(rows, { + appServerPid, + turnElapsedSeconds, + }); + if (pids.length === 0) { + input.log( + `stage=harness_reap killed=0 skipped=nothing-to-reap app_server=${appServerPid}`, + ); + return { killed: 0, skipped: "nothing-to-reap" }; + } + if (pids.length > MAX_REAPED) { + input.log( + `stage=harness_reap killed=0 skipped=too-many candidates=${pids.length} ` + + `limit=${MAX_REAPED} app_server=${appServerPid}`, + ); + return { killed: 0, skipped: "too-many" }; + } + + try { + const result = await runProcess.call(input.sandbox, { + command: "kill", + args: ["-9", ...pids.map(String)], + timeoutMs, + maxOutputBytes: 4 * 1024, + }); + if (result.exitCode != null && result.exitCode !== 0) { + throw new Error(`kill exited with status ${result.exitCode}`); + } + } catch (error) { + input.log( + "stage=harness_reap killed=0 skipped=kill-failed error=" + + (error instanceof Error ? error.message : String(error)).slice(0, 120), + ); + return { killed: 0, skipped: "kill-failed" }; + } + + input.log( + `stage=harness_reap killed=${pids.length} pids=${pids.join(",")} ` + + `app_server=${appServerPid} turn_elapsed_s=${turnElapsedSeconds}`, + ); + return { killed: pids.length }; +} diff --git a/services/runner/src/engines/sandbox_agent/reconstruct-history.ts b/services/runner/src/engines/sandbox_agent/reconstruct-history.ts index cacc4d2e5bf..dcfd3188c3e 100644 --- a/services/runner/src/engines/sandbox_agent/reconstruct-history.ts +++ b/services/runner/src/engines/sandbox_agent/reconstruct-history.ts @@ -30,6 +30,16 @@ export interface ReconstructHistoryOptions { restore?: (messages: ChatMessage[]) => Promise; } +function isLegacyWholeBodyTruncation(row: { attributes?: unknown }): boolean { + const attributes = row.attributes; + return ( + !!attributes && + typeof attributes === "object" && + (attributes as { _truncated?: unknown })._truncated === true && + !("type" in attributes) + ); +} + // Compose passes `${AGENTA_SESSIONS_RECONSTRUCT:-}`, so an empty value must mean on just like an // absent value. Only the literal "false" disables reconstruction. function reconstructEnabled(): boolean { @@ -100,6 +110,11 @@ export async function reconstructHistoryIfNeeded( const prior = currentTurnId ? records.filter((row) => row.turn_id !== currentTurnId) : records; + if (prior.some(isLegacyWholeBodyTruncation)) { + throw new Error( + `session ${sessionId} contains a truncated durable record; refusing to rebuild an incomplete conversation`, + ); + } // Reachable in practice: a caller that builds its answer from the durable interaction row can // echo the row's stored `turn_id`, which drops exactly the turn that parked. if (prior.length === 0) { diff --git a/services/runner/src/engines/sandbox_agent/run-limits.ts b/services/runner/src/engines/sandbox_agent/run-limits.ts index d360c3e43b6..a8b8c34d867 100644 --- a/services/runner/src/engines/sandbox_agent/run-limits.ts +++ b/services/runner/src/engines/sandbox_agent/run-limits.ts @@ -37,9 +37,11 @@ export const TOOL_CALL_TIMEOUT_ENV = "AGENTA_RUNNER_TOOL_CALL_TIMEOUT_MS"; // every mount-backed warm session rebuild cold. The ~1h gap under the 12h lease is // the warm parking window. export const DEFAULT_TOTAL_DEADLINE_MS = 11 * 60 * 60_000; // 11 hours -export const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60_000; // 30 min +// 30 minutes; override with AGENTA_RUNNER_RUN_IDLE_TIMEOUT_MS. +export const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60_000; export const DEFAULT_TTFB_TIMEOUT_MS = 2 * 60_000; // 2 min -export const DEFAULT_TOOL_CALL_TIMEOUT_MS = 30 * 60_000; // 30 min +// 30 minutes; override with AGENTA_RUNNER_TOOL_CALL_TIMEOUT_MS. +export const DEFAULT_TOOL_CALL_TIMEOUT_MS = 30 * 60_000; /** Every field is a usable timer delay (integer ms, at least 1, within Node's timer range) — * `resolveRunLimits` guarantees it, so callers can arm any of them without re-checking. */ diff --git a/services/runner/src/engines/sandbox_agent/run-plan.ts b/services/runner/src/engines/sandbox_agent/run-plan.ts index a0255d23d98..c5dab5cd026 100644 --- a/services/runner/src/engines/sandbox_agent/run-plan.ts +++ b/services/runner/src/engines/sandbox_agent/run-plan.ts @@ -42,6 +42,7 @@ import { daytonaOpaqueSecretsEnabled, type DaytonaSecretPlan, } from "./daytona-secret-plan.ts"; +import { materializeSandboxCredentials } from "./sandbox-credentials.ts"; type Log = (message: string) => void; @@ -106,6 +107,7 @@ export const LOCAL_SUBSCRIPTION_MOUNT_MISSING_MESSAGE = export interface RunPlanCredentials { /** Final plaintext model environment, after validating modelConnection. */ modelEnvironment: Record; + sandboxEnvironment: Record; /** * Process-local opaque credential plan. Present for every Daytona run unless credential * hiding was switched off with AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS, and present even with @@ -532,6 +534,8 @@ export function buildRunPlan( const materializedModel = materializeModelEnvironment(request); if (!materializedModel.ok) return materializedModel; + const materializedSandbox = materializeSandboxCredentials(request); + if (!materializedSandbox.ok) return materializedSandbox; // Daytona opaque-credential delivery is ON by default and switched off only by // AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS. Switched OFF: no secret plan is built at all, so // behavior is identical to the pre-feature runner — the full materialized environment reaches @@ -749,6 +753,7 @@ export function buildRunPlan( isDaytona, credentials: { modelEnvironment, + sandboxEnvironment: materializedSandbox.environment, daytonaSecretPlan, harnessApiKeyVar, // Consult the FULL materialized environment: on a Daytona Secrets run the opaque key is diff --git a/services/runner/src/engines/sandbox_agent/run-turn.ts b/services/runner/src/engines/sandbox_agent/run-turn.ts index b72265165ab..dfa7f09702b 100644 --- a/services/runner/src/engines/sandbox_agent/run-turn.ts +++ b/services/runner/src/engines/sandbox_agent/run-turn.ts @@ -67,6 +67,14 @@ import { CREDENTIAL_RACE_REPORTS_PER_SESSION, withinCredentialPropagationWindow, } from "./errors.ts"; +import { noteExecutionSettled } from "../../sessions/execution-registry.ts"; +import { isUserStopAbort } from "../../sessions/stop-signal.ts"; +import { cancelHarnessTurn } from "./cancel-turn.ts"; +import { + reapLeakedExecChildren, + reapResultHasCleanupMiss, +} from "./reap-exec.ts"; +import { sandboxAgentServerPort } from "./provider.ts"; import { PAUSED, PendingApprovalPauseController } from "./pause.ts"; import { capturePiTranscriptCursor, @@ -80,6 +88,12 @@ import { createCommitAuthorizationState, } from "./approved-content.ts"; import { createRunLimits, resolveRunLimits } from "./run-limits.ts"; +import { + httpLivenessProbe, + resolveSandboxLivenessLimits, + sandboxHealthUrl, + startSandboxLivenessProbe, +} from "./sandbox-liveness.ts"; import { RUN_LIMIT_TRIPPED, sendLastMessageOnly, @@ -147,6 +161,12 @@ export async function runTurn( // heartbeat aborts `signal`). Distinct from PAUSED/RUN_LIMIT_TRIPPED so the turn ends CLEANLY // (honest interrupted transcript, keep-warm) instead of falling through to the error catch. const CANCELLED = Symbol("cancelled"); + /** + * Did the harness confirm it stopped? Set only on the cancelled path, and only when the ACP + * cancel was sent AND the harness answered its open prompt inside the settle budget. It rides + * out on the result because it is the one fact that decides park versus delete for a Stop. + */ + let cancelSettled = false; const continuityStore = deps.sessionContinuityStore ?? sessionContinuityStore; /** * Should a credential refusal this turn be reported as a delivery race rather than a bad key? @@ -212,7 +232,8 @@ export async function runTurn( // A fresh turn never inherits an approval. Only a resume may consume records minted before // the park; anything else starts empty, so no call can execute on the strength of an approval // raised for an earlier turn. - if (!opts.resume) env.commitAuthorization = undefined; + if (!opts.resume && !opts.settleApprovalsThenPrompt) + env.commitAuthorization = undefined; env.nonParkablePauseCount = 0; // Hoisted so the catch can flush a partial trace (mirroring the pre-split `otel?` handling — // a createOtel throw must still return `{ ok: false }`, not propagate raw) and the finally can @@ -251,6 +272,33 @@ export async function runTurn( runLimitTrip?.(); }); + // The run limits above cannot see a sandbox that DIED under the turn: the ACP prompt they + // race against never settles once the peer is gone, and `notePaused()` retires them entirely + // while a turn waits for a human. So probe the sandbox's own HTTP surface, independently of + // the wedged ACP channel, and end the turn through the same trip path any other limit uses. + // See `sandbox-liveness.ts` and issue #6418. + // A remote sandbox does not refuse the socket when it dies: its provider's proxy answers for it + // with "sandbox not found" indefinitely, which the poll reads as alive. So the turn's own + // ACP transport reports that answer on `env.sandboxGone`, and the probe ends the turn on it at + // once. That path needs no health URL, so it is wired even when the poll is disabled. + const sandboxHealth = sandboxHealthUrl(env.sandbox); + const sandboxLiveness = startSandboxLivenessProbe({ + ...(sandboxHealth ? { probe: httpLivenessProbe(sandboxHealth) } : {}), + goneSignal: env.sandboxGone, + limits: resolveSandboxLivenessLimits(logger), + onGone: (reason: string) => { + runLimitReason = reason; + runLimitTrip?.(); + }, + log: logger, + }); + if (!sandboxHealth) { + logger( + "[sandbox-liveness] no health URL on this sandbox; polling disabled " + + "(the transport's own sandbox-gone report still ends the turn)", + ); + } + try { // AGENTA_SESSIONS_RECONSTRUCT defaults on so minimal-history clients keep their conversation; // only the literal "false" opts out. The compose default supplies an empty string, not "true". @@ -1076,10 +1124,15 @@ export async function runTurn( // byte-exact args). Either way, on a HITL pause the prompt resolves cancelled or never // resolves, and the pause signal ends the turn. let promptPromise: Promise; - if (opts.resume) { + // When the prompt was issued, so a reap after a Stop can tell a process this turn started + // from one the SESSION started earlier (an stdio MCP server). A resumed turn keeps the + // resume's own start, which only ever makes the reap more conservative. See `reap-exec.ts`. + let promptStartedAtMs = Date.now(); + const approvalTransition = opts.resume ?? opts.settleApprovalsThenPrompt; + if (approvalTransition) { // The resume turn owns continued events; each decision answers one parked gate by id. // Carried gates keep the shared original prompt pending until a later answer. - const decisions = opts.resume.decisions; + const decisions = approvalTransition.decisions; promptPromise = Promise.resolve(decisions[0]?.promptPromise); promptPromise.catch(() => {}); for (const seed of carriedApprovedExecutions) { @@ -1145,7 +1198,7 @@ export async function runTurn( // refresh the carried gates' approval TTL. Pi is exempt on purpose: it prepares the whole // batch before executing any call, so while a carried sibling gate is pending closure is // impossible and the paused-settle's park-and-carry branch owns those spans. - if (opts.resume.carriedForward.length > 0) { + if (opts.resume && opts.resume.carriedForward.length > 0) { if (!plan.isPi) { const answeredAllowedIds = decisions .filter((decision) => decision.reply === "once") @@ -1159,13 +1212,20 @@ export async function runTurn( pause.pause(); } } else { + promptStartedAtMs = Date.now(); promptPromise = Promise.resolve(env.session.prompt(promptBlocks)); promptPromise.catch(() => {}); } - // A user Stop aborts `signal`, which severs the harness fetch (rejecting the prompt). We want a - // clean cancel, not an error: resolve the race to CANCELLED both when the abort event lands first - // AND when the prompt rejection lands first while already aborted, so the outcome is deterministic - // regardless of ordering. A real (non-abort) prompt rejection is re-thrown into the shared catch. + // A user Stop aborts `signal`. That abort does NOT reach the harness: the signal is handed to + // `SandboxAgent.start` for its health wait only, never to the ACP transport or the prompt + // request, so the prompt promise below stays pending and the harness keeps working. (An earlier + // comment here claimed the abort severed the harness fetch. It does not, which is why the + // cancelled branch has to send a real `session/cancel` — see `cancel-turn.ts`.) + // + // So the race is won by the abort event itself. Resolve to CANCELLED both when the abort lands + // first AND when the prompt rejection lands first while already aborted, so the outcome is + // deterministic regardless of ordering. A real (non-abort) prompt rejection is re-thrown into + // the shared catch. const cancelled = new Promise((resolve) => { if (signal?.aborted) resolve(CANCELLED); else @@ -1173,26 +1233,55 @@ export async function runTurn( once: true, }); }); - const raced = await Promise.race([ - promptPromise.then( - (value) => value, - (err) => (signal?.aborted ? CANCELLED : Promise.reject(err)), - ), - pause.signal.then(() => PAUSED), - runLimitTripped.then(() => RUN_LIMIT_TRIPPED), - cancelled, - ]); + const racePrompt = (pending: Promise) => + Promise.race([ + pending.then( + (value) => value, + (err) => (signal?.aborted ? CANCELLED : Promise.reject(err)), + ), + pause.signal.then(() => PAUSED), + runLimitTripped.then(() => RUN_LIMIT_TRIPPED), + cancelled, + ]); + let raced = await racePrompt(promptPromise); + if ( + opts.settleApprovalsThenPrompt && + raced !== PAUSED && + raced !== RUN_LIMIT_TRIPPED && + raced !== CANCELLED && + !pause.active + ) { + // The request ends in a NEW user turn. Finish applying the interaction decision to the old + // prompt first, then make the request's actual work a regular prompt. Without this second + // prompt the runner silently answers the old denied tool call and drops the new text. The + // old prompt was raced above, so a harness that opened another gate after the denial pauses + // this turn instead of hanging unwatched. `continuation` makes promptBlocks the fresh tail. + promptStartedAtMs = Date.now(); + promptPromise = Promise.resolve(env.session.prompt(promptBlocks)); + promptPromise.catch(() => {}); + raced = await racePrompt(promptPromise); + } // A tripped run-limit ends the turn as an error: throw into the shared catch below so the // trace is flushed and the caller's teardown reclaims the (wedged) sandbox. if (raced === RUN_LIMIT_TRIPPED) { throw new Error(runLimitReason ?? "run limit tripped"); } - const stopReason = + let stopReason = raced === CANCELLED ? "cancelled" : raced === PAUSED || pause.active ? "paused" : (raced as any)?.stopReason; + // THE TURN'S OWN WORK IS OVER HERE. Everything below is teardown: draining gates, writing + // the transcript, exporting the trace, deciding whether to park. That takes hundreds of + // milliseconds, and the execution stays registered for all of it, so a Stop arriving now + // would abort a run that has already finished. The abort would change no outcome and would + // still make the teardown treat the run as aborted, which DESTROYS the warm environment + // instead of parking it. Marked here rather than where the caller awaits this function, + // because that window is precisely what lies between the two. + if (stopReason !== "paused" && request.sessionId && request.turnId) { + noteExecutionSettled(request.sessionId, request.turnId); + } // Terminalization drains queued gates, classifies pause-time completions, and gives allowed // executions their original per-call bound before the orphan sweep closes the turn. if (stopReason === "paused") { @@ -1210,11 +1299,19 @@ export async function runTurn( const openAllowedExecutions = openToolCallIds().filter( (id) => pause.isAllowedExecution(id) && !pause.isPausedToolCall(id), ); + // NOT scoped to a resume. Pi batches on the FIRST turn too, and the first turn is where a + // user meets it: the model asks for a Read and a Bash together, the Read answers `allow` + // and the Bash parks, and the allowed Read then never closes because Pi will not execute + // any call in the batch while a sibling gate is open. With `opts.resume` in this predicate + // that turn took the wait below and sat on the 30-minute per-tool-call bound. It never + // parked, never emitted `done`, and its alive watchdog kept beating `running=true`, so + // every durable continuation aimed at the next turn was refused for want of ownership. + // (Browser pass 2026-09-04, sessions d66e2920 at 17:32Z and 6d06f624 at 17:57Z. A healthy + // gated turn shows the Read's `tool_result` BEFORE the Bash gate — sequential, so nothing + // is open at pause time. The two failures show the two gates back to back with no result + // between them, which is the parallel batch.) const piBatchBlockedByApproval = Boolean( - opts.resume && - plan.isPi && - opts.approvalParkMode && - env.parkedApprovals.size > 0, + plan.isPi && opts.approvalParkMode && env.parkedApprovals.size > 0, ); if (piBatchBlockedByApproval) { // Pi prepares every call in a parallel batch before it executes any of them. While a @@ -1260,8 +1357,57 @@ export async function runTurn( unexpectedOpenToolCallIds.join(","), ); } + + if (isUserStopAbort(signal)) { + stopReason = "cancelled"; + } + if (request.sessionId && request.turnId) { + noteExecutionSettled(request.sessionId, request.turnId); + } } if (stopReason === "cancelled") { + env.parkedApprovals.clear(); + env.parkedApproval = undefined; + env.approvalGateCount = 0; + parkedApprovedExecutions.clear(); + // Tell the HARNESS to stop before anything else. The abort only made the runner stop + // waiting; without this the harness still holds an open prompt and a running tool, and the + // sandbox could never be parked. A settled cancel is what earns the warm park below; see + // `cancel-turn.ts`. + const cancel = await cancelHarnessTurn({ + sandbox: env.sandbox, + sessionId: env.session?.id, + promptPromise, + log: logger, + }); + cancelSettled = cancel.settled; + // Codex leaves its shell child running inside the sandbox we are about to park; Pi and + // Claude kill theirs. Reap it here, never in the bridge: the Codex shell is a child of a + // vendored Rust binary the JS bridge holds no pid for, and a bridge patch would ship only + // through a Daytona snapshot rebuild. This cleanup is best effort; the stopped TTL bounds + // leftovers without changing the harness-confirmed park and continuity decision. + if (cancel.settled && plan.acpAgent === "codex") { + let reapError: unknown; + const reap = await reapLeakedExecChildren({ + sandbox: env.sandbox, + sandboxAgentPort: sandboxAgentServerPort(env.sandbox?.sandboxId), + turnElapsedMs: Date.now() - promptStartedAtMs, + log: logger, + }).catch((error) => { + reapError = error; + return undefined; + }); + if (reapResultHasCleanupMiss(reap)) { + logger( + `stage=harness_reap cleanup_miss=true skipped=${reap?.skipped ?? "unknown"}` + + (reapError ? ` error=${String(reapError).slice(0, 120)}` : ""), + ); + } + } + // The harness has been asked to stop, so the Pi trace port and the environment teardown must + // not ask again. Their `destroySession` also aborts `env.mcpAbort`, which belongs to the + // ENVIRONMENT and must survive a park (the approval-park path skips it for the same reason). + if (cancel.requested) env.sessionDestroyRequested = true; // The user Stopped the turn: let any in-flight frames settle, honor real completions that // already arrived, then settle every STILL-open tool call with the interrupt sentinel so the // transcript closes HONESTLY — no orphaned "running" parts, no synthetic success. A deliberate @@ -1374,11 +1520,27 @@ export async function runTurn( return { ok: false, error: swallowedError }; } - // A pause has not finished authoring the turn, so only a completed execution can advance the - // in-memory resume pointer or complete the durable ledger row. + // Which endings are a faithful resume point, and may therefore advance the in-memory resume + // pointer and complete the durable ledger row. + // + // - A completed execution, as it always has been. + // - A user Stop the HARNESS confirmed. `cancelSettled` is the same proof that earns the warm + // park in `shouldPark`: the harness answered the cancelled prompt, so it is idle and its + // native transcript holds a short but FINISHED turn. Nothing more will be written into it. + // + // A stopped turn has to take this path, not just the park, because the park alone is + // process-local. `hydrateHarnessSessionFromDurable` refuses to re-seed the store from a row + // without `end_time`, so a Stop used to leave its row forever incomplete and the session lost + // its native harness session on the next runner restart or pool eviction: the rebuild went + // cold and the conversation survived only as replayed text. + // + // Still dropped, unchanged: a pause has not finished authoring the turn, and an UNSETTLED + // cancel leaves the harness in an unknown state, possibly still writing. Both fall back to + // cold replay, which is the always-correct floor. + const turnIsResumePoint = + stopReason !== "paused" && (stopReason !== "cancelled" || cancelSettled); if ( - stopReason !== "paused" && - stopReason !== "cancelled" && + turnIsResumePoint && env.continuityTurnIndex !== undefined && sessionId ) { @@ -1405,7 +1567,8 @@ export async function runTurn( ).catch(() => {}); } } else if (stopReason === "paused" || stopReason === "cancelled") { - // A pause/cancel stopped mid-turn, after the harness may have written a partial turn natively. + // A pause, or a cancel the harness never confirmed: the turn stopped mid-write, so the + // native transcript may hold a partial turn nobody can describe. invalidateContinuity(sessionId, plan.harness, deps); } @@ -1416,6 +1579,7 @@ export async function runTurn( events: emit ? [] : run.events(), usage, stopReason, + ...(stopReason === "cancelled" ? { cancelSettled } : {}), capabilities: { ...env.capabilities, streamingDeltas: !!emit && env.capabilities.streamingDeltas, @@ -1472,6 +1636,8 @@ export async function runTurn( void settleInBandInteractions?.(); // Release every run-limits timer (idempotent, never re-arms on a late event) on EVERY path. runLimits.dispose(); + // Same contract for the sandbox liveness probe: one timer, released on EVERY path. + sandboxLiveness?.dispose(); // This turn owns its relay: stop it on EVERY exit path (the happy path already stopped it // after the prompt; stop is safe to repeat, matching the old finally). Null it afterwards so // a later `destroy()` — possibly after the dispatch cleared the sink — cannot double-stop or diff --git a/services/runner/src/engines/sandbox_agent/runtime-contracts.ts b/services/runner/src/engines/sandbox_agent/runtime-contracts.ts index 47c107f672a..c3217532990 100644 --- a/services/runner/src/engines/sandbox_agent/runtime-contracts.ts +++ b/services/runner/src/engines/sandbox_agent/runtime-contracts.ts @@ -190,12 +190,18 @@ export interface RunTurnOptions { continuation?: boolean; /** * The session was rehydrated via `session/load` (the patched `resumeSession`), so the harness - * already holds the prior turns natively. Like `continuation`, the prompt is only the new user - * text; `buildTurnText` must not run. Distinct field from `continuation` because the two arrive - * through different acquire paths (live pool checkout vs a fresh cold acquire that loaded an - * old session) — `runTurn` treats them identically for the text-selection decision. + * accepted the prior native session id. This is deliberately weaker than proof that prior turns + * were replayed; `nativeHistoryVerified` supplies that proof. Distinct from `continuation` + * because the two arrive through different acquire paths (live pool checkout vs a fresh cold + * acquire that attempted to load an old session). */ loaded?: boolean; + /** + * The native load produced observable prior-message events. `loaded` alone only proves the + * adapter accepted the requested id; without this proof the reconstructed transcript remains + * authoritative and must be replayed. + */ + nativeHistoryVerified?: boolean; /** * Keep-alive approval park mode: on a parkable ACP permission gate the pause keeps the session * alive (no settle/abort/destroy) so a later resume can answer it. A non-parkable pause (Pi @@ -211,15 +217,25 @@ export interface RunTurnOptions { decisions: ResumeApprovalInput[]; carriedForward: ParkedApproval[]; }; + /** + * Settle the parked gate first, then send this request's fresh user tail as a normal prompt on + * the same warm session. Unlike `resume`, this does not make the old prompt the request's turn: + * its decision is context for the new prompt rather than the turn's terminal interaction. + */ + settleApprovalsThenPrompt?: { + decisions: ResumeApprovalInput[]; + }; } /** * Send only the new user text (not the full cold transcript) when the harness already holds the - * prior turns: a live continuation, or a session rehydrated via `session/load`. `runTurn` calls - * this, so a test that pins it pins the shipped decision. + * prior turns: a live continuation, or a `session/load` that emitted observable prior-message + * events. `runTurn` calls this, so a test that pins it pins the shipped decision. */ export function sendLastMessageOnly(opts: RunTurnOptions): boolean { - return Boolean(opts.continuation || opts.loaded); + return Boolean( + opts.continuation || (opts.loaded && opts.nativeHistoryVerified), + ); } /** @@ -280,6 +296,13 @@ export interface SessionEnvironment { plan: RunPlan; logger: Log; deps: SandboxAgentDeps; + /** + * Set once this environment's sandbox is known to be gone, by the ACP transport that talks to + * it. A remote provider answers for a deleted sandbox instead of refusing the socket, so this + * report is often the only evidence of the death that arrives at all. `run-turn.ts` hands the + * latch to the liveness probe, which is what ends the turn. See `sandbox-gone.ts`. + */ + sandboxGone?: import("./sandbox-gone.ts").SandboxGoneLatch; sandbox: any; session: any; sessionId: string; @@ -311,6 +334,10 @@ export interface SessionEnvironment { projectScopeId?: string; /** This acquire resumed the harness's native session via `session/load` (not cold). */ loadedFromContinuity: boolean; + /** The load emitted at least one prior conversation event, proving native history is present. */ + nativeHistoryVerified: boolean; + /** The native transcript path survives this environment's teardown and a later cold rebuild. */ + nativeHistoryDurable: boolean; /** A remote, session-owned run whose sandbox can be parked (warm) rather than deleted at end. */ resumable: boolean; /** @@ -406,8 +433,10 @@ export type AcquireEnvironmentResult = /** * The preflight proved this sandbox never got its Secret substitution wiring (the fault * is binary per sandbox and permanent — see credential-preflight.ts). The environment is - * already destroyed; the acquire wrapper retries once with a fresh sandbox, because a new - * sandbox on the same Secret works. + * already destroyed; the acquire wrapper rebuilds a fresh sandbox on the SAME Secret, + * which is the case Daytona support confirmed works. The Secret lease that makes the + * rebuild possible never reaches this type: it is internal to the acquire loop, which + * owns it for the whole run and releases it before returning. See `AcquireAttemptResult`. */ stuckSubstitution?: boolean; }; diff --git a/services/runner/src/engines/sandbox_agent/sandbox-credentials.ts b/services/runner/src/engines/sandbox_agent/sandbox-credentials.ts new file mode 100644 index 00000000000..a9db1073087 --- /dev/null +++ b/services/runner/src/engines/sandbox_agent/sandbox-credentials.ts @@ -0,0 +1,55 @@ +import type { AgentRunRequest } from "../../protocol.ts"; + +const ENVIRONMENT_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; + +export const RESERVED_SANDBOX_CREDENTIAL_NAMES: ReadonlySet = new Set([ + "PATH", "HOME", "LD_PRELOAD", "NODE_OPTIONS", "PYTHONPATH", + "PI_CODING_AGENT_DIR", "PI_CODING_AGENT_SESSION_DIR", "PI_ACP_PI_COMMAND", + "CODEX_HOME", "CODEX_SQLITE_HOME", "CLAUDE_CONFIG_DIR", + "AGENTA_AGENT_TOOLS_RELAY_DIR", "AGENTA_AGENT_TOOLS_PUBLIC_SPECS_FILE", + "AGENTA_AGENT_TOOLS_RELAY_RESPONSE_WATCH_ENABLED", + "AGENTA_AGENT_TELEMETRY_CONTROL_PATH", "AGENTA_AGENT_MODEL_PROVIDER_OVERRIDE", + "AGENTA_AGENT_BUILTIN_ACTIVATION", "AGENTA_AGENT_BUILTIN_GATING", + "AGENTA_AGENT_USAGE_CAPTURE_PATH", "ENABLE_TOOL_SEARCH", +]); + +const RESERVED_SANDBOX_CREDENTIAL_PREFIXES = [ + "AGENTA_AGENT_", "SANDBOX_AGENT_", "PI_CODING_AGENT_", +] as const; + +export type SandboxCredentialsResult = + | { ok: true; environment: Record } + | { ok: false; error: string }; + +export function materializeSandboxCredentials(request: AgentRunRequest): SandboxCredentialsResult { + const environment: Record = {}; + const occupied = new Set([ + ...Object.keys(request.modelConnection?.environment ?? {}), + ...(request.modelConnection?.credentials ?? []).map((credential) => credential.binding?.name), + ]); + + for (const credential of request.sandboxCredentials ?? []) { + const name = credential?.binding?.name; + if (credential?.binding?.kind !== "environment" || !name) { + return { ok: false, error: "sandboxCredentials require environment bindings with non-empty names" }; + } + if (!ENVIRONMENT_NAME.test(name)) { + return { ok: false, error: `sandboxCredentials binding '${name}' is not a valid environment variable name` }; + } + if (typeof credential.value !== "string" || credential.value.length === 0) { + return { ok: false, error: `sandboxCredentials binding '${name}' requires a non-empty value` }; + } + if ( + RESERVED_SANDBOX_CREDENTIAL_NAMES.has(name) || + RESERVED_SANDBOX_CREDENTIAL_PREFIXES.some((prefix) => name.startsWith(prefix)) + ) { + return { ok: false, error: `sandboxCredentials binding '${name}' is reserved by the runtime` }; + } + if (occupied.has(name)) { + return { ok: false, error: `sandboxCredentials binding '${name}' collides with another environment owner` }; + } + occupied.add(name); + environment[name] = credential.value; + } + return { ok: true, environment }; +} diff --git a/services/runner/src/engines/sandbox_agent/sandbox-gone.ts b/services/runner/src/engines/sandbox_agent/sandbox-gone.ts new file mode 100644 index 00000000000..7e8580461aa --- /dev/null +++ b/services/runner/src/engines/sandbox_agent/sandbox-gone.ts @@ -0,0 +1,148 @@ +/** + * Recognise a provider answer that says THIS SANDBOX no longer exists. + * + * A local sandbox announces its death by refusing the socket: the probe's `fetch` rejects and the + * liveness counter climbs. A REMOTE sandbox never does that. Daytona keeps its proxy host alive + * after the sandbox is deleted and answers every request for it with a normal HTTP error that + * names the sandbox: + * + * 404, `x-daytona-error-code: SANDBOX_NOT_FOUND`, + * "not found: sandbox not found, it may have been deleted or stopped" + * + * The liveness probe reads any HTTP status as alive on purpose (see `sandbox-liveness.ts`), so + * that answer used to mean "still there" and the turn hung until the runner process died. That is + * the blind spot this module closes. The answer is authoritative in a way a status alone is not: + * the provider's own control plane is telling us the machine is gone, so it counts as death on + * the FIRST sighting rather than after the usual three failures. + * + * Recognition is deliberately narrow, because a false positive ends a healthy turn: + * - The answer must be an HTTP ERROR (>= 400). A 200 body that merely quotes this prose, such as + * an agent describing its own earlier failure, is not evidence of anything. + * - Either the provider's own error-code header names the sandbox, or the error body does. A + * bare 404 stays "alive": the daemon's health route may simply not exist on an older image, + * and reading that as death would end healthy turns. + */ + +/** The shape both the probe's `fetch` and the ACP transport's response satisfy. */ +export interface SandboxAnswer { + status: number; + headers: { get(name: string): string | null }; +} + +/** Provider headers that carry a machine-readable error code for the sandbox itself. */ +const GONE_CODE_HEADERS = ["x-daytona-error-code"] as const; + +/** + * Error codes that mean the sandbox is GONE, not that the request was bad and not that the sandbox + * is merely between states. + * + * `SANDBOX_STOPPED` and `SANDBOX_ARCHIVED` are deliberately absent. Both are RESUMABLE states the + * provider itself handles, and the reconnect ladder can legitimately meet either one while it + * brings a parked sandbox back. Reading them as death would end a turn on a sandbox that is about + * to answer. + */ +const GONE_CODE = /^SANDBOX_(NOT_FOUND|DELETED|DESTROYED)$/i; + +/** + * The same verdict in prose, for a proxy that sends no code header. "may have been deleted or + * stopped" is Daytona's own wording for a sandbox it cannot find, so it stays even though a + * `SANDBOX_STOPPED` code does not count. + */ +const GONE_BODY = + /sandbox\s+\S+\s+not found|may have been deleted or stopped|sandbox\s+\S+\s+(?:has been |was )?(?:deleted|destroyed)/i; + +/** + * The reason this answer proves the sandbox is gone, or undefined when it proves nothing. + * + * `bodyText` is optional: the ACP transport must not drain the response body it is about to hand + * to its caller, so it passes headers only. The liveness probe owns its response and passes the + * body too. + */ +export function sandboxGoneReason( + response: SandboxAnswer, + bodyText?: string, +): string | undefined { + if (response.status < 400) return undefined; + for (const header of GONE_CODE_HEADERS) { + const code = response.headers.get(header)?.trim(); + if (code && GONE_CODE.test(code)) { + return `provider reports the sandbox is gone (HTTP ${response.status}, ${header}: ${code})`; + } + } + if (bodyText && GONE_BODY.test(bodyText)) { + return `provider reports the sandbox is gone (HTTP ${response.status}: ${bodyText.slice(0, 200)})`; + } + return undefined; +} + +/** + * A one-way latch shared by everything that talks to one sandbox. + * + * The ACP transport sees the death first — it is the socket carrying the turn — but it has no way + * to end a turn. The liveness probe can end a turn but only wakes every 30 seconds. The latch is + * the seam between them: the transport notes the reason, the probe fires on it at once. First + * reason wins; later notes are ignored, so one death yields one outcome. + */ +export interface SandboxGoneLatch { + /** + * Open the latch. Every `note` before this is DISCARDED. + * + * The latch starts closed because the same fetch that carries a turn also carries the SDK's + * health wait during acquire, and that wait polls a sandbox which is still coming up. A + * provider proxy that lags its own control plane can answer "not found" for a sandbox it has + * not finished re-exposing, which is a normal step of a warm resume rather than a death. The + * latch is one-way, so a report from that window has to be discarded rather than reasoned about + * later. The owner of the environment arms it once the sandbox is acquired. + */ + arm(): void; + /** + * Record that the sandbox is gone. Idempotent; only the first reason after `arm()` is kept, and + * a note before `arm()` is ignored. + */ + note(reason: string): void; + /** The recorded reason, or undefined while the sandbox still answers. */ + reason(): string | undefined; + /** + * Call `listener` when the sandbox is declared gone, or immediately when it already was. At + * most one call per listener. + * + * Returns an unsubscribe function the caller MUST call when its turn ends. A warm environment + * outlives every turn that runs on it, so a turn that leaves its listener behind leaks one dead + * closure per turn and would end up calling a finished turn's `onGone`. + */ + subscribe(listener: (reason: string) => void): () => void; +} + +export function createSandboxGoneLatch(): SandboxGoneLatch { + let armed = false; + let reason: string | undefined; + const listeners = new Set<(reason: string) => void>(); + return { + arm(): void { + armed = true; + }, + note(next: string): void { + if (!armed || reason) return; + reason = next; + for (const listener of listeners) { + try { + listener(next); + } catch { + // A listener fault must not stop the others, nor the request that noticed the death. + } + } + listeners.clear(); + }, + reason: () => reason, + subscribe(listener: (next: string) => void): () => void { + if (reason) { + listener(reason); + return () => {}; + } + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} diff --git a/services/runner/src/engines/sandbox_agent/sandbox-liveness.ts b/services/runner/src/engines/sandbox_agent/sandbox-liveness.ts new file mode 100644 index 00000000000..7d364381e8d --- /dev/null +++ b/services/runner/src/engines/sandbox_agent/sandbox-liveness.ts @@ -0,0 +1,281 @@ +/** + * Detect that the sandbox died UNDER a running turn, so the turn ends instead of hanging. + * + * The runner talks to the sandbox agent over ACP, a JSON-RPC channel whose agent-to-client half + * is a long-lived SSE `GET`. When the sandbox process disappears that stream is severed, but the + * transport's read loop swallows the error and never fails the readable, so the pending + * `session/prompt` request is structurally incapable of settling. The turn then holds its + * sandbox, its mount and its slot forever, while the alive watchdog keeps telling the platform + * `running=true` every 30 seconds. That is issue #6418. + * + * The existing run limits do not cover it. Time-to-first-byte (2 min) catches a sandbox that + * dies before the first token, and idle (30 min) catches one that dies mid-stream — but + * `notePaused()` retires every one of them for good the moment the turn parks for a human, and a + * sandbox that dies during a pause therefore has no deadline at all. + * + * So probe the sandbox directly, over its own HTTP surface, which is a different socket from the + * wedged ACP channel: it answers while the sandbox lives and refuses once it is gone. + * `failureThreshold` consecutive failures — not one — is what separates a dead sandbox from a + * slow network, and each probe carries its own timeout because a vanished host can hang a + * request rather than refuse it. + * + * What counts as alive is deliberately weak: ANY HTTP response, including 401 or 404. The + * question is whether something is listening, not whether we are authorised or whether the + * route exists, and only a transport failure answers that with certainty. + * + * The ONE exception is an answer that names the SANDBOX as gone, which `sandbox-gone.ts` + * recognises. Behind a remote provider's proxy the transport failure never arrives: Daytona keeps + * the proxy host up after the sandbox is deleted and answers "sandbox not found" for it + * indefinitely, so the weak rule alone read a dead sandbox as alive and the turn hung until the + * runner process died. That answer is the provider's own verdict rather than a network symptom, + * so it ends the turn on the FIRST sighting instead of after three failures. + * + * `goneSignal` is the other half of the same fix. The ACP transport carrying the turn sees that + * answer seconds before any poll can, and it cannot end a turn on its own, so it notes the death + * on a shared latch and this probe fires on the latch at once. + * + * NOTE on what NOT to probe: `SandboxAgent.getSession()` looks like a liveness check and is not + * one. It reads the local persist driver and never touches the daemon, so it answers happily + * while the sandbox is dead — verified live on 2026-09-02, where a killed daemon logged + * `ECONNREFUSED` on the ACP socket while every `getSession` succeeded. + * + * The probe deliberately keeps running while the turn is paused. A pause is a legitimate wait for + * a human; it is not a reason to stop noticing that the machine underneath is gone. + */ + +import { envInt, envTimerMs } from "../../env.ts"; +import { SANDBOX_GONE_MARKER } from "./errors.ts"; +import { sandboxGoneReason, type SandboxGoneLatch } from "./sandbox-gone.ts"; + +export const PROBE_INTERVAL_ENV = "AGENTA_RUNNER_SANDBOX_PROBE_INTERVAL_MS"; +export const PROBE_TIMEOUT_ENV = "AGENTA_RUNNER_SANDBOX_PROBE_TIMEOUT_MS"; +export const PROBE_FAILURES_ENV = "AGENTA_RUNNER_SANDBOX_PROBE_FAILURES"; +export const PROBE_DISABLED_ENV = "AGENTA_RUNNER_SANDBOX_PROBE_DISABLED"; + +// One probe per heartbeat interval. Anything faster buys latency the user cannot perceive and +// costs a request per sandbox per tick. +export const DEFAULT_PROBE_INTERVAL_MS = 30_000; +// A live daemon answers a session read in milliseconds; ten seconds is a generous ceiling that +// still bounds a hung request well inside one interval. +export const DEFAULT_PROBE_TIMEOUT_MS = 10_000; +// Three consecutive failures, so a single dropped request or a brief network stall is not a +// death sentence. At the defaults that is about 90 seconds before a turn is ended. +export const DEFAULT_PROBE_FAILURES = 3; + +export interface SandboxLivenessLimits { + intervalMs: number; + timeoutMs: number; + failureThreshold: number; +} + +export interface Clock { + setTimeout(fn: () => void, ms: number): NodeJS.Timeout; + clearTimeout(handle: NodeJS.Timeout): void; +} + +const realClock: Clock = { + setTimeout: (fn, ms) => setTimeout(fn, ms), + clearTimeout: (handle) => clearTimeout(handle), +}; + +/** Read the probe's limits from env, with wide defaults. */ +export function resolveSandboxLivenessLimits( + log: (message: string) => void = () => {}, +): SandboxLivenessLimits { + return { + intervalMs: envTimerMs(PROBE_INTERVAL_ENV, DEFAULT_PROBE_INTERVAL_MS, { + log, + }), + timeoutMs: envTimerMs(PROBE_TIMEOUT_ENV, DEFAULT_PROBE_TIMEOUT_MS, { log }), + failureThreshold: envInt(PROBE_FAILURES_ENV, DEFAULT_PROBE_FAILURES, { + min: 1, + log, + }), + }; +} + +/** + * The daemon's health URL, derived from the only public handle on the agent that carries its + * base address. `inspectorUrl` is `/ui/`; the health route is `/v1/health`. + * + * Returns undefined when the agent exposes no usable URL, which disables the probe rather than + * guessing — a probe pointed at the wrong host would end healthy turns. + */ +export function sandboxHealthUrl(sandbox: unknown): string | undefined { + const inspector = (sandbox as { inspectorUrl?: unknown } | undefined) + ?.inspectorUrl; + if (typeof inspector !== "string" || !inspector) return undefined; + const base = inspector.replace(/\/ui\/?$/, "").replace(/\/+$/, ""); + if (!/^https?:\/\//.test(base)) return undefined; + return `${base}/v1/health`; +} + +/** + * A failure the provider itself confirmed: the sandbox is gone, so waiting for two more probes + * would only delay an outcome that is already certain. + */ +export class SandboxGoneError extends Error { + constructor(reason: string) { + super(reason); + this.name = "SandboxGoneError"; + } +} + +/** + * The default probe: one unauthenticated GET at the daemon's health route. + * + * Resolves on any HTTP status, except one whose headers or body name the sandbox as gone — that + * rejects with {@link SandboxGoneError}. Otherwise it rejects only when the request never became a + * response, which is what "nothing is listening any more" looks like from here. + * + * The body is read only for an HTTP error, so a healthy answer costs nothing extra and a 200 that + * happens to quote the provider's prose can never be misread as death. + */ +export function httpLivenessProbe(url: string): () => Promise { + return async () => { + const response = await fetch(url, { method: "GET" }); + const bodyText = + response.status >= 400 + ? await response.text().catch(() => "") + : undefined; + const reason = sandboxGoneReason(response, bodyText); + if (reason) throw new SandboxGoneError(reason); + return response.status; + }; +} + +export interface SandboxLivenessHandle { + /** Release the probe's timer. Always call this once the turn ends, on every path. */ + dispose(): void; + /** Consecutive failures observed so far; for tests and diagnostics. */ + failures(): number; +} + +export interface SandboxLivenessOptions { + /** + * One liveness check. Resolves when the sandbox answered, rejects or hangs when it did not. + * + * Optional: a sandbox that exposes no health URL still gets the `goneSignal` route, which needs + * no polling at all. + */ + probe?: () => Promise; + limits: SandboxLivenessLimits; + /** Called at most once, with a human-readable reason, when the sandbox is declared gone. */ + onGone: (reason: string) => void; + /** + * The latch the turn's ACP transport writes to when a response names the sandbox as gone. It + * ends the turn on the spot, without waiting for the next probe interval. + */ + goneSignal?: SandboxGoneLatch; + clock?: Clock; + log?: (message: string) => void; +} + +/** + * Start probing. Returns immediately; the first probe runs one interval later, because a turn + * that just acquired its environment has already proved the sandbox was up. + */ +export function startSandboxLivenessProbe({ + probe, + limits, + onGone, + goneSignal, + clock = realClock, + log = () => {}, +}: SandboxLivenessOptions): SandboxLivenessHandle { + let disposed = false; + let fired = false; + let inFlight = false; + let failures = 0; + let timer: NodeJS.Timeout | undefined; + + /** Declare the sandbox gone, at most once for the life of this handle. */ + const fire = (reason: string): void => { + if (fired || disposed) return; + fired = true; + if (timer) clock.clearTimeout(timer); + timer = undefined; + log(`[sandbox-liveness] ${reason}`); + onGone(reason); + }; + + const schedule = (): void => { + if (disposed || fired) return; + timer = clock.setTimeout(() => void tick(), limits.intervalMs); + }; + + const withTimeout = async (): Promise => { + let timeoutHandle: NodeJS.Timeout | undefined; + try { + await Promise.race([ + probe!(), + new Promise((_resolve, reject) => { + timeoutHandle = clock.setTimeout( + () => + reject(new Error(`probe timed out after ${limits.timeoutMs}ms`)), + limits.timeoutMs, + ); + }), + ]); + } finally { + if (timeoutHandle) clock.clearTimeout(timeoutHandle); + } + }; + + const tick = async (): Promise => { + // A probe still running when the next tick lands means the sandbox is not answering; let + // the in-flight one reach its own timeout rather than stacking requests on a dead host. + if (disposed || fired || inFlight) { + schedule(); + return; + } + inFlight = true; + try { + await withTimeout(); + failures = 0; + } catch (err) { + failures += 1; + const detail = err instanceof Error ? err.message : String(err); + // The provider answering "that sandbox does not exist" is a verdict, not a symptom, so it + // needs no corroboration from two more probes. + if (err instanceof SandboxGoneError) { + log(`[sandbox-liveness] probe failed (definitive): ${detail}`); + fire(`${SANDBOX_GONE_MARKER}: ${detail}`); + return; + } + log( + `[sandbox-liveness] probe failed (${failures}/${limits.failureThreshold}): ${detail}`, + ); + if (failures >= limits.failureThreshold) { + fire( + `${SANDBOX_GONE_MARKER}: ${failures} consecutive liveness probes failed ` + + `(last: ${detail})`, + ); + return; + } + } finally { + inFlight = false; + } + schedule(); + }; + + // The transport's report is not a poll, so `PROBE_DISABLED_ENV` does not silence it: that switch + // exists to stop the runner making a request per sandbox per tick, not to make the runner ignore + // a death it was told about. The latch belongs to the ENVIRONMENT, which outlives this turn on a + // warm sandbox, so `dispose` must hand the listener back or every turn leaves one behind. + const unsubscribeGone = goneSignal?.subscribe((reason) => { + fire(`${SANDBOX_GONE_MARKER}: ${reason}`); + }); + + if (probe && !process.env[PROBE_DISABLED_ENV]) schedule(); + + return { + dispose() { + disposed = true; + if (timer) clock.clearTimeout(timer); + timer = undefined; + unsubscribeGone?.(); + }, + failures: () => failures, + }; +} diff --git a/services/runner/src/engines/sandbox_agent/session-continuity.ts b/services/runner/src/engines/sandbox_agent/session-continuity.ts index 7a6c7e39022..666e56701ae 100644 Binary files a/services/runner/src/engines/sandbox_agent/session-continuity.ts and b/services/runner/src/engines/sandbox_agent/session-continuity.ts differ diff --git a/services/runner/src/engines/sandbox_agent/session-identity.ts b/services/runner/src/engines/sandbox_agent/session-identity.ts index 574d4dafcb3..421305e873e 100644 --- a/services/runner/src/engines/sandbox_agent/session-identity.ts +++ b/services/runner/src/engines/sandbox_agent/session-identity.ts @@ -26,6 +26,19 @@ export interface KeepaliveConfig { enabled: boolean; ttlMs: number; approvalTtlMs: number; + /** + * The idle window for a session PARKED BY A USER STOP. + * + * Defaults to 600 s for both providers, matching the local approval window because both waits + * begin when a human is about to act. This deliberately differs from the ordinary 60 s local + * and 120 s Daytona idle windows. The trade-off is that a stopped Daytona sandbox can remain + * billed for up to ten minutes. Override with AGENTA_RUNNER_SESSION_STOPPED_TTL_MS. + * + * Optional so a hand-built config (every test fixture) keeps meaning what it always meant: + * omitted reads as "same as the idle window". `readKeepaliveConfig`, the only production + * source, always sets it. + */ + stoppedTtlMs?: number; poolMax: number; } @@ -34,6 +47,7 @@ export type KeepaliveProviderName = "local" | "daytona"; const KEEPALIVE_ENV = "AGENTA_RUNNER_SESSION_KEEPALIVE"; const TTL_ENV = "AGENTA_RUNNER_SESSION_TTL_MS"; const APPROVAL_TTL_ENV = "AGENTA_RUNNER_SESSION_APPROVAL_TTL_MS"; +const STOPPED_TTL_ENV = "AGENTA_RUNNER_SESSION_STOPPED_TTL_MS"; const POOL_MAX_ENV = "AGENTA_RUNNER_SESSION_POOL_MAX"; const DEFAULT_TTL_MS = 60_000; @@ -46,6 +60,7 @@ const DEFAULT_TTL_MS = 60_000; // (never fails the turn), and an awaiting_approval entry keeps holding a pool slot — override // via AGENTA_RUNNER_SESSION_APPROVAL_TTL_MS if warm slots are contended. const DEFAULT_APPROVAL_TTL_MS = 600_000; +const DEFAULT_STOPPED_TTL_MS = 600_000; const DEFAULT_POOL_MAX = 8; const DAYTONA_TTL_ENV = "AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS"; const DAYTONA_POOL_MAX_ENV = "AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM"; @@ -97,6 +112,9 @@ export function readKeepaliveConfig( // pool never sees an awaiting_approval park for Daytona today because parkedApproval is // only set by ACP gates. approvalTtlMs: ttlMs, + // A stopped Daytona session is deliberately held for the same human-response window as a + // local one, even though the sandbox remains billed. Zero remains a valid operator override. + stoppedTtlMs: nonNegativeIntEnv(STOPPED_TTL_ENV, DEFAULT_STOPPED_TTL_MS), // This budgets billed compute (idle warm sandboxes), deliberately separate from the local // pool's host-memory budget; Slice 4 adds the strict warm-slot accounting semantics. poolMax: positiveIntEnv(DAYTONA_POOL_MAX_ENV, DEFAULT_DAYTONA_POOL_MAX), @@ -106,6 +124,8 @@ export function readKeepaliveConfig( enabled: boolEnv(KEEPALIVE_ENV, true), ttlMs: positiveIntEnv(TTL_ENV, DEFAULT_TTL_MS), approvalTtlMs: positiveIntEnv(APPROVAL_TTL_ENV, DEFAULT_APPROVAL_TTL_MS), + // A settled Stop gets the same ten-minute human-response window as a pending approval. + stoppedTtlMs: positiveIntEnv(STOPPED_TTL_ENV, DEFAULT_STOPPED_TTL_MS), poolMax: positiveIntEnv(POOL_MAX_ENV, DEFAULT_POOL_MAX), }; } @@ -291,6 +311,8 @@ function configShape(request: AgentRunRequest) { ), } : null, + sandboxCredentials: + request.sandboxCredentials?.map((credential) => ({ binding: credential.binding })) ?? null, agentsMd: request.agentsMd ?? null, systemPrompt: request.systemPrompt ?? null, appendSystemPrompt: request.appendSystemPrompt ?? null, @@ -504,15 +526,34 @@ export function approvalDecisionForToolCall( toolCallId: string, ): "allow" | "deny" | undefined { if (!toolCallId) return undefined; - for (const message of request.messages ?? []) { + const messages = request.messages ?? []; + if (messages.length === 0) return undefined; + + // A pure interaction reply carries its decision at the request tail. A fresh user turn can + // carry a rewritten `output-denied` tool part in its history; only the LAST assistant message + // is relevant there. Scanning the whole transcript lets an older denial bind to a newer gate + // that reused the id and incorrectly diverts the new user text into approval-resume. + let message: ChatMessage | undefined; + if (!tailIsFreshUserMessage(request)) { + message = messages[messages.length - 1]; + } else { + for (let i = messages.length - 2; i >= 0; i--) { + if (messages[i]?.role === "assistant") { + message = messages[i]; + break; + } + } + } + if (message) { const content = message?.content; - if (!Array.isArray(content)) continue; - for (const block of content) { - if (block?.type !== "tool_result" || block.toolCallId !== toolCallId) { - continue; + if (Array.isArray(content)) { + for (const block of content) { + if (block?.type !== "tool_result" || block.toolCallId !== toolCallId) { + continue; + } + const decision = approvalDecisionOf(block); + if (decision !== undefined) return decision; } - const decision = approvalDecisionOf(block); - if (decision !== undefined) return decision; } } return undefined; @@ -652,6 +693,10 @@ export function computeCredentialEpoch( usage: credential.usage, }), ), + sandboxCredentials: (request.sandboxCredentials ?? []).map((credential) => ({ + binding: credential.binding, + value: credential.value, + })), mcpCredentials: (request.mcpServers ?? []).flatMap((server) => (server.connection?.credentials ?? []).map((credential) => ({ server: server.name, @@ -666,6 +711,10 @@ export function computeCredentialEpoch( // locally by the provider SDK, so they are baked into the daemon environment at create. const directMaterial = canonicalJson({ modelEnvironment: request.modelConnection?.environment ?? {}, + sandboxCredentials: (request.sandboxCredentials ?? []).map((credential) => ({ + binding: credential.binding, + value: credential.value, + })), localUseCredentials: (request.modelConnection?.credentials ?? []) .filter((credential) => credential.usage === "local_use") .map((credential) => ({ diff --git a/services/runner/src/engines/sandbox_agent/teardown.ts b/services/runner/src/engines/sandbox_agent/teardown.ts index 456c691e250..7a4ce69e977 100644 --- a/services/runner/src/engines/sandbox_agent/teardown.ts +++ b/services/runner/src/engines/sandbox_agent/teardown.ts @@ -31,6 +31,8 @@ export type TeardownReason = | "kill" | "failed-turn" | "aborted" + /** A user Stop whose harness cancel SETTLED. The daemon is idle and sound, so park it. */ + | "cancelled" /** @deprecated Name the failing layer instead. Kept so an unclassified call site fails safe. */ | "compatibility-mismatch" | "session-incompatible" @@ -61,6 +63,10 @@ const PARKABLE_REASONS: ReadonlySet = new Set([ "idle-expiry", "capacity-eviction", "shutdown-idle", + // A settled Stop. The harness answered its cancelled prompt, so nothing inside the daemon is + // mid-flight and nothing baked into it is stale. An UNSETTLED Stop never reaches this reason: + // it stays `aborted`, which deletes. + "cancelled", // The two incompatibilities whose daemon is still sound. See the module comment. "session-incompatible", "continuity-invalid", diff --git a/services/runner/src/environment/abortable-sandbox-provider.ts b/services/runner/src/environment/abortable-sandbox-provider.ts new file mode 100644 index 00000000000..f083aaa40f1 --- /dev/null +++ b/services/runner/src/environment/abortable-sandbox-provider.ts @@ -0,0 +1,109 @@ +import type { SandboxProvider } from "sandbox-agent"; + +import { waitForAcquire } from "./acquire-abort.ts"; + +type ProviderMethod = (...args: any[]) => Promise; + +async function cleanupCreatedSandbox( + provider: SandboxProvider, + sandboxId: string, + log: (message: string) => void, +): Promise { + try { + await provider.destroy(sandboxId); + log(`cancelled acquire cleaned late-created sandbox=${sandboxId}`); + } catch (error) { + log( + `cancelled acquire cleanup failed sandbox=${sandboxId}: ${String( + error instanceof Error ? error.message : error, + ).slice(0, 160)}`, + ); + } +} + +async function cleanupReconnectedSandbox( + provider: SandboxProvider, + sandboxId: string, + log: (message: string) => void, +): Promise { + try { + if (provider.pause) await provider.pause(sandboxId); + else await provider.destroy(sandboxId); + log(`cancelled acquire cleaned late-reconnected sandbox=${sandboxId}`); + } catch (error) { + log( + `cancelled reconnect cleanup failed sandbox=${sandboxId}: ${String( + error instanceof Error ? error.message : error, + ).slice(0, 160)}`, + ); + } +} + +/** + * Make the provider-owned part of `SandboxAgent.start` observe the turn signal. + * + * `sandbox-agent` forwards its signal only to the client health wait; provider `create()` and + * `reconnect()` have no signal parameter. This proxy races those calls without changing provider + * identity or hiding provider-specific methods. A fresh sandbox that appears after cancellation + * is deleted; a late reconnect is returned to its parked state when the provider supports pause. + */ +export function abortableSandboxProvider( + provider: T, + signal: AbortSignal | undefined, + log: (message: string) => void, +): T { + if (!signal) return provider; + + return new Proxy(provider, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver); + if (typeof value !== "function") return value; + + if (property === "create") { + return (...args: unknown[]) => + waitForAcquire( + () => Reflect.apply(value as ProviderMethod, target, args), + signal, + { + onLateSuccess: (sandboxId: string) => + cleanupCreatedSandbox(target, sandboxId, log), + }, + ); + } + + if (property === "reconnect") { + return (sandboxId: string, ...args: unknown[]) => + waitForAcquire( + () => + Reflect.apply(value as ProviderMethod, target, [ + sandboxId, + ...args, + ]), + signal, + { + onLateSuccess: () => + cleanupReconnectedSandbox(target, sandboxId, log), + onLateFailure: () => + cleanupReconnectedSandbox(target, sandboxId, log), + }, + ); + } + + // These calls happen after a raw sandbox id exists. `SandboxAgent.start` owns compensation + // if one is cancelled, so they need only become promptly abortable here. + if ( + property === "ensureServer" || + property === "getUrl" || + property === "getFetch" + ) { + return (...args: unknown[]) => + waitForAcquire( + () => Reflect.apply(value as ProviderMethod, target, args), + signal, + ); + } + + return value.bind(target); + }, + }); +} diff --git a/services/runner/src/environment/acquire-abort.ts b/services/runner/src/environment/acquire-abort.ts new file mode 100644 index 00000000000..cb6b2b691cf --- /dev/null +++ b/services/runner/src/environment/acquire-abort.ts @@ -0,0 +1,96 @@ +/** + * Cancellation helpers for environment acquisition. + * + * A user Stop can arrive while a provider or mount call is still pending. Waiting for that call + * before observing the signal makes the control delivery time out. Racing without compensating + * cleanup is worse: a provider may finish creating a sandbox after the turn has already ended. + * These helpers provide the shared race and the late-success cleanup hook used by those stages. + */ + +/** The stable error shape returned when acquisition is interrupted by its turn signal. */ +export class AcquireAbortedError extends Error { + constructor() { + super("Sandbox acquisition was aborted."); + this.name = "AbortError"; + } +} + +export function throwIfAcquireAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) throw new AcquireAbortedError(); +} + +export interface AbortableAcquireHooks { + /** Cleanup for a resource that materialized after the caller already observed cancellation. */ + onLateSuccess?: (value: T) => void | Promise; + /** Cleanup for a known resource whose operation failed after cancellation. */ + onLateFailure?: (error: unknown) => void | Promise; +} + +function runLateHook( + hook: ((value: T) => void | Promise) | undefined, + value: T, +): void { + if (!hook) return; + void Promise.resolve() + .then(() => hook(value)) + .catch(() => {}); +} + +/** + * Start one acquire operation and reject as soon as `signal` aborts. The underlying operation is + * not assumed to support AbortSignal, so a resource that resolves later is handed to the cleanup + * hook instead of being leaked or published to the cancelled caller. + */ +export function waitForAcquire( + start: () => Promise, + signal?: AbortSignal, + hooks: AbortableAcquireHooks = {}, +): Promise { + if (!signal) return start(); + throwIfAcquireAborted(signal); + + return new Promise((resolve, reject) => { + let cancelled = false; + let settled = false; + const onAbort = () => { + if (settled || cancelled) return; + cancelled = true; + reject(new AcquireAbortedError()); + }; + signal.addEventListener("abort", onAbort, { once: true }); + + let operation: Promise; + try { + operation = start(); + } catch (error) { + settled = true; + signal.removeEventListener("abort", onAbort); + reject(error); + return; + } + + operation.then( + (value) => { + settled = true; + signal.removeEventListener("abort", onAbort); + if (cancelled) { + runLateHook(hooks.onLateSuccess, value); + return; + } + resolve(value); + }, + (error) => { + settled = true; + signal.removeEventListener("abort", onAbort); + if (cancelled) { + runLateHook(hooks.onLateFailure, error); + return; + } + reject(error); + }, + ); + + // Cover an abort that raced the listener registration and operation start. + if (signal.aborted) onAbort(); + }); +} diff --git a/services/runner/src/environment/harness-session-lifecycle.ts b/services/runner/src/environment/harness-session-lifecycle.ts index 24525e6f463..605f23db7f4 100644 --- a/services/runner/src/environment/harness-session-lifecycle.ts +++ b/services/runner/src/environment/harness-session-lifecycle.ts @@ -59,7 +59,14 @@ export interface OpenSessionInput { createSession: (request: unknown) => Promise<{ id: string }>; }; /** The session persist driver. Typed loosely so this unit does not restate the SDK's record. */ - persist: { updateSession: (record: never) => Promise }; + persist: { + updateSession: (record: never) => Promise; + listEvents?: (request: { + sessionId: string; + cursor?: string; + limit?: number; + }) => Promise<{ items: unknown[]; nextCursor?: string }>; + }; acpAgent: string; harness: string; cwd: string; @@ -67,6 +74,8 @@ export interface OpenSessionInput { sessionInit: Record; /** The native session id to resume, when the store says one is eligible. */ priorAgentSessionId: string | undefined; + /** Whether the native transcript path is backed by durable storage for this acquire. */ + nativeHistoryDurable: boolean; /** The runner-local key both modes use for the persist record. */ localSessionId: string | undefined; /** For the continuity log line only. */ @@ -87,9 +96,56 @@ export interface OpenSessionResult { * reopen may claim continuity; this unit reports what it can observe and no more. */ loadedFromContinuity: boolean; + /** Whether `session/load` emitted prior conversation content, not merely accepted the id. */ + nativeHistoryVerified: boolean; mode: "load" | "create"; } +const HISTORY_SESSION_UPDATES = new Set([ + "user_message_chunk", + "agent_message_chunk", + "agent_thought_chunk", + "tool_call", + "tool_call_update", +]); + +/** + * `sandbox-agent` persists every ACP envelope observed while `session/load` runs. A real native + * replay therefore leaves at least one conversation update behind; an adapter that merely accepts + * the id leaves none. This is the positive proof the id comparison cannot provide. + */ +async function loadedHistoryWasObserved( + persist: OpenSessionInput["persist"], + localSessionId: string, + eventCountBeforeLoad: number, +): Promise { + if (!persist.listEvents) return false; + const items: unknown[] = []; + let cursor: string | undefined; + do { + const page = await persist.listEvents({ + sessionId: localSessionId, + cursor, + limit: 100, + }); + items.push(...page.items); + cursor = page.nextCursor; + } while (cursor); + return items.slice(eventCountBeforeLoad).some((item) => { + const event = item as { + sender?: unknown; + payload?: { method?: unknown; params?: { update?: { sessionUpdate?: unknown } } }; + }; + return ( + event.sender === "agent" && + event.payload?.method === "session/update" && + HISTORY_SESSION_UPDATES.has( + String(event.payload.params?.update?.sessionUpdate ?? ""), + ) + ); + }); +} + /** * Open the harness session: load the native conversation when one is eligible, otherwise create * a fresh one. @@ -102,6 +158,7 @@ export async function openSession( ): Promise { let session: { id: string; agentSessionId?: string } | undefined; let loadedFromContinuity = false; + let nativeHistoryVerified = false; if (input.priorAgentSessionId && input.localSessionId) { await input.persist.updateSession({ @@ -114,12 +171,55 @@ export async function openSession( } as never); const createSessionStartedAt = Date.now(); try { + let eventCountBeforeLoad: number | undefined; + if (input.nativeHistoryDurable && input.persist.listEvents) { + try { + const page = await input.persist.listEvents({ + sessionId: input.localSessionId, + limit: 100, + }); + eventCountBeforeLoad = page.items.length; + let cursor = page.nextCursor; + while (cursor) { + const next = await input.persist.listEvents({ + sessionId: input.localSessionId, + cursor, + limit: 100, + }); + eventCountBeforeLoad += next.items.length; + cursor = next.nextCursor; + } + } catch (err) { + input.log( + `[continuity] native history baseline failed: ${conciseError(err, input.harness)}`, + ); + } + } session = await input.sandbox.resumeSession(input.localSessionId); loadedFromContinuity = session.agentSessionId === input.priorAgentSessionId; + if ( + loadedFromContinuity && + input.nativeHistoryDurable && + eventCountBeforeLoad !== undefined + ) { + try { + nativeHistoryVerified = await loadedHistoryWasObserved( + input.persist, + input.localSessionId, + eventCountBeforeLoad, + ); + } catch (err) { + input.log( + `[continuity] native history verification failed: ${conciseError(err, input.harness)}`, + ); + } + } input.log( `[continuity] session/load attempted session=${input.continuitySessionKey} ` + - `harness=${input.harness} loaded=${loadedFromContinuity}`, + `harness=${input.harness} loaded=${loadedFromContinuity} ` + + `historyDurable=${input.nativeHistoryDurable} ` + + `historyVerified=${nativeHistoryVerified}`, ); } catch (err) { input.log( @@ -143,10 +243,20 @@ export async function openSession( } finally { input.timingLog("create_session", createSessionStartedAt, " mode=create"); } - return { session, loadedFromContinuity, mode: "create" }; + return { + session, + loadedFromContinuity, + nativeHistoryVerified, + mode: "create", + }; } - return { session, loadedFromContinuity, mode: "load" }; + return { + session, + loadedFromContinuity, + nativeHistoryVerified, + mode: "load", + }; } /** @@ -222,6 +332,7 @@ export type ReopenResult = ok: true; session: { id: string; agentSessionId?: string }; loadedFromContinuity: boolean; + nativeHistoryVerified: boolean; } | { ok: false; reason: "history-unverifiable" | "reopen-failed" }; @@ -251,6 +362,7 @@ export async function reopen(input: ReopenInput): Promise { ok: true, session: opened.session, loadedFromContinuity: opened.loadedFromContinuity, + nativeHistoryVerified: opened.nativeHistoryVerified, }; } catch (err) { input.log(`reopen failed: ${conciseError(err, input.harness)}`); diff --git a/services/runner/src/environment/mount-lifecycle.ts b/services/runner/src/environment/mount-lifecycle.ts index 493317e8fc6..be45fce5745 100644 --- a/services/runner/src/environment/mount-lifecycle.ts +++ b/services/runner/src/environment/mount-lifecycle.ts @@ -62,6 +62,7 @@ import { writeSystemPromptLocal, } from "../engines/sandbox_agent/pi-assets.ts"; import { containsTransportEndpointDisconnected } from "../engines/sandbox_agent/runtime-policy.ts"; +import { throwIfAcquireAborted } from "./acquire-abort.ts"; import { rethrowIfInvariant, type AcquireContext } from "./acquire-context.ts"; /** The Pi agent directory inside a Daytona sandbox. Injected so this unit stays import-light. */ @@ -77,6 +78,8 @@ export interface MountDeps { ) => Promise; /** The remote Pi directory constant, passed in rather than imported. */ daytonaPiDir: string; + /** The turn signal that must preempt a mount during environment acquisition. */ + signal?: AbortSignal; } /** @@ -203,10 +206,11 @@ export async function mountLocalDurableCwd( const mounted = await (deps.mountStorage ?? mountStorage)( plan.workspace.cwd, creds, - { log: ctx.log }, + { log: ctx.log, signal: deps.signal }, ); if (mounted) { ctx.commitLocalMount("cwd", plan.workspace.cwd, creds); + throwIfAcquireAborted(deps.signal); // Session-local links belong to the mount's lifecycle, not to first acquire: this mount is // object storage, which has no symlinks, so a remount hands back a 0-byte file where the link // was. Re-materialize the subscription Codex login link here, AFTER the mount is live @@ -220,6 +224,7 @@ export async function mountLocalDurableCwd( } return true; } + throwIfAcquireAborted(deps.signal); // A false result means mountStorage stopped the attempt and CONFIRMED the path detached. ctx.markCwdDetachConfirmed(); return false; @@ -240,6 +245,7 @@ export async function mountLocalAgentCwd( if ( !(await (deps.mountStorage ?? mountStorage)(mountPath, creds, { log: ctx.log, + signal: deps.signal, })) ) { // false means mountStorage confirmed detach is safe. This path is a sibling of the session @@ -248,6 +254,7 @@ export async function mountLocalAgentCwd( return false; } ctx.commitLocalMount("agent", mountPath, creds); + throwIfAcquireAborted(deps.signal); await seedAgentReadme(mountPath, { log: ctx.log }); await linkAgentFiles(plan.workspace.cwd, mountPath, { log: ctx.log }); await activateAgentMountGuidance(ctx, deps); diff --git a/services/runner/src/environment/runtime-lifecycle.ts b/services/runner/src/environment/runtime-lifecycle.ts index 67782c2d2d7..b0b9aae1b79 100644 --- a/services/runner/src/environment/runtime-lifecycle.ts +++ b/services/runner/src/environment/runtime-lifecycle.ts @@ -169,6 +169,18 @@ export interface RuntimeEnvironment { * The stable telemetry-control PATH enters Pi's env; per-turn context rides that read-once file. * The OTLP endpoint and authorization remain runner-owned and never enter the daemon env. */ +export function assignSandboxEnvironment( + targets: Array>, + sandboxEnvironment: Record, +): void { + for (const name of Object.keys(sandboxEnvironment)) { + if (targets.some((target) => Object.prototype.hasOwnProperty.call(target, name))) { + throw new Error(`sandboxCredentials binding collides with runner-owned environment`); + } + } + for (const target of targets) Object.assign(target, sandboxEnvironment); +} + export function buildRuntimeEnvironment( input: BuildRuntimeEnvironmentInput, ): RuntimeEnvironment { @@ -220,6 +232,7 @@ export function buildRuntimeEnvironment( // `sessions/` rollouts) while CODEX_SQLITE_HOME points in-VM, off the mount. Set here because // the Daytona daemon env is fixed at sandbox creation and is built from `piExtEnv`. configureDaytonaCodexEnv(input.plan, piExtEnv); + assignSandboxEnvironment([env, piExtEnv], p.credentials.sandboxEnvironment); // LAST, deliberately: the local daemon inherits the extension env, and Daytona gets the same // values through `envVars`. Assigning earlier would drop every key added above. Object.assign(env, piExtEnv); diff --git a/services/runner/src/environment/sandbox-lifecycle.ts b/services/runner/src/environment/sandbox-lifecycle.ts index 9aeb8feef58..aa6635b935e 100644 --- a/services/runner/src/environment/sandbox-lifecycle.ts +++ b/services/runner/src/environment/sandbox-lifecycle.ts @@ -152,10 +152,10 @@ export async function teardown( ): Promise<{ parked: boolean }> { const { sandbox, log } = input; const disposition = teardownDisposition(input.reason ?? "failed-turn"); + const sandboxLogId = sandbox?.sandboxId ?? input.plannedSandboxId; let parked = false; if (disposition === "stop" && input.isDaytona && sandbox?.pauseSandbox) { - const sandboxLogId = sandbox.sandboxId ?? input.plannedSandboxId; try { await sandbox.pauseSandbox(); parked = true; @@ -172,9 +172,23 @@ export async function teardown( // that failed may still have removed the sandbox, so reconnecting to it is a wasted round // trip either way. See `markSandboxDestroyed`. markSandboxDestroyed(sandbox?.sandboxId ?? input.plannedSandboxId ?? undefined); - await sandbox?.destroySandbox?.().catch(() => {}); + // SWALLOWED, BUT NEVER SILENT. Teardown must always complete, so the rejection cannot + // propagate — but it is the only signal that a remote sandbox, and on Daytona the Secret + // mounted into it, may still exist. It used to vanish here, so a stranded pair left no trace + // at all. The Daytona provider arms its own retry on this same failure; this line is what + // tells an operator it happened. `conciseError` reads only the top-level message, which for + // a Secret cleanup failure is a fixed sentence, so no Secret id or value can reach the log. + await sandbox?.destroySandbox?.().catch((err) => { + log( + `sandbox delete failed sandbox=${sandboxLogId}: ${conciseError(err, input.harness)}`, + ); + }); } - await sandbox?.dispose?.().catch(() => {}); + await sandbox?.dispose?.().catch((err) => { + log( + `sandbox dispose failed sandbox=${sandboxLogId}: ${conciseError(err, input.harness)}`, + ); + }); return { parked }; } diff --git a/services/runner/src/lifecycle/desired-state.ts b/services/runner/src/lifecycle/desired-state.ts index a90549cc0b2..b2aec1794ca 100644 --- a/services/runner/src/lifecycle/desired-state.ts +++ b/services/runner/src/lifecycle/desired-state.ts @@ -132,6 +132,8 @@ export function normalizeDesiredState( credentials: credentialShapes(request.modelConnection.credentials), } : null, + sandboxCredentials: + request.sandboxCredentials?.map((credential) => ({ binding: credential.binding })) ?? null, }); // WORKSPACE FILES: the managed files the runner writes and OWNS. Instructions and skills only. diff --git a/services/runner/src/lifecycle/session-coordinator.ts b/services/runner/src/lifecycle/session-coordinator.ts index 4ab7ca810d4..05622589062 100644 --- a/services/runner/src/lifecycle/session-coordinator.ts +++ b/services/runner/src/lifecycle/session-coordinator.ts @@ -40,6 +40,7 @@ import { type SessionEnvironment, } from "../engines/sandbox_agent.ts"; import type { MountCredentials } from "../engines/sandbox_agent/mount.ts"; +import { SESSION_TURN_IN_USE_MESSAGE } from "../sessions/admission.ts"; import { teardownDisposition, type TeardownReason, @@ -192,6 +193,14 @@ export interface KeepaliveContext { clientGone?: () => boolean; /** Latest session credential accessor supplied by the alive watchdog. */ credential?: () => string; + /** + * Called once with this run's project scope, as soon as it is known. + * + * The scope can only be resolved here: `runContext.project.id` is empty on the live invoke + * path, so the project comes from the signed mount, which is signed inside this function. The + * transport needs it to route a control command to the right tenant's session. + */ + onScopeResolved?: (projectId: string) => void; /** * Test seam for the credential-propagation hold. Production waits for real: the hold is what * keeps applied state from advancing over a value the provider's egress layer has probably not @@ -292,6 +301,10 @@ export async function runWithKeepalive( } const key = scope.key; klog(`scope=${scope.source} key=${key} session=${sessionId}`); + // Tell the transport which project this run belongs to. Until this lands, a control command + // cannot tell one tenant's session from another's, because the request itself often carries + // no project and the scope was only just derived from the signed mount. + ctx.onScopeResolved?.(scope.key.slice(0, scope.key.lastIndexOf(":"))); // The mount may be null here (store unconfigured, 503, ephemeral fallback) or undefined (the // sign attempt threw) when the run-context scope produced the key. A mount-less session still @@ -557,6 +570,14 @@ export async function runWithKeepalive( } }; + /** + * The idle window a clean park gets. A user Stop gets the longer stopped window, because the + * user is about to type the next message; every other clean turn gets the ordinary one. See + * `KeepaliveConfig.stoppedTtlMs` for how to collapse the two. + */ + const parkTtlMs = (stopped: boolean): number => + stopped ? (config.stoppedTtlMs ?? config.ttlMs) : config.ttlMs; + const resultTeardownReason = (result: AgentRunResult): TeardownReason => shouldPark(result, signal, clientGone) ? "clean-resumable" @@ -768,7 +789,12 @@ export async function runWithKeepalive( watchParkedPrompt(env); } } else if (shouldPark(result, signal, clientGone)) { - if (!(await seat(config.ttlMs, "idle"))) { + // A settled user Stop parks like any clean turn, but on the LONGER stopped window: the + // user is about to type. Logged so the live evidence shows the sandbox surviving a Stop + // rather than a `no-park:cancelled` eviction. + const stopped = result.stopReason === "cancelled"; + if (stopped) klog(`park-cancelled key=${key} ttl=${parkTtlMs(stopped)}ms`); + if (!(await seat(parkTtlMs(stopped), "idle"))) { await drop("park-refused", "clean-resumable"); } else { await notifyParkedLive(env); @@ -826,7 +852,9 @@ export async function runWithKeepalive( watchParkedPrompt(env); } } else if (shouldPark(result, signal, clientGone)) { - if (!(await pool.repark(live, update, config.ttlMs))) { + const stopped = result.stopReason === "cancelled"; + if (stopped) klog(`park-cancelled key=${key} ttl=${parkTtlMs(stopped)}ms`); + if (!(await pool.repark(live, update, parkTtlMs(stopped)))) { await live.teardown("failed-turn"); } else { await notifyParkedLive(env); @@ -891,6 +919,7 @@ export async function runWithKeepalive( result = await engine.runTurn(env, request, trackedEmit, signal, { approvalParkMode: true, loaded: env.loadedFromContinuity, + nativeHistoryVerified: env.nativeHistoryVerified, ...turnCredential, }); } catch (err) { @@ -1156,6 +1185,7 @@ export async function runWithKeepalive( const parkedList = [...existing.environment.parkedApprovals.values()]; const resumeDecisions: ResumeApprovalInput[] = []; const carriedForward: ParkedApproval[] = []; + const freshUserTail = tailIsFreshUserMessage(request); let mismatch: string | undefined; if (parkedList.length === 0) { mismatch = "no-parked-gate"; @@ -1205,7 +1235,14 @@ export async function runWithKeepalive( // session; the history check only guards a client that DID assert a transcript. const clientAssertsHistory = !carriesApprovalReplyOnly(request); if (!mismatch) { - if (clientAssertsHistory && priorFp !== existing.historyFingerprint) { + if (freshUserTail && carriedForward.length > 0) { + // A new prompt cannot start while any old gate still holds the harness's original prompt. + // Only a complete decision set can settle that prompt and keep this environment warm. + mismatch = "fresh-prompt-unanswered-gate"; + } else if ( + clientAssertsHistory && + priorFp !== existing.historyFingerprint + ) { mismatch = "history"; } else if (mountCredentialsExpired(existing.credentialEpoch)) { mismatch = "credentials-expired"; @@ -1261,21 +1298,25 @@ export async function runWithKeepalive( const live = pool.checkoutApproval(key); if (live) { - shadowRoute(existing, "reuse", "approval-resume"); + const decisionRoute = freshUserTail + ? "approval-decision-then-prompt" + : "approval-resume"; + shadowRoute(existing, "reuse", decisionRoute); const approveCount = resumeDecisions.filter( (d) => d.reply === "once", ).length; const rejectCount = resumeDecisions.length - approveCount; klog( - `resume key=${key} gates=${parkedList.length} answered=${resumeDecisions.length} ` + + `${freshUserTail ? "decision-then-prompt" : "resume"} key=${key} ` + + `gates=${parkedList.length} answered=${resumeDecisions.length} ` + `carried=${carriedForward.length} ` + `approve=${approveCount} reject=${rejectCount} tool=${parked?.toolName ?? "?"}`, ); let result: AgentRunResult; try { - // Answer the parked gate on the SAME live session; the original prompt continues and this - // (new) turn owns streaming + tracing. The gated tool runs with its original byte-exact - // args — no model re-issues anything, so argument drift/task restart cannot happen. + // A pure decision resumes the original prompt. A decision followed by fresh user text + // settles that gate first and then sends the text as a normal continuation prompt on the + // same warm session; the decision becomes context instead of swallowing the new turn. result = await engine.runTurn( live.environment, request, @@ -1283,7 +1324,14 @@ export async function runWithKeepalive( signal, { approvalParkMode: true, - resume: { decisions: resumeDecisions, carriedForward }, + ...(freshUserTail + ? { + continuation: true, + settleApprovalsThenPrompt: { decisions: resumeDecisions }, + } + : { + resume: { decisions: resumeDecisions, carriedForward }, + }), ...turnCredential, }, ); @@ -1316,12 +1364,29 @@ export async function runWithKeepalive( return result; } // checkout lost a race; fall through to cold. + } else if (existing && existing.state === "busy") { + // A LIVE turn is streaming on this environment right now, in this process. Refuse; never + // destroy it. + // + // This branch used to `evict` and cold-start ("supersede-busy"), which is the second half of + // the double-send bug (#6417, #5539, #5538): a second message on a running session tore the + // sandbox out from under the first turn, so both turns died and the session stayed locked + // until the 30-minute lease expired. Admission (`sessions/admission.ts`, decided by the API's + // atomic `nx` acquire on the turn's first heartbeat) now refuses the second turn at the edge, + // so in normal operation nothing reaches here at all. + // + // What still reaches here is the fail-open window: the heartbeat fails open on a network or + // HTTP error, so an API blip can admit two turns. Local state is the more specific truth in + // that window — a busy entry means a turn is demonstrably in flight on this box — so this is + // the backstop that keeps the invariant true when the arbiter is unreachable. Only a + // `checkoutIdle` continuation and a freshly `reserve`d cold turn leave a busy entry; + // `checkoutApproval` REMOVES its session, so an in-flight approval resume is never found here. + klog(`refuse (busy) key=${key}; another turn owns this session`); + return { ok: false, error: SESSION_TURN_IN_USE_MESSAGE }; } else if (existing) { - // Busy / destroyed: two turns racing one session. Only a checkoutIdle continuation leaves a - // busy entry in the map (checkoutApproval REMOVES its session, so an in-flight approval - // resume can never be found — a duplicate approval misses the pool and runs cold, and its - // environment can never be destroyed by this branch). Supersede — destroy the parked one and - // cold-start — awaited so its teardown cannot overlap our acquire. + // `destroyed`: a dead entry left by a drain (`destroyAll`) or a teardown that has already + // run. Nothing is in flight on it, so clearing the key and cold-starting is correct and + // costs nothing warm. klog(`evict (supersede-${existing.state}) key=${key}; cold`); await pool.evict(key, `supersede-${existing.state}`, "failed-turn"); } else { diff --git a/services/runner/src/protocol.ts b/services/runner/src/protocol.ts index d5d7dfe8a95..1b30143e42e 100644 --- a/services/runner/src/protocol.ts +++ b/services/runner/src/protocol.ts @@ -465,6 +465,18 @@ export type AgentEvent = total?: number; cost?: number; } + /** + * This turn's ADMITTED execution id, emitted once at the start of a session-owned run. + * + * The runner mints the turn id per execution (`resolveTurnId`), so before this the browser had + * no way to learn it: the client's `start` frame is built and sent before the runner replies at + * all. Without the id no first-party client can name the execution it means to act on, which is + * why `expected_execution_id` on the public Cancel has never had a caller that could fill it. + * + * Emitted LIVE only, never through the persisting emitter: it is transport correlation, not + * conversation, and it must not become a record in the session's history. + */ + | { type: "turn"; turnId: string } | { type: "error"; message: string; @@ -507,6 +519,12 @@ export interface ModelCredentialBinding { name: string; } +/** A resolved custom credential whose plaintext is required by code inside the sandbox. */ +export interface SandboxCredential { + binding: ModelCredentialBinding; + value: string; +} + /** * One secret the model provider needs, plus enough information to decide whether the sandbox is * allowed to see it. @@ -669,6 +687,8 @@ export interface AgentRunRequest { connection?: { mode: string; slug?: string }; /** Resolved model routing and credential bindings, grouped under their consumer. */ modelConnection?: ModelConnection; + /** Resolved custom credentials delivered as readable sandbox environment variables. */ + sandboxCredentials?: SandboxCredential[]; /** The conversation so far; the runner picks the latest turn and replays the rest. */ messages?: ChatMessage[]; /** Deprecated: accepted and ignored. Pi activates every built-in tool on every run. */ @@ -760,6 +780,8 @@ export interface AgentRunRequest { * non-session runs. A session sees a sequence of turnIds (send/steer each start a new one). */ turnId?: string; + /** True only when the shared event route, rather than this HTTP response, owns delivery. */ + detached?: boolean; /** * The Agenta project id for this run. Set alongside `turnId` on session-owned runs so * the runner can include it in heartbeat and record-ingest calls. Absent otherwise. @@ -798,6 +820,12 @@ export interface AgentRunResult { usage?: AgentUsage; /** Why the turn ended (harness-reported when available). */ stopReason?: string; + /** + * Only on `stopReason: "cancelled"`. True when the harness was told to stop AND confirmed it + * stopped inside the settle budget, which is what lets the sandbox be parked warm instead of + * deleted. Absent or false means the harness never confirmed, so the environment is destroyed. + */ + cancelSettled?: boolean; /** What the harness was probed to support this run. */ capabilities?: HarnessCapabilities; sessionId?: string; diff --git a/services/runner/src/redaction.ts b/services/runner/src/redaction.ts index a0f3a1d1492..a6589ef7810 100644 --- a/services/runner/src/redaction.ts +++ b/services/runner/src/redaction.ts @@ -188,12 +188,57 @@ function looksSecret(name: string): boolean { ); } +function looksLikeFilePath(value: string): boolean { + const trimmed = value.trim(); + return ( + trimmed.startsWith("/") || + trimmed.startsWith("./") || + trimmed.startsWith("../") || + trimmed.startsWith("~/") || + /^[A-Za-z]:[\\/]/.test(trimmed) || + trimmed.startsWith("\\\\") + ); +} + +/** Some provider SDK settings travel through credential-shaped fields because the SDK reads + * them locally, even though their values are locators rather than credential material. */ +function isNonSecretCredentialLocator(name: string, value: string): boolean { + const upper = name.toUpperCase(); + return ( + upper === "AWS_PROFILE" || + (upper === "GOOGLE_APPLICATION_CREDENTIALS" && looksLikeFilePath(value)) + ); +} + +const PUBLIC_MODEL_ENVIRONMENT_BINDINGS: ReadonlySet = new Set([ + "AWS_REGION", + "AWS_DEFAULT_REGION", + "GOOGLE_CLOUD_PROJECT", + "GOOGLE_CLOUD_LOCATION", +]); + +/** Keep the public provider configuration named by the wire contract out of the deny-set while + * conservatively redacting unknown legacy environment bindings. */ +export function modelEnvironmentSecretValues( + environment: Record = {}, +): string[] { + return Object.entries(environment) + .filter(([name]) => !PUBLIC_MODEL_ENVIRONMENT_BINDINGS.has(name)) + .map(([, value]) => value); +} + /** The VALUES (never the names) of every process env var whose name is selected by the * PREFIX/SUFFIX/BLOCKLIST matchers. Mirrors `seed.py`'s `curated_env_secret_values`. */ export function curatedEnvSecretValues(): string[] { const values: string[] = []; for (const [name, value] of Object.entries(process.env)) { - if (value && looksSecret(name)) values.push(value); + if ( + value && + looksSecret(name) && + !isNonSecretCredentialLocator(name, value) + ) { + values.push(value); + } } return values; } @@ -399,10 +444,17 @@ export function seedFromEnv(options?: { /** The shape `seedForRun` reads off an `AgentRunRequest` (structural, to avoid importing the * wire types into the redaction primitive). */ export interface RunSeedSource { - /** Resolved model routing: typed credential values plus the materialized environment values. */ + /** Resolved custom credentials injected into the sandbox environment. */ + sandboxCredentials?: Array<{ value?: string }>; + /** Resolved model routing. Approved `environment` bindings are public config; unknown legacy + * bindings remain fail-safe. `credentials` carries secrets and provider-SDK locators. */ modelConnection?: { environment?: Record; - credentials?: Array<{ value?: string }>; + credentials?: Array<{ + value?: string; + binding?: { kind?: string; name?: string }; + usage?: string; + }>; }; /** Resolved MCP servers: each connection's typed secret header credential values. */ mcpServers?: Array<{ @@ -414,21 +466,30 @@ export interface RunSeedSource { } /** - * Every credential-bearing value the request's TYPED shapes carry: the model connection's - * credential values and materialized environment values, plus each MCP server connection's - * credential values. This is a superset of whatever subset actually lands in the sandbox env - * (on a Daytona Secrets run the opaque values leave the plaintext env for the secret plan, but - * they still transit runner memory and can be echoed by the model), so the deny-set seeds from - * the request, not from the delivered environment. + * Every credential-bearing value the request's TYPED shapes carry: unknown legacy model + * environment values, actual model credential values, and MCP credential values. Approved public + * model configuration and provider-SDK locators stay readable. */ export function requestSecretValues( request: RunSeedSource, ): Array { return [ - ...Object.values(request.modelConnection?.environment ?? {}), - ...(request.modelConnection?.credentials ?? []).map( - (credential) => credential.value, - ), + ...(request.sandboxCredentials ?? []).map((credential) => credential.value), + ...modelEnvironmentSecretValues(request.modelConnection?.environment), + ...(request.modelConnection?.credentials ?? []) + .filter( + (credential) => + !( + credential.value && + credential.binding?.kind === "environment" && + credential.binding.name && + isNonSecretCredentialLocator( + credential.binding.name, + credential.value, + ) + ), + ) + .map((credential) => credential.value), ...(request.mcpServers ?? []).flatMap((server) => (server.connection?.credentials ?? []).map( (credential) => credential.value, diff --git a/services/runner/src/server.ts b/services/runner/src/server.ts index 56fc89d5cd8..124eb73a034 100644 --- a/services/runner/src/server.ts +++ b/services/runner/src/server.ts @@ -8,6 +8,7 @@ * GET /subscription-status -> one login state per harness (no paths, no credentials) * POST /stream -> body is an AgentRunRequest, NDJSON event stream (alias: POST /run) * POST /kill -> best-effort, idempotent teardown, scoped to one { sessionId, projectId } + * POST /cancel -> stop the CURRENT TURN of one session and keep it warm * * Uses Node's built-in http server (no framework dependency). * @@ -16,6 +17,10 @@ */ import { apiBase, runWithRequestApiBase } from "./apiBase.ts"; import { loadDurableDecisions } from "./sessions/interactions.ts"; +import { + isUserStopAbort, + USER_STOP_ABORT_REASON, +} from "./sessions/stop-signal.ts"; import { randomUUID, timingSafeEqual } from "node:crypto"; import { createServer, @@ -46,6 +51,10 @@ import { type ParkedApproval, type SessionEnvironment, } from "./engines/sandbox_agent.ts"; +import { + cancelHarnessTurn, + resolveCancelSettleMs, +} from "./engines/sandbox_agent/cancel-turn.ts"; import { isMounted, type MountCredentials, @@ -54,6 +63,7 @@ import type { TeardownReason } from "./engines/sandbox_agent/teardown.ts"; import { approvalDecisionForToolCall, poolKeyFor, + projectScopeFor, readKeepaliveConfig, tailIsFreshUserMessage, type KeepaliveConfig, @@ -76,7 +86,34 @@ import { import { applyDaytonaSdkEnv } from "./engines/sandbox_agent/daytona-provider.ts"; import { isEntrypoint } from "./entry.ts"; import { insecureEgressAllowed } from "./tools/ssrf-guard.ts"; -import { startAliveWatchdog } from "./sessions/alive.ts"; +import { + SESSION_TURN_IN_USE_CODE, + SESSION_TURN_IN_USE_MESSAGE, +} from "./sessions/admission.ts"; +import { + REPLICA_ID, + releaseOwnedSessions, + startAliveWatchdog, +} from "./sessions/alive.ts"; +import { + applyCommand, + holdsSession, + type ControlCommand, + type ParkedSessionControl, +} from "./sessions/control-channel.ts"; +import { + noteExecutionProject, + registerExecution, + unregisterExecution, +} from "./sessions/execution-registry.ts"; +import { + awaitTurnOrAbandon, + resolveTurnSettleLimits, +} from "./sessions/turn-settle.ts"; +import { + ABANDONED_TURN_MARKER, + type RunErrorCode, +} from "./engines/sandbox_agent/errors.ts"; import { buildWorkflowReferenceList, cancelStaleInteractions, @@ -285,6 +322,7 @@ const realKeepaliveEngine: KeepaliveEngine = { try { result = await runTurn(acquired.env, request, emit, signal, { loaded: acquired.env.loadedFromContinuity, + nativeHistoryVerified: acquired.env.nativeHistoryVerified, ...(credential ? { credential } : {}), seededDecisions: await loadDurableDecisions( acquired.env.sessionId, @@ -349,6 +387,15 @@ const runAgent: RunAgent = (request, emit, signal, options) => { config, clientGone: options?.clientGone, credential: options?.credential, + // The coordinator is the first place that knows this run's project, because the scope can + // come from the signed mount rather than the request. A control command needs it to tell + // one tenant's session from another's. + onScopeResolved: (projectId) => { + const sessionId = request.sessionId?.trim(); + const turnId = request.turnId?.trim(); + if (sessionId && turnId) + noteExecutionProject(sessionId, turnId, projectId); + }, }); }; @@ -391,7 +438,7 @@ function inBandAnswerTokens(request: AgentRunRequest): string[] | undefined { * exactly one terminal `{kind:"result"}` line (success or failure). Selected by the caller * with `Accept: application/x-ndjson`; the one-shot `/run` path is left untouched. * - * For session-owned runs (a sessionId is present; the turnId is runner-minted): + * For session-owned runs (including explicitly detached shared-sender runs): * - the run survives client disconnect (abort is NOT wired to the response close event); * - every event is persisted producer-side via the record ingest endpoint; * - an alive-lock watchdog heartbeats the coordination plane for the run's lifetime. @@ -426,6 +473,7 @@ async function runAndStreamWithApiBaseResolved( }); const sessionOwned = isSessionOwned(request); + const detached = sessionOwned && request.detached === true; const sessionId = request.sessionId!; const turnId = resolveTurnId(request); // Write the resolved id back: every downstream reader of `request.turnId` (the turns-ledger @@ -445,11 +493,18 @@ async function runAndStreamWithApiBaseResolved( `[sessions] stream sessionOwned=${sessionOwned} sessionId=${sessionId ?? "-"} turnId=${turnId ?? "-"} cred=${credentialState}\n`, ); - // Session-owned runs survive client disconnect — the runner owns the run. Non-session - // runs abort on disconnect (original behavior: caller drives, disconnect = cancel). + // Session-owned runs survive client disconnect; detached additionally selects shared response. + // Non-session runs remain request-owned: closing invoke aborts the turn. const controller = new AbortController(); let clientDisconnected = false; - if (!sessionOwned) { + // Resolves when the platform tells us this turn is no longer current — a Stop, a takeover, + // or the API's own execution watchdog having declared the turn lost. `awaitTurnOrAbandon` + // uses it to stop waiting on a run that may never return. See `sessions/turn-settle.ts`. + let markInterrupted: ((reason: string) => void) | undefined; + const interrupted = new Promise((resolve) => { + markInterrupted = resolve; + }); + if (!sessionOwned && !detached) { // Listen on the response, not the request: the request body is already fully read, so // its `close` can fire early on a keep-alive connection. `res` `close` fires when the // response connection ends — after a normal `res.end()` (harmless: the run is already @@ -471,6 +526,21 @@ async function runAndStreamWithApiBaseResolved( res.write(JSON.stringify(record) + "\n"); }; const liveEmit: EmitEvent = (event) => writeRecord({ kind: "event", event }); + // The invoke stream's sole positive payload in shared mode: correlation/acceptance. Live text + // and tools arrive through /sessions/{id}/events and are filtered from invoke client-side. + // + // Emitted only from the admission path below, never here: it switches the client to shared + // delivery, and a turn the runner is about to refuse (bad attachments, a competing turn already + // holding the session) must not move the client off its local stream first. + const emitSessionAccepted = () => { + if (!detached) return; + liveEmit({ + type: "data", + name: "session-accepted", + data: { sessionId, turnId, executionId: turnId }, + transient: true, + }); + }; const turn = currentUserTurn(request); const attachmentError = attachmentCountError(turn.attachments.length); if (attachmentError) { @@ -485,8 +555,20 @@ async function runAndStreamWithApiBaseResolved( // For session-owned runs: wrap the live emitter so every event is also persisted // producer-side, independent of whether the client is still connected. let emitFn: EmitEvent = liveEmit; + // Closed once this request has written the turn's terminal outcome. An abandoned run may + // still unwind minutes later and emit its own `error`/`done` through the same emitter; the + // turn already has an ending, and a second one would put two endings in one transcript. + let turnClosed = false; + const gatedEmit: EmitEvent = (event) => { + if (turnClosed) return; + emitFn(event); + }; let flushPersist: (() => Promise) | undefined; - let persistError: ((message: string) => void) | undefined; + let persistError: + | ((message: string, code?: RunErrorCode) => void) + | undefined; + let persistTerminal: ((stopReason?: string) => void) | undefined; + let terminalRecordEmitted = false; let aliveWatchdog: | { release: () => Promise; @@ -494,97 +576,230 @@ async function runAndStreamWithApiBaseResolved( } | undefined; - if (sessionOwned) { - // The request's api base (if any) is already scoped for this call via - // runWithRequestApiBase in the outer runAndStream — apiBase() below sees it. - // The runner authenticates session calls AS the invoke caller (the run credential), - // refreshing it for the turn's lifetime — never the admin key. Project scope is - // resolved server-side from the credential, so no project_id rides the request. - // - // onInterrupted (W7.4): a cancel/steer/kill against this session (via - // `POST /sessions/streams/` or the runner's own `/kill`) drops this turn's alive lock. - // The next heartbeat surfaces that as `is_current_turn: false`; wiring it to - // `controller.abort()` is what makes the control-plane signal actually reach this - // in-flight run — before this, a session-owned run's controller was never aborted. - // Awaited (WP3) so the first heartbeat's stream_id is ready before the turn starts. - // - // The beat also proposes the two things a headless session otherwise never gets: a name - // (no browser ever renders it, and the browser is the only other title writer) and the - // run's workflow references (they ride only a fire-and-forget turn append today, so a - // dropped append leaves a row the UI cannot open). Both are fill-once server-side. - const watchdog = await startAliveWatchdog( - sessionId, - turnId, - platformCredentialForRequest(request), - () => controller.abort(), - { - name: proposeSessionName(request), - references: buildWorkflowReferenceList(request.runContext?.workflow), - }, - ); - aliveWatchdog = watchdog; - // The heartbeat response already carries the session_streams row id — free, no extra - // round-trip. Thread it onto the request so the engine's turn-append write has it. - request.streamId = watchdog.streamId(); - // A new turn supersedes any prior turn's unanswered gate: cancel stale pending - // interactions (sparing this turn's own, plus a parked gate this turn answers in-band — - // the resume resolves that one). Best-effort, never blocks the turn. - const answeredTokens = inBandAnswerTokens(request); - void cancelStaleInteractions( - sessionId, - turnId, - answeredTokens, - watchdog.credential, - ); - // Deny-set from THIS run's typed credential material (model connection credentials + - // materialized environment values + MCP connection credentials) and the run credential — - // not process env, which never holds them. A credential value a model echoes back must - // never reach the durable session records unredacted. - const { - emit: persistingEmit, - persist, - flush, - } = buildPersistingEmitter( - sessionId, - watchdog.credential, - liveEmit, - seedForRun(request), - turnId, - request.runContext?.trace?.span_id, - ); - // Record the inbound user turn first so the session record is the full conversation, not just - // agent output. Guard on `tailIsFreshUserMessage`: an approval RESUME's tail is the tool_result - // envelope, so it must not re-persist the ORIGINAL prompt as a duplicate user row. The guard - // writes the prompt only on the turn that first introduced it. - if (tailIsFreshUserMessage(request)) { - persist( - { type: "message", text: turn.text, attachments: turn.attachments }, - "user", + try { + if (sessionOwned) { + // The request's api base (if any) is already scoped for this call via + // runWithRequestApiBase in the outer runAndStream — apiBase() below sees it. + // The runner authenticates session calls AS the invoke caller (the run credential), + // refreshing it for the turn's lifetime — never the admin key. Project scope is + // resolved server-side from the credential, so no project_id rides the request. + // + // onInterrupted (W7.4): a cancel/steer/kill against this session (via + // `POST /sessions/streams/` or the runner's own `/kill`) drops this turn's alive lock. + // The next heartbeat surfaces that as `is_current_turn: false`; wiring it to + // `controller.abort()` is what makes the control-plane signal actually reach this + // in-flight run — before this, a session-owned run's controller was never aborted. + // Awaited (WP3) so the first heartbeat's stream_id is ready before the turn starts. + // + // The beat also proposes the two things a headless session otherwise never gets: a name + // (no browser ever renders it, and the browser is the only other title writer) and the + // run's workflow references (they ride only a fire-and-forget turn append today, so a + // dropped append leaves a row the UI cannot open). Both are fill-once server-side. + const watchdog = await startAliveWatchdog( + sessionId, + turnId, + platformCredentialForRequest(request), + () => { + markInterrupted?.( + "the platform reported this turn is no longer current (stopped, taken over, or " + + "declared lost)", + ); + // LABELLED, not a bare abort: `shouldPark` parks only an abort it can prove was a + // cooperative Stop. See `sessions/stop-signal.ts`. + controller.abort(USER_STOP_ABORT_REASON); + }, + { + name: proposeSessionName(request), + references: buildWorkflowReferenceList(request.runContext?.workflow), + }, ); - if (turn.attachments.length > 0) { - // A failed claim is accepted as graceful loss: the worst case is that the sweeper - // reclaims the attachment and cold replay renders it as no longer available. - await claimAttachments( - sessionId, - turn.attachments.map((attachment) => attachment.attachmentId), - watchdog.credential, + aliveWatchdog = watchdog; + // The heartbeat response already carries the session_streams row id — free, no extra + // round-trip. Thread it onto the request so the engine's turn-append write has it. + request.streamId = watchdog.streamId(); + + // ADMISSION. That first beat asked the platform's atomic `nx` acquire whether this turn may + // run, and `admitted: false` means a DIFFERENT turn already holds the session. Stop here. + // + // Everything below this point has a side effect that a refused turn must not have: + // `cancelStaleInteractions` would cancel the LIVE turn's unanswered approval gate, the + // persisting emitter would write this message into the durable transcript, and `run()` would + // reach the keepalive pool and destroy the live turn's warm environment. That last one is + // the double-send bug (#6417, #5539, #5538): the arbiter's answer was already correct, the + // runner simply never read it before acting. + // + // The refusal travels as an `error` EVENT with a stable code plus a failed terminal result, + // which is the path every runner failure already takes to the browser. Nothing is persisted, + // so the refused message never appears in the session's history — the client keeps the text. + if (!watchdog.admitted) { + process.stderr.write( + `[sessions] admission REFUSED session=${sessionId} turn=${turnId}; ` + + `another turn owns this session. No pool resolve, no eviction.\n`, + ); + // Stops the heartbeat interval and releases the credential lease. Its final + // `is_running: false` beat is owner-scoped server-side, so it cannot clear the live + // turn's `running` lock or stamp its own turn id on the session row. + await watchdog.release().catch(() => {}); + unregisterExecution(sessionId, turnId); + liveEmit({ + type: "error", + message: SESSION_TURN_IN_USE_MESSAGE, + code: SESSION_TURN_IN_USE_CODE, + }); + writeRecord({ + kind: "result", + result: { ok: false, error: SESSION_TURN_IN_USE_MESSAGE, events: [] }, + }); + res.end(); + return; + } + + // A refused contender must never replace the admitted execution's Stop handle. + registerExecution({ + projectId: projectScopeFor(request, undefined)?.id, + sessionId, + turnId, + startedAt: Date.now(), + abort: () => controller.abort(USER_STOP_ABORT_REASON), + }); + + // Admitted. Tell the client which execution it is watching, before anything else streams. + // + // The runner mints the turn id (`resolveTurnId`), and until now it never told anyone: the + // client's `start` frame is built and sent before the runner replies at all, so it cannot + // carry a runner-minted id. That is why `expected_execution_id` on the public Cancel has had + // no first-party caller able to fill it — a Stop could only mean "whatever is running now", + // never "the turn I was watching". This is the earliest frame that can carry it. + // + // Deliberately on `liveEmit`, not the persisting emitter that replaces it below: this is + // transport correlation, not conversation, and it must never become a session record. + // + // Acceptance rides the same frame for the same reason, and lands here rather than at the + // top of the request so it can never precede the admission verdict. + emitSessionAccepted(); + liveEmit({ type: "turn", turnId }); + + // A new turn supersedes any prior turn's unanswered gate: cancel stale pending + // interactions (sparing this turn's own, plus a parked gate this turn answers in-band — + // the resume resolves that one). Best-effort, never blocks the turn. + const answeredTokens = inBandAnswerTokens(request); + void cancelStaleInteractions( + sessionId, + turnId, + answeredTokens, + watchdog.credential, + ); + // Deny-set from THIS run's typed credential material (model connection credentials + + // materialized environment values + MCP connection credentials) and the run credential — + // not process env, which never holds them. A credential value a model echoes back must + // never reach the durable session records unredacted. + const { + emit: persistingEmit, + persist, + flush, + } = buildPersistingEmitter( + sessionId, + watchdog.credential, + liveEmit, + seedForRun(request), + turnId, + request.runContext?.trace?.span_id, + ); + // Record the inbound user turn first so the session record is the full conversation, not just + // agent output. Guard on `tailIsFreshUserMessage`: an approval RESUME's tail is the tool_result + // envelope, so it must not re-persist the ORIGINAL prompt as a duplicate user row. The guard + // writes the prompt only on the turn that first introduced it. + if (tailIsFreshUserMessage(request)) { + persist( + { type: "message", text: turn.text, attachments: turn.attachments }, + "user", ); + if (turn.attachments.length > 0) { + // A failed claim is accepted as graceful loss: the worst case is that the sweeper + // reclaims the attachment and cold replay renders it as no longer available. + await claimAttachments( + sessionId, + turn.attachments.map((attachment) => attachment.attachmentId), + watchdog.credential, + ); + } } + emitFn = (event) => { + if (event.type === "done") terminalRecordEmitted = true; + persistingEmit(event); + }; + flushPersist = flush; + persistError = (message, code) => + persist({ type: "error", message, ...(code ? { code } : {}) }, "agent"); + persistTerminal = (stopReason) => { + terminalRecordEmitted = true; + persist( + { + type: "done", + ...(stopReason === "cancelled" ? { stopReason } : {}), + }, + "agent", + ); + }; } - emitFn = persistingEmit; - flushPersist = flush; - persistError = (message) => persist({ type: "error", message }, "agent"); + } catch (error) { + if (aliveWatchdog) await aliveWatchdog.release().catch(() => {}); + if (sessionOwned) unregisterExecution(sessionId, turnId); + throw error; } let result: AgentRunResult; try { - result = await run(request, emitFn, controller.signal, { - clientGone: () => clientDisconnected, - credential: aliveWatchdog?.credential, + // Not a bare `await run(...)`: an await inside the run that never settles would keep this + // function parked forever, and with it the terminal record below AND the alive watchdog's + // release in the `finally` — the turn would announce `running=true` every 30s for good. + // `awaitTurnOrAbandon` returns either the run's own result or a reason to write one + // without it, so this request always produces exactly one terminal outcome. + const outcome = await awaitTurnOrAbandon({ + run: run(request, gatedEmit, controller.signal, { + clientGone: () => clientDisconnected, + credential: aliveWatchdog?.credential, + }), + abort: () => controller.abort(), + interrupted: sessionOwned ? interrupted : undefined, + limits: resolveTurnSettleLimits((message) => + process.stderr.write(`${message}\n`), + ), + log: (message) => process.stderr.write(`${message}\n`), }); - // A failed engine run ({ok:false}) already emitted its own error EVENT through the - // persisting emitter, so no extra persist here (it would duplicate the record). Drain - // all queued persists before the sandbox tears down. + if (outcome.settled) { + result = outcome.value; + // `runTurn` normally emits `done` itself. Acquisition can fail before `runTurn` starts, + // though, and a cooperative Stop during a cold sandbox create reaches exactly that path. + // Close any failed run that emitted no terminal record; preserve the Stop marker when the + // labelled control-plane abort caused it. A genuine acquire failure never reached runTurn's + // error emitter, so preserve its error before the done backstop instead of making the empty + // turn look successful. Both records use the same ordered persistence chain as runTurn's + // emitter but stay off the live stream, whose result envelope is unchanged. + if ( + !terminalRecordEmitted && + persistTerminal && + (!result.ok || isUserStopAbort(controller.signal)) + ) { + const userStopped = isUserStopAbort(controller.signal); + if (!userStopped && !result.ok && persistError) { + persistError(result.error ?? "Agent run failed."); + } + persistTerminal(userStopped ? "cancelled" : undefined); + } + } else { + // The run is still pending and may never settle. Give the turn the ending the runner + // owes it, and let the abandoned run keep its own teardown if it ever unwinds. + turnClosed = true; + const message = `${ABANDONED_TURN_MARKER}: ${outcome.reason}`; + process.stderr.write( + `[sessions] ABANDONED session=${sessionId ?? "-"} turn=${turnId ?? "-"}: ${outcome.reason}\n`, + ); + if (persistError) persistError(message, "execution_lost"); + result = { ok: false, error: message }; + } + // Drain the terminal backstop or abandonment marker and all prior persists before the + // sandbox tears down. if (flushPersist) await flushPersist(); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -599,6 +814,9 @@ async function runAndStreamWithApiBaseResolved( // A throw escaping run() itself (outside the engine's own try/catch) emitted no error // event — persist it here as the backstop. if (persistError) persistError(message); + if (!terminalRecordEmitted && persistTerminal) { + persistTerminal(isUserStopAbort(controller.signal) ? "cancelled" : undefined); + } if (flushPersist) await flushPersist().catch(() => {}); result = { ok: false, error: message }; } finally { @@ -616,6 +834,10 @@ async function runAndStreamWithApiBaseResolved( } } if (aliveWatchdog) await aliveWatchdog.release().catch(() => {}); + // Same `finally` as the watchdog release, so a run that threw still leaves the registry + // clean. Scoped to this turn id, so a turn that finishes after its successor registered + // cannot unregister the successor. + if (sessionOwned) unregisterExecution(sessionId, turnId); } // Streaming delivered the events live, so don't echo them in the terminal record. @@ -682,6 +904,131 @@ function readBodyCapped( }); } +/** `/cancel`'s payload is five short strings. */ +const CANCEL_BODY_MAX_BYTES = 16 * 1024; + +/** A non-empty trimmed string, or null. Used for every id `/cancel` reads. */ +function readRequiredId(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +/** + * Does the keep-alive pool hold this session parked awaiting an approval? + * + * A Stop against a parked approval has no entry in the execution registry, because no turn is + * running. Without this lookup the runner would answer 404 for exactly the case that has no + * control channel at all today: a parked session stops heartbeating, so the only existing Stop + * signal never reaches it. + */ +function parkedSessionControl( + projectId: string, + sessionId: string, +): ParkedSessionControl | undefined { + const key = `${projectId}:${sessionId}`; + for (const provider of Object.keys( + keepalivePools, + ) as KeepaliveProviderName[]) { + const pool = keepalivePools[provider]; + const parked = pool.get(key); + if (!parked || parked.state !== "awaiting_approval") continue; + return { + stop: async () => { + // Checkout makes the transition exclusive: a racing request cannot consume the same + // permission gate while Stop is releasing it. + const live = pool.checkoutApproval(key); + if (!live) throw new Error("parked approval was already checked out"); + await stopParkedApprovalSession({ + environment: live.environment, + repark: () => + pool.repark( + live, + { + historyFingerprint: live.historyFingerprint, + historyAsserted: live.historyAsserted, + credentialEpoch: live.credentialEpoch, + }, + keepaliveConfigs[provider].stoppedTtlMs ?? + keepaliveConfigs[provider].ttlMs, + ), + teardown: () => + pool.evictIfCurrent( + live, + "stop-approval-failed", + "failed-turn", + ), + }); + }, + }; + } + return undefined; +} + +interface StopParkedApprovalSessionInput { + environment: SessionEnvironment; + repark: () => Promise; + teardown: () => Promise; + /** Test seams; production uses the operator-configured bound and a real timer. */ + cancelSettleMs?: number; + wait?: (ms: number) => Promise; +} + +/** Reject and cancel a parked prompt before exposing its environment as idle again. */ +export async function stopParkedApprovalSession( + input: StopParkedApprovalSessionInput, +): Promise { + const env = input.environment; + const gates = [...env.parkedApprovals.values()]; + try { + await Promise.all( + gates.map((gate) => + env.session.respondPermission(gate.permissionId, "reject"), + ), + ); + const cancel = await cancelHarnessTurn({ + sandbox: env.sandbox, + sessionId: env.session?.id, + promptPromise: gates[0]?.promptPromise, + timeoutMs: input.cancelSettleMs ?? resolveCancelSettleMs(), + log: env.logger, + wait: input.wait, + }); + if (cancel.requested) env.sessionDestroyRequested = true; + if (!cancel.settled && cancel.requested) { + // The ACP cancel WAS sent but the harness did not confirm it inside the budget. The prompt + // may still be open, so fail closed rather than present a possibly-running turn as idle. + throw new Error("parked approval harness cancel did not settle"); + } + if (!cancel.settled) { + // No ACP cancel could be SENT: a local runtime whose sandbox client has no `cancelSession` + // (`stage=harness_cancel sent=false reason=client-has-no-cancelSession`). The reject above + // is still the stop signal for a parked approval, which runs no turn, and a Stop must NEVER + // evict the warm sandbox. So repark it warm, exactly as the reject-then-repark path did + // before the ACP cancel was added, instead of tearing it down and reporting a failed Stop. + env.logger( + "stage=parked_stop reject-only (client has no cancelSession); reparking warm", + ); + } + + env.parkedApprovals.clear(); + env.parkedApproval = undefined; + env.parkedApprovedExecutions?.clear(); + env.approvalGateCount = 0; + env.nonParkablePauseCount = 0; + env.commitAuthorization = undefined; + env.clearTurn(); + if (!(await input.repark())) { + throw new Error("released approval could not return to the pool"); + } + } catch (error) { + // A partly released or unsettled prompt is not safe to present as idle. Fail closed through + // the normal teardown path; applyCommand reports the failed outcome. + await input.teardown(); + throw error; + } +} + /** Build the HTTP request listener around a given engine runner (the testable seam). */ export function createRequestListener( run: RunAgent, @@ -751,6 +1098,78 @@ export function createRequestListener( return send(res, 200, { ok: true }); } + if (req.method === "POST" && req.url === "/cancel") { + if (!isAuthorized(req)) { + return send(res, 401, { ok: false, error: "Unauthorized" }); + } + // Stop the CURRENT TURN and keep the session warm. This is not `/kill`: the sandbox, + // the native harness session and the keep-alive pool entry all survive, and the next + // message continues the same conversation. + // + // The response is an ACKNOWLEDGEMENT, not an outcome. What happened to the execution + // goes to the API's outcome route, so settlement has one path on every transport. + let cancelBody: { + commandId?: unknown; + projectId?: unknown; + sessionId?: unknown; + targetTurnId?: unknown; + createdAt?: unknown; + }; + try { + const raw = await readBodyCapped(req, CANCEL_BODY_MAX_BYTES); + cancelBody = raw.trim() ? JSON.parse(raw) : {}; + } catch (err) { + if (err instanceof BodyTooLargeError) { + return send(res, 413, { ok: false, error: err.message }); + } + return send(res, 400, { + ok: false, + error: `Invalid JSON: ${err instanceof Error ? err.message : String(err)}`, + }); + } + const commandId = readRequiredId(cancelBody.commandId); + const cancelSessionId = readRequiredId(cancelBody.sessionId); + const cancelProjectId = readRequiredId(cancelBody.projectId); + if (!commandId || !cancelSessionId || !cancelProjectId) { + return send(res, 400, { + ok: false, + error: + "commandId, sessionId and projectId are all required: a pool key is always project-scoped", + }); + } + const command: ControlCommand = { + id: commandId, + projectId: cancelProjectId, + sessionId: cancelSessionId, + kind: "cancel", + target: { + turnId: readRequiredId(cancelBody.targetTurnId), + expectedTurnId: null, + }, + createdAt: + typeof cancelBody.createdAt === "string" + ? cancelBody.createdAt + : "", + }; + if ( + !holdsSession( + cancelProjectId, + cancelSessionId, + parkedSessionControl, + ) + ) { + // 404 is ambiguous on purpose and the API disambiguates it: a `not_held` for a + // session whose row is alive and beating means the call reached the wrong replica. + return send(res, 404, { ok: false, error: "session not held here" }); + } + // Answer before the outcome. The applier reports it separately, and a Stop that takes + // seconds to settle must not hold this request open. + void applyCommand(command, { + isParked: parkedSessionControl, + }).catch(() => {}); + return send(res, 202, { ok: true, replicaId: REPLICA_ID }); + } + // POST /stream is the productized name; /run is kept as a back-compat alias // for one release (the SDK still posts /run). Both share the handler. if ( @@ -889,6 +1308,14 @@ if (isEntrypoint(import.meta.url)) { ), ); await destroyInFlightSandboxes(timeoutMs, "shutdown-in-flight"); + // LAST, and only after the sandboxes are gone: hand back the `owner:session:` + // affinity keys this replica holds. Nothing else releases them, and `claim_owner` never + // steals, so without this the replacement replica is refused every message on those + // sessions for the rest of the 120-second lease. It runs last because a session whose + // sandbox is still being destroyed should not yet look free to another replica, and it + // is bounded so it can never hold the process past the SIGTERM grace period. A SIGKILL + // reaches no handler at all; the lease stays the fallback for that. + await releaseOwnedSessions(timeoutMs); }, }); diff --git a/services/runner/src/sessions/admission.ts b/services/runner/src/sessions/admission.ts new file mode 100644 index 00000000000..e711af76484 --- /dev/null +++ b/services/runner/src/sessions/admission.ts @@ -0,0 +1,28 @@ +/** + * Single-turn admission: at most one execution runs per session, decided in one place. + * + * The decision is NOT made here. It is made by the platform API's atomic `nx` acquire of the + * `alive` Redis lock, which the runner asks for on a turn's first heartbeat + * (`sessions/alive.ts` -> `POST /sessions/streams/heartbeat` -> + * `api/oss/src/core/sessions/streams/service.py`). This module holds only what the runner needs + * to REPORT that decision: the stable code and the one line the user reads. + * + * Why the runner has to stop rather than continue: before this, a second turn that lost the + * acquire still walked into the keepalive pool, found the first turn's environment busy, and + * destroyed it (`lifecycle/session-coordinator.ts`, the old `supersede-busy` branch). Both turns + * then died and the session stayed locked under a dead turn's lease. Refusing at the edge is what + * makes the first turn survive. + */ + +import type { RunErrorCode } from "../engines/sandbox_agent/errors.ts"; + +/** Stable class for a refused turn. Never a display string. */ +export const SESSION_TURN_IN_USE_CODE: RunErrorCode = "session_turn_in_use"; + +/** + * Product copy. The reader is the person in the chat, so it says what happened to THEIR message + * and what to do next, with no lock, turn, or session-id mechanics. It must stay ONE line: the + * SDK's `sanitize_runner_error` keeps only the first line of a runner error. + */ +export const SESSION_TURN_IN_USE_MESSAGE = + "This session is already running a turn. Your message was not sent. Wait for the reply, or stop the turn, then send again."; diff --git a/services/runner/src/sessions/alive.ts b/services/runner/src/sessions/alive.ts index 292ee3d825e..eeeef93baf3 100644 --- a/services/runner/src/sessions/alive.ts +++ b/services/runner/src/sessions/alive.ts @@ -15,13 +15,28 @@ * Key contract constants mirror `sessions/contract.ts`; do not duplicate them. */ +import { envTimerMs } from "../env.ts"; import { apiBase } from "../apiBase.ts"; import { randomUUID } from "node:crypto"; -import { HEARTBEAT_INTERVAL_SECONDS } from "./contract.ts"; +import { HEARTBEAT_INTERVAL_SECONDS, OWNER_TTL_SECONDS } from "./contract.ts"; const REFRESH_INTERVAL_MS = HEARTBEAT_INTERVAL_SECONDS * 1000; +export const HEARTBEAT_TIMEOUT_ENV = "AGENTA_RUNNER_HEARTBEAT_TIMEOUT_MS"; +/** + * A beat that never answers must not outlive its interval. + * + * The beat used a bare `fetch` with no signal, so a stalled socket never settled: beats piled + * up behind it, and the final `is_running: false` beat in `release()` could hold the request + * open after the turn had already ended. Half an interval keeps at most one beat in flight. + */ +export const DEFAULT_HEARTBEAT_TIMEOUT_MS = Math.floor(REFRESH_INTERVAL_MS / 2); + +function heartbeatTimeoutMs(): number { + return envTimerMs(HEARTBEAT_TIMEOUT_ENV, DEFAULT_HEARTBEAT_TIMEOUT_MS); +} + /** * This runner container's stable id, minted once per process. An orchestrator can inject a * meaningful id (pod/container name) via `AGENTA_RUNNER_REPLICA_ID`; otherwise a random @@ -50,6 +65,67 @@ function log(msg: string): void { process.stderr.write(`[sessions/alive] ${msg}\n`); } +// --- owner-claim registry -------------------------------------------------- // +// +// WHY THIS EXISTS. `owner:session:` is claimed by every beat and released by nothing, and +// the API's `claim_owner` deliberately never steals from a live owner. So a runner that exits +// while holding claims leaves each of those sessions unusable by the replacement replica until +// the lease expires — measured at 112 to 123 s against a 120 s TTL, on every restart. The +// registry is the smallest thing that makes the shutdown handler able to hand them back: which +// sessions this process claimed, and a credential that can still speak for each one. +// +// The credential is the run's own ephemeral platform token, the same one every beat already +// carries; it never leaves this process and is never logged. An entry that outlives its token +// simply fails its release call and falls back to the lease, exactly as a killed runner does. +// +// BOUNDED BY THE LEASE ITSELF. Every beat records, so without a bound a long-lived runner would +// accumulate one entry per session it ever served, hold each of their credentials for the +// process lifetime, and fire a useless release for every one of them at shutdown. An entry +// whose last beat is older than `OWNER_TTL_SECONDS` cannot still hold the key, so it is pruned: +// the registry holds only what this replica can plausibly still own. + +interface OwnedSession { + authorization: string; + /** When the API last confirmed this replica owns the session. */ + claimedAt: number; +} + +const ownedSessions = new Map(); + +/** Drop entries whose affinity lease cannot still be held. */ +function pruneExpiredClaims(now: number): void { + const cutoff = now - OWNER_TTL_SECONDS * 1000; + for (const [sessionId, entry] of ownedSessions) { + if (entry.claimedAt < cutoff) ownedSessions.delete(sessionId); + } +} + +/** + * Note that this replica holds (or has just refreshed) the affinity key for `sessionId`, so + * the shutdown handler can release it. Called from every beat that the API confirmed we own. + * Overwrites the stored credential, which keeps the freshest token per session. + */ +export function recordOwnedSession( + sessionId: string, + authorization: string, + now: number = Date.now(), +): void { + if (!sessionId || !authorization) return; + pruneExpiredClaims(now); + ownedSessions.set(sessionId, { authorization, claimedAt: now }); +} + +/** Forget a session (a test hook, and the successful-release path). */ +export function forgetOwnedSession(sessionId: string): void { + ownedSessions.delete(sessionId); +} + +/** How many sessions this replica could still own. Test/inspection hook. */ +export function ownedSessionCount(now: number = Date.now()): number { + pruneExpiredClaims(now); + return ownedSessions.size; +} + /** * Send one heartbeat to keep the `alive` lock and the `session_streams` row live. Carries the * container `replica_id` (refreshes `owner` affinity) and the `turn_id` (proves alive ownership). @@ -60,8 +136,8 @@ function log(msg: string): void { * uuid — the free gift of a call the runner already makes every turn, no new round-trip) and * `interrupted: true` when the API reports `is_current_turn: false` (a cancel/steer/kill took * this turn's alive/running lock since the last beat — W7.4, the control-signal path). A - * network/HTTP failure yields `{ streamId: undefined, interrupted: false }` (fail-open: a - * transient API blip must neither abort a healthy run nor fabricate a stream id). + * network/HTTP failure yields `confirmed: false`; callers use that to fail closed for initial + * admission while later watchdog beats remain best effort for a turn already admitted. */ async function sendHeartbeat( sessionId: string, @@ -69,11 +145,16 @@ async function sendHeartbeat( authorization: string, isRunning = true, proposal?: SessionProposal, -): Promise<{ streamId: string | undefined; interrupted: boolean }> { +): Promise<{ + streamId: string | undefined; + interrupted: boolean; + confirmed: boolean; +}> { try { const url = `${apiBase()}/sessions/streams/heartbeat`; const res = await fetch(url, { method: "POST", + signal: AbortSignal.timeout(heartbeatTimeoutMs()), headers: { "content-type": "application/json", authorization, @@ -91,11 +172,12 @@ async function sendHeartbeat( }); if (!res.ok) { log(`heartbeat HTTP ${res.status} session=${sessionId} turn=${turnId}`); - return { streamId: undefined, interrupted: false }; + return { streamId: undefined, interrupted: false, confirmed: false }; } const body = (await res.json()) as { stream?: { id?: unknown } | null; is_current_turn?: unknown; + replica_id?: unknown; }; const rawStreamId = body.stream?.id; const streamId = @@ -103,15 +185,21 @@ async function sendHeartbeat( ? rawStreamId : undefined; const interrupted = body.is_current_turn === false; + // Record ONLY what the API says we own. The beat claims affinity as a side effect, so this + // is the one place that learns the claim happened; a beat this replica lost records nothing + // and the shutdown release skips it. + if (body.replica_id === REPLICA_ID) { + recordOwnedSession(sessionId, authorization); + } log( `heartbeat OK session=${sessionId} turn=${turnId} running=${isRunning}${interrupted ? " INTERRUPTED" : ""}`, ); - return { streamId, interrupted }; + return { streamId, interrupted, confirmed: true }; } catch (err) { log( `heartbeat failed session=${sessionId} turn=${turnId}: ${String(err instanceof Error ? err.message : err).slice(0, 120)}`, ); - return { streamId: undefined, interrupted: false }; + return { streamId: undefined, interrupted: false, confirmed: false }; } } @@ -146,6 +234,7 @@ export async function claimSessionOwnership( const body = (await res.json()) as { replica_id?: unknown }; const owner = typeof body.replica_id === "string" ? body.replica_id : undefined; + if (owner === REPLICA_ID) recordOwnedSession(sessionId, authorization); return { replicaId: REPLICA_ID, ownerReplicaId: owner }; } catch (err) { log( @@ -173,6 +262,16 @@ export async function claimSessionOwnership( * the caller MUST await in the run's `finally` so the heartbeat stops and the row is marked * `ended`. * + * That first beat is also this turn's ADMISSION request, and `admitted` reports its answer. The + * beat's `nx` acquire of the `alive` lock is the platform's single atomic arbiter of "who runs + * this session" (`api/oss/src/core/sessions/streams/service.py`), and it already refuses a turn + * that arrives while a different turn holds `running`. Reading that answer BEFORE the caller + * touches the sandbox is what makes at-most-one-execution-per-session true: a refused turn stops + * at the edge instead of reaching the keepalive pool and destroying the live turn's environment. + * + * Initial admission fails closed unless the coordination plane confirms this turn owns the lock. + * Later heartbeat failures remain best effort and do not abort an already-admitted healthy turn. + * * `proposal` rides EVERY beat rather than only the first. The server fills each field once, so * repeating them is a no-op, and one payload for all beats beats a "was this the first?" flag. */ @@ -186,6 +285,8 @@ export async function startAliveWatchdog( release: () => Promise; credential: () => string; streamId: () => string | undefined; + /** False when the FIRST beat reported `is_current_turn: false` — another turn owns the session. */ + admitted: boolean; }> { // Session coordination and standalone turns share this lease. The watchdog owns it here so // heartbeat, persistence, and trace export all observe the same current credential. @@ -218,17 +319,29 @@ export async function startAliveWatchdog( ); handleBeat(first); + // One beat in flight at a time. `setInterval` fires unconditionally, so without this a + // slow API stacks a new request every 30s on top of every request already waiting. + let beatInFlight = false; const interval = setInterval(() => { + if (beatInFlight) { + log(`heartbeat skipped (previous still in flight) session=${sessionId}`); + return; + } + beatInFlight = true; void (async () => { - handleBeat( - await sendHeartbeat( - sessionId, - turnId, - credentialLease.credential(), - true, - proposal, - ), - ); + try { + handleBeat( + await sendHeartbeat( + sessionId, + turnId, + credentialLease.credential(), + true, + proposal, + ), + ); + } finally { + beatInFlight = false; + } })(); }, REFRESH_INTERVAL_MS); @@ -238,6 +351,8 @@ export async function startAliveWatchdog( } return { + // Read from the FIRST beat only. Later interruptions travel the abort path instead. + admitted: first.confirmed && !first.interrupted, async release() { clearInterval(interval); credentialLease.release(); @@ -254,3 +369,80 @@ export async function startAliveWatchdog( streamId: () => streamId, }; } + +/** + * Hand this replica's affinity key for one session back to the coordination plane. + * + * The inverse beat: `release_owner: true`, no turn id, no liveness claim. The API releases + * `owner:session:` only while this replica still holds it, so the call can never take a + * session from a live runner and is safe to repeat. + * + * Never throws. A failure leaves the key to expire on its own lease, which is exactly the + * behaviour a killed (SIGKILL) runner already has. + */ +export async function releaseSessionOwnership( + sessionId: string, + authorization: string, + timeoutMs?: number, +): Promise { + try { + const runnerToken = process.env.AGENTA_RUNNER_TOKEN?.trim(); + const res = await fetch(`${apiBase()}/sessions/streams/heartbeat`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization, + ...(runnerToken ? { "x-agenta-runner-token": runnerToken } : {}), + }, + body: JSON.stringify({ + session_id: sessionId, + replica_id: REPLICA_ID, + release_owner: true, + }), + ...(timeoutMs ? { signal: AbortSignal.timeout(timeoutMs) } : {}), + }); + if (!res.ok) { + log(`ownership release HTTP ${res.status} session=${sessionId}`); + return false; + } + forgetOwnedSession(sessionId); + log(`ownership released session=${sessionId}`); + return true; + } catch (err) { + log( + `ownership release failed session=${sessionId}: ${String(err instanceof Error ? err.message : err).slice(0, 120)}`, + ); + return false; + } +} + +/** How long the whole shutdown release may take before the process stops waiting for it. */ +export const DEFAULT_OWNERSHIP_RELEASE_TIMEOUT_MS = 5_000; + +/** + * Release every affinity key this replica holds. Called from the shutdown handler, so it is + * bounded and never rejects: a runner that cannot reach the API must still exit promptly, and + * the 120-second owner lease is the fallback for that case and for a SIGKILL, which reaches no + * handler at all. + * + * The releases run concurrently because they are independent single-key deletes, and the whole + * set races one deadline rather than each call carrying its own budget. + */ +export async function releaseOwnedSessions( + timeoutMs: number = DEFAULT_OWNERSHIP_RELEASE_TIMEOUT_MS, +): Promise { + pruneExpiredClaims(Date.now()); + const held = [...ownedSessions.entries()]; + if (held.length === 0) return; + log(`releasing ${held.length} session ownership claim(s) on shutdown`); + const releases = Promise.all( + held.map(([sessionId, entry]) => + releaseSessionOwnership(sessionId, entry.authorization, timeoutMs), + ), + ); + const deadline = new Promise((resolve) => { + const handle = setTimeout(resolve, timeoutMs); + handle.unref?.(); + }); + await Promise.race([releases.then(() => undefined), deadline]); +} diff --git a/services/runner/src/sessions/applied-commands.ts b/services/runner/src/sessions/applied-commands.ts new file mode 100644 index 00000000000..71e1206b149 --- /dev/null +++ b/services/runner/src/sessions/applied-commands.ts @@ -0,0 +1,91 @@ +/** + * Commands this process has already acted on. + * + * WHY IT MUST OUTLIVE THE DELIVERY PATH. Delivery is at-least-once by design: a lost + * acknowledgement, a retried admission, or a re-armed claim can all bring the same command back. + * Applying a Stop a second time is not harmless — by then the session may be running a NEWER + * turn, and a second abort would kill work the user never asked to stop. + * + * So the set lives at module scope, beside the session pool, not inside a request or a poll + * loop. A loop restart with an empty set would be exactly the bug this prevents. + * + * An already-applied command is a NO-OP THAT STILL ACKNOWLEDGES. It aborts nothing and it + * reports the stored outcome, so a lost acknowledgement is repaired without a second abort. + * + * The entry is written when the command is ACCEPTED, not when the cancel finishes. A duplicate + * that arrives while the first is still cancelling must also be a no-op. + */ + +export interface AppliedCommand { + commandId: string; + /** What this process reported, so a duplicate can repeat the same answer. */ + executionState: string; + executionId: string | null; + result: "applied" | "obsolete"; + appliedAt: number; +} + +/** + * How long an applied command is remembered. Long enough to cover every redelivery path (the + * claim lease is 90 seconds and the sweep runs inside two minutes), short enough that the map + * cannot grow without bound on a long-lived process. + */ +export const APPLIED_COMMAND_TTL_MS = 30 * 60 * 1000; + +/** Hard cap, so a burst cannot grow the map faster than the TTL prunes it. */ +const MAX_APPLIED_COMMANDS = 5000; + +const applied = new Map(); + +function prune(now: number): void { + for (const [id, entry] of applied) { + if (now - entry.appliedAt > APPLIED_COMMAND_TTL_MS) applied.delete(id); + } + while (applied.size > MAX_APPLIED_COMMANDS) { + const oldest = applied.keys().next(); + if (oldest.done) break; + applied.delete(oldest.value); + } +} + +/** What this process already did with `commandId`, if anything. */ +export function recallCommand( + commandId: string, + now: number = Date.now(), +): AppliedCommand | undefined { + const entry = applied.get(commandId); + if (!entry) return undefined; + if (now - entry.appliedAt > APPLIED_COMMAND_TTL_MS) { + applied.delete(commandId); + return undefined; + } + return entry; +} + +/** Record what this process did with a command. Insertion order is the prune order. */ +export function rememberCommand( + entry: Omit, + now: number = Date.now(), +): AppliedCommand { + const stored: AppliedCommand = { ...entry, appliedAt: now }; + applied.delete(entry.commandId); + applied.set(entry.commandId, stored); + prune(now); + return stored; +} + +/** Revise the outcome of a command already accepted, once the cancel settles. */ +export function updateCommandOutcome( + commandId: string, + patch: Pick, +): void { + const entry = applied.get(commandId); + if (!entry) return; + entry.executionState = patch.executionState; + entry.result = patch.result; +} + +/** Test seam. */ +export function resetAppliedCommandsForTest(): void { + applied.clear(); +} diff --git a/services/runner/src/sessions/control-channel.ts b/services/runner/src/sessions/control-channel.ts new file mode 100644 index 00000000000..bc45e57f1ca --- /dev/null +++ b/services/runner/src/sessions/control-channel.ts @@ -0,0 +1,299 @@ +/** + * Applying a control command, and reporting what it did. + * + * The applier sits ABOVE the transport, not inside it, so every delivery path shares one set of + * guards and one deduplication set. Today there is one path, the direct `POST /cancel` route in + * `server.ts`. A long-poll loop would call the same `applyCommand` and change nothing here. + * + * WHAT THE RUNNER DECIDES AND WHAT IT DOES NOT. It decides whether it holds the named execution + * and whether that execution is old enough to be the one the user meant. It does NOT decide the + * command's fate: it reports an outcome to the API, and the API settles the command and the + * execution together. Settlement has one writer, on every transport. + * + * THE THREE ANSWERS. + * + * stopped — this process held the target execution and aborted it. + * not_running — it holds no execution that can still be stopped. A turn whose + * prompt has already settled and is only tearing down answers this. + * An approval-parked turn is still stoppable: its pending gate is + * released and it answers `stopped` like a live execution. + * superseded_by_newer_turn — it holds an execution that STARTED AFTER the command was + * created, so the command was meant for a turn that has since + * ended. Nothing is aborted. This check is exact, because it + * compares against this process's own memory of when it started + * the run. + */ + +import { apiBase } from "../apiBase.ts"; +import { REPLICA_ID } from "./alive.ts"; +import { + recallCommand, + rememberCommand, + updateCommandOutcome, +} from "./applied-commands.ts"; +import { findExecution, type LiveExecution } from "./execution-registry.ts"; + +function log(message: string): void { + process.stderr.write(`[control] ${message}\n`); +} + +/** One command as the API delivers it. The same shape arrives on every transport. */ +export interface ControlCommand { + id: string; + projectId: string; + sessionId: string; + kind: "cancel"; + target: { turnId: string | null; expectedTurnId: string | null }; + /** When the API admitted the command. The late-Stop guard compares against this. */ + createdAt: string; +} + +export type ExecutionState = + | "stopped" + | "failed" + | "not_running" + | "superseded_by_newer_turn"; + +export interface ControlOutcome { + /** The command's terminal state, as the runner sees it. */ + result: "applied" | "obsolete"; + execution: { + id: string | null; + state: ExecutionState; + error?: string; + }; +} + +/** The control operation exposed by one approval-parked session. */ +export interface ParkedSessionControl { + /** Release every gate and return the same environment to the pool as idle. */ + stop(): Promise | void; +} + +/** How the runner reaches a parked session. Injected so tests need no pool. */ +export interface ParkedLookup { + (projectId: string, sessionId: string): ParkedSessionControl | undefined; +} + +export interface ApplyCommandDeps { + /** Overridden in tests. Defaults to the module-level execution registry. */ + findLive?: (projectId: string, sessionId: string) => LiveExecution | undefined; + /** Whether the keep-alive pool holds this session parked awaiting an approval. */ + isParked?: ParkedLookup; + /** Overridden in tests. Defaults to the HTTP report below. */ + report?: (command: ControlCommand, outcome: ControlOutcome) => Promise; + now?: () => number; +} + +/** Does this process hold the session at all? The `/cancel` route answers 404 when it does not. */ +export function holdsSession( + projectId: string, + sessionId: string, + isParked?: ParkedLookup, +): boolean { + if (findExecution(projectId, sessionId)) return true; + return isParked ? isParked(projectId, sessionId) !== undefined : false; +} + +/** + * Apply one command and report its outcome. Never throws. + * + * Returns the outcome it reported, which is what a duplicate delivery repeats. + */ +export async function applyCommand( + command: ControlCommand, + deps: ApplyCommandDeps = {}, +): Promise { + const findLive = deps.findLive ?? findExecution; + const report = deps.report ?? reportOutcome; + const now = deps.now ?? (() => Date.now()); + + const seen = recallCommand(command.id, now()); + if (seen) { + // A no-op that STILL acknowledges. Aborting a second time could kill a newer turn; not + // acknowledging would leave the command open until the settlement sweep gave up on it. + const outcome: ControlOutcome = { + result: seen.result, + execution: { + id: seen.executionId, + state: seen.executionState as ExecutionState, + }, + }; + log( + `duplicate command=${command.id} session=${command.sessionId} state=${seen.executionState}`, + ); + await report(command, outcome).catch(() => {}); + return outcome; + } + + const createdAtMs = Date.parse(command.createdAt); + const live = findLive(command.projectId, command.sessionId); + const parked = live + ? undefined + : deps.isParked?.(command.projectId, command.sessionId); + const outcome = decideOutcome(command, live, parked, createdAtMs); + + // Remember BEFORE aborting. A duplicate that arrives while the first abort is still settling + // must find the command already taken, not start a second one. + rememberCommand( + { + commandId: command.id, + executionId: outcome.execution.id, + executionState: outcome.execution.state, + result: outcome.result, + }, + now(), + ); + + if (outcome.execution.state === "stopped") { + try { + if (live) { + // The abort is the cancel. It makes the turn end `cancelled`, which is what sends the + // ACP `session/cancel` to the harness and lets the environment be PARKED rather than + // deleted (see `cancel-turn.ts` and `shouldPark`). Stop keeps the session warm. + live.abort(); + log( + `aborted command=${command.id} session=${command.sessionId} turn=${live.turnId}`, + ); + } else if (parked) { + // An approval park has no live execution to abort, but its harness still holds the + // original prompt on one or more permission gates. Releasing those gates ends the work + // and returns the SAME environment to the idle pool, so the next user message is a + // normal warm prompt rather than an approval resume. + await parked.stop(); + log( + `released parked approval command=${command.id} session=${command.sessionId}`, + ); + } + } catch (error) { + const message = + error instanceof Error ? error.message : String(error ?? "abort failed"); + outcome.result = "applied"; + outcome.execution.state = "failed"; + outcome.execution.error = message.slice(0, 2000); + updateCommandOutcome(command.id, { result: "applied", executionState: "failed" }); + log(`abort FAILED command=${command.id} session=${command.sessionId}: ${message}`); + } + } + + // Reported as soon as the abort is issued, not after the harness settles. The command's job + // is to deliver the Stop; the turn's own teardown then writes its transcript and parks the + // sandbox on its own clock, which can take seconds. Waiting for it would make a Stop that + // worked look stuck. + await report(command, outcome).catch((error) => { + log( + `outcome report failed command=${command.id}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); + return outcome; +} + +function decideOutcome( + command: ControlCommand, + live: LiveExecution | undefined, + parked: ParkedSessionControl | undefined, + createdAtMs: number, +): ControlOutcome { + if (!live) { + if (parked) { + return { + result: "applied", + execution: { id: command.target.turnId, state: "stopped" }, + }; + } + // No live or approval-parked turn is held here. There is nothing to stop. + return { + result: "applied", + execution: { id: command.target.turnId, state: "not_running" }, + }; + } + + if (Number.isFinite(createdAtMs) && live.startedAt > createdAtMs) { + // This execution began AFTER the user pressed Stop, so it is not the one they meant. + return { + result: "obsolete", + execution: { id: live.turnId, state: "superseded_by_newer_turn" }, + }; + } + + if (command.target.turnId && command.target.turnId !== live.turnId) { + // A different execution holds the session. The pinned target is gone. + return { + result: "obsolete", + execution: { id: command.target.turnId, state: "not_running" }, + }; + } + + if (live.settled) { + // THE STOP LOST THE RACE BY A MOMENT. The harness prompt already settled and the entry is + // only still here because teardown is running: writing the transcript, exporting the trace, + // parking the environment. There is nothing left to abort. + // + // Doing nothing is not merely tidier, it is the whole fix. `live.abort()` here would abort + // a finished run, and the aborted signal then makes `shouldPark` refuse to park a healthy + // idle environment, so the sandbox is destroyed and the user's next message rebuilds cold. + // The user paid a cold start for pressing Stop as the answer landed. + // + // `obsolete`, not `applied`: the command never stopped anything. `not_running` is the same + // answer a parked approval gets, and it means the same thing here — this process holds no + // execution that can still be stopped. + return { + result: "obsolete", + execution: { id: command.target.turnId ?? live.turnId, state: "not_running" }, + }; + } + + return { + result: "applied", + execution: { id: live.turnId, state: "stopped" }, + }; +} + +/** + * Report a command's outcome to the API. + * + * Authenticates with the shared runner token, not a project credential: the runner holds no + * project credential for a command it was handed, and the command id resolves the project on + * the API side. + */ +export async function reportOutcome( + command: ControlCommand, + outcome: ControlOutcome, +): Promise { + const token = process.env.AGENTA_RUNNER_TOKEN; + if (!token) { + log(`cannot report command=${command.id}: AGENTA_RUNNER_TOKEN is not set`); + return; + } + const url = `${apiBase()}/sessions/control/commands/${encodeURIComponent(command.id)}/outcome`; + const res = await fetch(url, { + method: "POST", + redirect: "error", + headers: { + "content-type": "application/json", + "x-agenta-runner-token": token, + }, + body: JSON.stringify({ + replica_id: REPLICA_ID, + result: outcome.result, + execution: { + id: outcome.execution.id, + state: outcome.execution.state, + ...(outcome.execution.error ? { error: outcome.execution.error } : {}), + }, + }), + }); + if (!res.ok) { + // A 409 means the claim was gone, which is an answer, not a failure to retry: the API has + // already written a terminal outcome for this command. + log( + `outcome HTTP ${res.status} command=${command.id} session=${command.sessionId}`, + ); + return; + } + log( + `outcome reported command=${command.id} session=${command.sessionId} state=${outcome.execution.state}`, + ); +} diff --git a/services/runner/src/sessions/execution-registry.ts b/services/runner/src/sessions/execution-registry.ts new file mode 100644 index 00000000000..0d83a2f3820 --- /dev/null +++ b/services/runner/src/sessions/execution-registry.ts @@ -0,0 +1,133 @@ +/** + * Which executions this runner process is running right now. + * + * WHY IT EXISTS. The abort controller for a session-owned run was a local variable inside the + * request handler in `server.ts`. Nothing outside that closure could reach it, so the only way + * to stop a turn was to take the session's Redis lock away and wait up to 30 seconds for the + * heartbeat to notice. A control command has to reach the running turn directly, and that needs + * a lookup keyed by something the API knows. + * + * THE KEY IS THE SESSION ID, AND THE PROJECT IS CHECKED SEPARATELY. Keying by + * `:` would be tidier, but the project scope is NOT known when a run + * starts: `runContext.project.id` is empty on the live invoke path, and the scope actually used + * for the pool key comes from the signed mount, which the coordinator resolves after the run is + * already in flight (`session-coordinator.ts`, `poolKeyFor(request, signed?.projectId)`). + * Registering under a key that does not exist yet is what made the first version of this + * registry answer "I do not hold that session" for every Stop. + * + * So the entry goes in under the session id at once, and `noteExecutionProject` fills the + * project in as soon as the coordinator knows it. A lookup matches only when the stored project + * agrees, so a Stop from another tenant is REFUSED rather than misrouted. Until the project is + * known the entry matches any project: that window is a few hundred milliseconds at the very + * start of a run, and refusing every Stop in it would reintroduce the bug this comment + * describes. + * + * The limit worth knowing: one entry per session id per process. Two projects running the same + * session id on one runner at the same time keep only the later entry, and the earlier one's + * Stop is then refused with a 404. Refusal is the safe direction, and the keep-alive pool has + * the same shape of key. + * + * `startedAt` is the field that makes a late Stop safe. The API pins the target turn at + * admission and compares its own clock, but the runner's comparison against its OWN memory is + * exact: a command created before an execution started cannot have been meant for it. + * + * Entries are removed in the same `finally` that releases the alive watchdog, so a run that + * threw still leaves the registry clean. + */ + +export interface LiveExecution { + /** Undefined until the coordinator resolves the run's project scope. */ + projectId: string | undefined; + sessionId: string; + /** The execution id, which is the runner's `turn_id`. */ + turnId: string; + /** When this process started the run, in epoch milliseconds. */ + startedAt: number; + /** + * True once the harness prompt has settled, whatever it settled as. + * + * The entry stays registered through teardown, which writes the transcript, exports the + * trace and decides whether to park, and that takes hundreds of milliseconds. A Stop that + * arrives in that window has nothing left to abort, and aborting anyway is actively harmful: + * the abort makes teardown read the run as cancelled-but-unsettled and DESTROY a healthy + * environment that was about to be parked. So the applier reads this flag and does nothing. + */ + settled?: boolean; + /** Stop the run. Aborting is what makes the turn end `cancelled`. */ + abort: () => void; +} + +const executions = new Map(); + +/** + * Register a run as live. A second registration for the same session REPLACES the first, + * because the pool's own supersede path has already torn the previous environment down by the + * time a replacement turn starts. + */ +export function registerExecution(execution: LiveExecution): void { + executions.set(execution.sessionId, execution); +} + +/** + * Fill in the project scope once the coordinator has resolved it. Scoped to the turn id, so a + * late callback from a finished run cannot relabel its successor. + */ +export function noteExecutionProject( + sessionId: string, + turnId: string, + projectId: string, +): void { + const current = executions.get(sessionId); + if (current && current.turnId === turnId) current.projectId = projectId; +} + +/** + * Mark a run's own work as finished, the moment the harness prompt settles and before teardown + * begins. Scoped to the turn id for the same reason `noteExecutionProject` is: a late callback + * from a finished run must not relabel its successor. + * + * Set from inside the turn, not from the request handler that awaits it, because the harmful + * window is exactly the teardown that runs between those two points. + */ +export function noteExecutionSettled(sessionId: string, turnId: string): void { + const current = executions.get(sessionId); + if (current && current.turnId === turnId) current.settled = true; +} + +/** + * Remove a run, but only if it is still the one registered. A turn that finishes after its + * successor registered must not unregister the successor. + */ +export function unregisterExecution(sessionId: string, turnId: string): void { + const current = executions.get(sessionId); + if (current && current.turnId === turnId) executions.delete(sessionId); +} + +/** + * The live execution for a session, when it belongs to the asking project. + * + * A stored project that DISAGREES yields nothing, so a Stop from another tenant is refused. + * A stored project that is not known yet matches, because the run has genuinely not been + * scoped at that point and refusing would drop every Stop in the first moments of a run. + */ +export function findExecution( + projectId: string, + sessionId: string, +): LiveExecution | undefined { + const current = executions.get(sessionId); + if (!current) return undefined; + if (current.projectId !== undefined && current.projectId !== projectId) { + return undefined; + } + return current; +} + +/** Test/inspection snapshot. */ +export function liveExecutions(): LiveExecution[] { + return [...executions.values()]; +} + +/** Test seam: drop everything. Never called by the server. */ +export function resetExecutionsForTest(): void { + executions.clear(); +} diff --git a/services/runner/src/sessions/live-frames.ts b/services/runner/src/sessions/live-frames.ts new file mode 100644 index 00000000000..e2a29b5edc3 --- /dev/null +++ b/services/runner/src/sessions/live-frames.ts @@ -0,0 +1,346 @@ +import type { AgentEvent } from "../protocol.ts"; +import { apiBase } from "../apiBase.ts"; + +export const LIVE_FRAMES_ENV = "AGENTA_RUNNER_LIVE_FRAMES"; +export const LIVE_FRAME_BUFFER_CAPACITY = 256; +export const LIVE_FRAME_FLUSH_INTERVAL_MS = 150; +export const LIVE_FRAME_BATCH_CAPACITY = 50; +export const LIVE_FRAME_BATCH_MAX_BYTES = 64 * 1024; +// A stalled ingest POST would keep `pump()` pending, and `flush()` waits on `whenIdle()` — so an +// unbounded post delays turn completion. A timed-out batch counts as dropped, like any other +// send failure. +export const LIVE_FRAME_POST_TIMEOUT_MS = 5_000; + +export interface LiveFrameEnvelope { + version: 1; + kind: "frame"; + session_id: string; + execution_id: string; + frame_or_event_id: string; + frame_index: number; + entity_id: string; + type: string; + payload: Record; + created_at: string; +} + +interface ProjectedFrame { + entityId: string; + type: string; + payload: Record; +} + +interface QueuedFrame { + frame: LiveFrameEnvelope; + bytes: number; +} + +interface LiveFramePublisherOptions { + sessionId: string; + executionId: string; + auth: () => string; + enabled?: boolean; + capacity?: number; + flushIntervalMs?: number; + batchCapacity?: number; + maxBatchBytes?: number; + send?: (frames: LiveFrameEnvelope[]) => Promise; + postTimeoutMs?: number; + now?: () => string; + log?: (message: string) => void; +} + +function envEnabled(): boolean { + return ["1", "true", "yes", "on"].includes( + String(process.env[LIVE_FRAMES_ENV] ?? "") + .trim() + .toLowerCase(), + ); +} + +async function postFrames( + auth: () => string, + frames: LiveFrameEnvelope[], + timeoutMs: number = LIVE_FRAME_POST_TIMEOUT_MS, +): Promise { + const response = await fetch(`${apiBase()}/sessions/records/ingest`, { + method: "POST", + signal: AbortSignal.timeout(timeoutMs), + headers: { + "content-type": "application/json", + authorization: auth(), + }, + body: JSON.stringify(frames), + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } +} + +function projectEvent( + event: AgentEvent, + seenToolCalls: Set, +): ProjectedFrame[] { + switch (event.type) { + case "message_start": + return [{ entityId: event.id, type: "text-start", payload: { id: event.id } }]; + case "message_delta": + return [ + { + entityId: event.id, + type: "text-delta", + payload: { id: event.id, delta: event.delta }, + }, + ]; + case "message_end": + return [{ entityId: event.id, type: "text-end", payload: { id: event.id } }]; + case "thought_start": + return [ + { entityId: event.id, type: "reasoning-start", payload: { id: event.id } }, + ]; + case "thought_delta": + return [ + { + entityId: event.id, + type: "reasoning-delta", + payload: { id: event.id, delta: event.delta }, + }, + ]; + case "thought_end": + return [ + { entityId: event.id, type: "reasoning-end", payload: { id: event.id } }, + ]; + case "tool_call": { + if (!event.id) return []; + const payload = { + toolCallId: event.id, + toolName: event.name, + input: event.input ?? {}, + }; + const input = { + entityId: event.id, + type: "tool-input-available", + payload, + }; + if (seenToolCalls.has(event.id)) return [input]; + seenToolCalls.add(event.id); + return [ + { + entityId: event.id, + type: "tool-input-start", + payload: { toolCallId: event.id, toolName: event.name }, + }, + input, + ]; + } + case "tool_result": { + if (!event.id || !seenToolCalls.has(event.id)) return []; + if (event.denied) { + return [ + { + entityId: event.id, + type: "tool-output-denied", + payload: { toolCallId: event.id }, + }, + ]; + } + if (event.isError) { + return [ + { + entityId: event.id, + type: "tool-output-error", + payload: { toolCallId: event.id, errorText: event.output ?? "" }, + }, + ]; + } + return [ + { + entityId: event.id, + type: "tool-output-available", + payload: { + toolCallId: event.id, + output: event.data ?? event.output, + }, + }, + ]; + } + default: + return []; + } +} + +export class LiveFramePublisher { + private readonly enabled: boolean; + private readonly capacity: number; + private readonly flushIntervalMs: number; + private readonly batchCapacity: number; + private readonly maxBatchBytes: number; + private readonly send: (frames: LiveFrameEnvelope[]) => Promise; + private readonly now: () => string; + private readonly log: (message: string) => void; + private readonly sessionId: string; + private readonly executionId: string; + private readonly queue: QueuedFrame[] = []; + private readonly seenToolCalls = new Set(); + private frameIndex = 0; + private dropped = 0; + private queuedPayloadBytes = 0; + private pumping = false; + private flushRequested = false; + private flushTimer: NodeJS.Timeout | null = null; + private idleWaiters: Array<() => void> = []; + + constructor(options: LiveFramePublisherOptions) { + this.enabled = options.enabled ?? envEnabled(); + this.capacity = Math.max(1, options.capacity ?? LIVE_FRAME_BUFFER_CAPACITY); + this.flushIntervalMs = Math.max( + 0, + options.flushIntervalMs ?? LIVE_FRAME_FLUSH_INTERVAL_MS, + ); + this.batchCapacity = Math.max( + 1, + options.batchCapacity ?? LIVE_FRAME_BATCH_CAPACITY, + ); + this.maxBatchBytes = Math.max( + 1, + options.maxBatchBytes ?? LIVE_FRAME_BATCH_MAX_BYTES, + ); + this.sessionId = options.sessionId; + this.executionId = options.executionId; + const postTimeoutMs = Math.max( + 1, + options.postTimeoutMs ?? LIVE_FRAME_POST_TIMEOUT_MS, + ); + this.send = + options.send ?? + ((frames) => postFrames(options.auth, frames, postTimeoutMs)); + this.now = options.now ?? (() => new Date().toISOString()); + this.log = + options.log ?? + ((message) => process.stderr.write(`[sessions/live-frames] ${message}\n`)); + } + + emit(event: AgentEvent): void { + if (!this.enabled) return; + for (const projected of projectEvent(event, this.seenToolCalls)) { + const index = this.frameIndex++; + const frame: LiveFrameEnvelope = { + version: 1, + kind: "frame", + session_id: this.sessionId, + execution_id: this.executionId, + frame_or_event_id: `${this.executionId}:${index}`, + frame_index: index, + entity_id: projected.entityId, + type: projected.type, + payload: projected.payload, + created_at: this.now(), + }; + if (this.queue.length >= this.capacity) { + this.dropped += 1; + continue; + } + const bytes = Buffer.byteLength(JSON.stringify(frame), "utf8"); + this.queue.push({ frame, bytes }); + this.queuedPayloadBytes += bytes; + } + if (this.shouldFlushImmediately()) { + this.startPump(false); + } else { + this.scheduleFlush(); + } + } + + reportDrops(): number { + const dropped = this.dropped; + if (dropped > 0) { + this.log( + `DROPPED session=${this.sessionId} execution=${this.executionId} count=${dropped}`, + ); + this.dropped = 0; + } + return dropped; + } + + async whenIdle(): Promise { + if (!this.pumping && this.queue.length === 0) return; + this.startPump(true); + await new Promise((resolve) => this.idleWaiters.push(resolve)); + } + + private serializedQueueBytes(): number { + if (this.queue.length === 0) return 2; + return this.queuedPayloadBytes + this.queue.length + 1; + } + + private shouldFlushImmediately(): boolean { + return ( + this.queue.length >= this.batchCapacity || + this.serializedQueueBytes() >= this.maxBatchBytes + ); + } + + private scheduleFlush(): void { + if (this.pumping || this.flushTimer || this.queue.length === 0) return; + this.flushTimer = setTimeout(() => { + this.flushTimer = null; + this.startPump(false); + }, this.flushIntervalMs); + this.flushTimer.unref?.(); + } + + private startPump(forceDrain: boolean): void { + if (forceDrain) this.flushRequested = true; + if (this.pumping || this.queue.length === 0) return; + if (this.flushTimer) { + clearTimeout(this.flushTimer); + this.flushTimer = null; + } + this.pumping = true; + void this.pump(); + } + + private takeBatch(): LiveFrameEnvelope[] { + let count = 0; + let bytes = 2; + for (const queued of this.queue) { + if (count >= this.batchCapacity) break; + const additional = queued.bytes + (count > 0 ? 1 : 0); + if (count > 0 && bytes + additional > this.maxBatchBytes) break; + bytes += additional; + count += 1; + } + + const queued = this.queue.splice(0, count); + for (const item of queued) this.queuedPayloadBytes -= item.bytes; + return queued.map((item) => item.frame); + } + + private async pump(): Promise { + let sendFirstBatch = true; + while ( + this.queue.length > 0 && + (sendFirstBatch || this.flushRequested || this.shouldFlushImmediately()) + ) { + sendFirstBatch = false; + const frames = this.takeBatch(); + try { + await this.send(frames); + } catch { + this.dropped += frames.length; + } + } + this.pumping = false; + if (this.queue.length > 0) { + if (this.flushRequested) { + this.startPump(true); + } else { + this.scheduleFlush(); + } + return; + } + this.flushRequested = false; + const waiters = this.idleWaiters.splice(0); + for (const resolve of waiters) resolve(); + } +} diff --git a/services/runner/src/sessions/persist.ts b/services/runner/src/sessions/persist.ts index 56ac40dc246..885970c2c32 100644 --- a/services/runner/src/sessions/persist.ts +++ b/services/runner/src/sessions/persist.ts @@ -28,6 +28,7 @@ import { envInt, envTimerMs } from "../env.ts"; import type { AgentEvent } from "../protocol.ts"; import type { Redactor } from "../redaction.ts"; import { stableRecordId } from "./record-id.ts"; +import { LiveFramePublisher } from "./live-frames.ts"; const INGEST_MAX_RETRIES = 3; const INGEST_RETRY_BASE_MS = 100; @@ -262,6 +263,9 @@ export function buildPersistingEmitter( flush: () => Promise; } { let eventIndex = 0; + const liveFrames = turnId + ? new LiveFramePublisher({ sessionId, executionId: turnId, auth }) + : null; // Coalescing state: accumulate delta families into a single durable event. const coalescedMessages = new Map(); @@ -296,6 +300,7 @@ export function buildPersistingEmitter( const emit = (event: AgentEvent): void => { // Always forward to the live stream (if any). liveEmit?.(event); + liveFrames?.emit(event); // Transient data describes the current live turn. It must not become transcript history. if (event.type === "data" && event.transient) return; @@ -451,6 +456,8 @@ export function buildPersistingEmitter( `WARN session=${sessionId} durable log incomplete: ${dropped} record(s) dropped this turn; reconstruction may lack context`, ); } + await liveFrames?.whenIdle(); + liveFrames?.reportDrops(); }; return { emit, persist, flush }; diff --git a/services/runner/src/sessions/stop-signal.ts b/services/runner/src/sessions/stop-signal.ts new file mode 100644 index 00000000000..f7428d13ad8 --- /dev/null +++ b/services/runner/src/sessions/stop-signal.ts @@ -0,0 +1,43 @@ +/** + * Labelling the abort so the park policy can tell a user Stop from every other abort. + * + * WHY A LABEL AND NOT THE FLAG. The runner has one `AbortController` per run, and several + * different events end a run through it. Only one of them is a cooperative user Stop: the + * heartbeat reporting `is_current_turn: false` after the API cleared this turn's alive lock + * (`sessions/alive.ts`, wired at `server.ts`). The rest — a client disconnect on a + * non-session run, anything a future call site adds — are not Stops, and their environments + * must still be destroyed. + * + * Before this label, `shouldPark` could only read `signal.aborted`, which cannot answer WHY. + * Inferring the Stop from `stopReason === "cancelled"` would be worse than it looks: the turn + * sets that value whenever the signal aborts, whatever aborted it, so any new + * `controller.abort()` anywhere in the runner would silently start parking sandboxes whose + * state nobody has checked. The teardown allowlist exists precisely to stop that from being + * possible, and this label is what keeps the allowlist honest. + * + * The mechanism is the standard one: `AbortController.abort(reason)` puts the value on + * `signal.reason`, and the same signal object reaches the park decision, so nothing new has to + * be threaded through the engine, the coordinator or the turn. + * + * WHAT THIS LABEL DOES NOT DISTINGUISH. Cancel, steer and hard kill all reach the runner the + * same way today: the API clears the alive lock and the next heartbeat reports it. So all three + * arrive labelled as a user Stop. That is safe rather than merely tolerable. A steer WANTS the + * warm environment for the turn it starts, and a kill separately calls the runner's `/kill`, + * which destroys the pool entry by key whether or not it was parked first. Naming the actual + * operation needs the durable command plane, which is work package B. + */ + +/** + * The `signal.reason` value a cooperative user Stop aborts with. + * + * A plain frozen object, not a string or an `Error`: object identity cannot be produced by + * accident, so nothing can be mistaken for a Stop by writing the same text. + */ +export const USER_STOP_ABORT_REASON = Object.freeze({ + agentaAbort: "user-stop", +} as const); + +/** True when this signal was aborted BY a cooperative user Stop, not by anything else. */ +export function isUserStopAbort(signal: AbortSignal | undefined): boolean { + return signal?.aborted === true && signal.reason === USER_STOP_ABORT_REASON; +} diff --git a/services/runner/src/sessions/turn-settle.ts b/services/runner/src/sessions/turn-settle.ts new file mode 100644 index 00000000000..917748dc1e9 --- /dev/null +++ b/services/runner/src/sessions/turn-settle.ts @@ -0,0 +1,177 @@ +/** + * Guarantee that a turn ends, even when `run()` does not. + * + * The runner's terminal record, and the release of its alive watchdog, both sit downstream of + * `await run(...)`. That is correct for every path where `run()` returns, and it is the whole + * bug where it does not: an await inside the run that never settles leaves the heartbeat + * announcing `running=true` every thirty seconds forever, so the platform holds the session + * open under a turn nobody is running and no terminal record is ever written. See issues #6418, + * #6100 and #5327. + * + * This module bounds that. It waits for `run()` normally, and gives up on it when either: + * + * * the platform says this turn is no longer current (a Stop, a takeover, or the API's own + * execution watchdog declaring the turn lost), or + * * the hard deadline elapses. + * + * Giving up is two steps, never one. First `abort()`, because most hangs DO unwind from an + * abort — the prompt race inside the turn resolves on the signal — and an unwound turn tears + * its sandbox down properly. Only if the run is still pending after `abandonGraceMs` does the + * caller stop waiting and write the outcome itself. + * + * What this deliberately does NOT do: kill the sandbox, or change any teardown rule. The + * abandoned `run()` still owns its environment and still runs its own `finally` if it ever + * settles. This is about the platform always learning the outcome, not about reclaiming + * machines — the keep-alive pool and the API watchdog already own that. + */ + +import { envTimerMs } from "../env.ts"; +import { DEFAULT_TOTAL_DEADLINE_MS } from "../engines/sandbox_agent/run-limits.ts"; + +export const HARD_DEADLINE_ENV = "AGENTA_RUNNER_TURN_HARD_DEADLINE_MS"; +export const ABANDON_GRACE_ENV = "AGENTA_RUNNER_TURN_ABANDON_GRACE_MS"; + +/** + * Half an hour past the longest legitimate run. + * + * This is a backstop, not a policy: it must never be the limit that ends a real turn, because + * the run limits already own that decision and users have asked for LONGER runs, not shorter + * ones (issues #6084, #5356). Keeping it above `DEFAULT_TOTAL_DEADLINE_MS` means a turn that + * reaches it is one whose own deadline already tripped and failed to end it. + */ +export const DEFAULT_HARD_DEADLINE_MS = DEFAULT_TOTAL_DEADLINE_MS + 30 * 60_000; + +/** + * How long a turn may take to unwind after its abort before the caller stops waiting. + * + * Long enough for a normal teardown (flush the trace, settle the interaction rows, destroy or + * park the sandbox), short enough that a user who pressed Stop is not left watching a spinner. + */ +export const DEFAULT_ABANDON_GRACE_MS = 60_000; + +export interface TurnSettleLimits { + hardDeadlineMs: number; + abandonGraceMs: number; +} + +export interface Clock { + setTimeout(fn: () => void, ms: number): NodeJS.Timeout; + clearTimeout(handle: NodeJS.Timeout): void; +} + +const realClock: Clock = { + setTimeout: (fn, ms) => setTimeout(fn, ms), + clearTimeout: (handle) => clearTimeout(handle), +}; + +export function resolveTurnSettleLimits( + log: (message: string) => void = () => {}, +): TurnSettleLimits { + return { + hardDeadlineMs: envTimerMs(HARD_DEADLINE_ENV, DEFAULT_HARD_DEADLINE_MS, { + log, + }), + abandonGraceMs: envTimerMs(ABANDON_GRACE_ENV, DEFAULT_ABANDON_GRACE_MS, { + log, + }), + }; +} + +export type TurnSettleOutcome = + /** `run()` returned. The normal path, and the only one that carries the run's own result. */ + | { settled: true; value: T } + /** `run()` never returned. The caller must write the terminal outcome itself. */ + | { settled: false; reason: string }; + +export interface AwaitTurnOptions { + /** The in-flight run. Never rejected by this function; the caller keeps its own catch. */ + run: Promise; + /** Ask the run to stop. Called once, before the grace window opens. */ + abort: () => void; + /** + * Resolves when the platform says this turn is no longer current — the heartbeat answered + * `is_current_turn: false`. Optional: a non-session run has no such signal. + */ + interrupted?: Promise; + limits: TurnSettleLimits; + clock?: Clock; + log?: (message: string) => void; +} + +/** + * Await `run`, or give up on it and say why. + * + * Resolves as soon as `run` settles on the happy path, with no timer left armed. + */ +export async function awaitTurnOrAbandon({ + run, + abort, + interrupted, + limits, + clock = realClock, + log = () => {}, +}: AwaitTurnOptions): Promise> { + const timers: NodeJS.Timeout[] = []; + const clearTimers = (): void => { + for (const timer of timers) clock.clearTimeout(timer); + timers.length = 0; + }; + + // A tagged sentinel, not a symbol on the value channel: `run` may resolve to anything, + // including a symbol, and the race must be able to tell the two apart with certainty. + type Raced = + | { kind: "resolved"; value: T } + | { kind: "rejected"; error: unknown } + | { kind: "abandon" }; + const trigger: Raced = { kind: "abandon" }; + let triggerReason: string | undefined; + const settled: Promise = run.then( + (value) => ({ kind: "resolved" as const, value }), + (error) => ({ kind: "rejected" as const, error }), + ); + + try { + const deadline = new Promise((resolve) => { + timers.push( + clock.setTimeout(() => { + triggerReason = `hard turn deadline of ${limits.hardDeadlineMs}ms exceeded`; + resolve(trigger); + }, limits.hardDeadlineMs), + ); + }); + const displaced: Promise | undefined = interrupted?.then((reason) => { + triggerReason = reason; + return trigger; + }); + + const first = await Promise.race( + displaced ? [settled, deadline, displaced] : [settled, deadline], + ); + if (first.kind === "resolved") return { settled: true, value: first.value }; + if (first.kind === "rejected") throw first.error; + + // The run must stop. Most hangs unwind from here, so ask before giving up. + const reason = triggerReason ?? "turn abandoned"; + log(`[turn-settle] ${reason}; aborting and waiting ${limits.abandonGraceMs}ms`); + try { + abort(); + } catch (err) { + log(`[turn-settle] abort threw: ${err instanceof Error ? err.message : err}`); + } + + const grace = new Promise((resolve) => { + timers.push(clock.setTimeout(() => resolve(trigger), limits.abandonGraceMs)); + }); + const second = await Promise.race([settled, grace]); + if (second.kind === "resolved") return { settled: true, value: second.value }; + if (second.kind === "rejected") throw second.error; + + log( + `[turn-settle] run did not unwind within ${limits.abandonGraceMs}ms of the abort; ` + + `writing the terminal outcome without it`, + ); + return { settled: false, reason }; + } finally { + clearTimers(); + } +} diff --git a/services/runner/src/tracing/otel.ts b/services/runner/src/tracing/otel.ts index 0f9034c1461..b9ea3f01174 100644 --- a/services/runner/src/tracing/otel.ts +++ b/services/runner/src/tracing/otel.ts @@ -340,12 +340,40 @@ export function resolveOtlpTraceEndpoint(endpoint?: string): string { * public URL while the runner's own hop is internal. The full normalized ingest URL must match, * because a third-party collector may share the Agenta host behind a different proxy path. */ +/** + * Host names that all denote THIS deployment's own API host. + * + * A service running in bridge mode cannot reach the host through `localhost` — that name resolves + * to its own container — so the SDK rewrites a configured `localhost`/`0.0.0.0` API URL to + * `host.docker.internal` before using it (`agenta/sdk/utils/helpers.py`, `parse_url`). The endpoint + * that then arrives on a run request names a DIFFERENT alias than the base configured here, and + * comparing them verbatim says "this is somebody else's collector" about the deployment's own + * ingest. The credential is withheld on that basis and every session call the runner makes comes + * back 401 — with nothing in the message pointing at a hostname. + * + * Folding the aliases together does not widen who gets the credential: the endpoint must still + * equal one of the operator-configured API bases, port and path included. Only the spelling of the + * local host is treated as interchangeable, which is the same equivalence the SDK's rewrite asserts. + */ +const LOCAL_HOST_ALIASES = new Set([ + "localhost", + "127.0.0.1", + "0.0.0.0", + "[::1]", + "::1", + "host.docker.internal", +]); + export function isAgentaIngest(endpoint: string): boolean { const normalize = (value: string): string | undefined => { try { const url = new URL(value); const path = url.pathname.replace(/\/+$/, "") || "/"; - return `${url.origin}${path}`; + const host = LOCAL_HOST_ALIASES.has(url.hostname) + ? "__local__" + : url.hostname; + const port = url.port ? `:${url.port}` : ""; + return `${url.protocol}//${host}${port}${path}`; } catch { return undefined; } @@ -381,8 +409,13 @@ const BRIDGE_REWRITTEN_HOSTS = new Set(["localhost", "0.0.0.0"]); * The mirror is deliberately exact: same scheme, port, and path, and only the two hosts the * rewrite touches. Three things it deliberately does NOT do: * - * - `127.0.0.1` earns no alias. Neither copy of `parse_url` rewrites it, so that deployment - * already matches its own raw base and the bridge form is a pair the platform cannot produce. + * - `127.0.0.1` earns no alias HERE. Neither copy of `parse_url` rewrites it, so that + * deployment already matches its own raw base and the bridge form is a pair the platform + * cannot produce. It is admitted anyway, one layer up: `isAgentaIngest` folds every + * local-host spelling into one host (#6392), which subsumes this mirror entirely. This + * function is kept because it is the record of WHICH rewrite the platform actually performs, + * and because it names the bridge form in `configuredIngestBases()` so a rejection message + * lists the host the operator will see on the wire. * - A scheme-less base earns no alias, because it cannot help. The SDK's `parse_url` does no * scheme defaulting (unlike the api's), so a scheme-less `AGENTA_API_URL` yields an equally * scheme-less ENDPOINT, which `new URL` reads as an opaque `localhost:`-scheme path. Both @@ -2049,11 +2082,19 @@ export function createSandboxAgentOtel( } // Stamp the run's trace id on the turn's terminal event so a persisted transcript can link a // replayed turn back to its trace (undefined only in span-less mode with no valid traceparent). - // Mark a paused turn's terminal record so a cold reload can tell a pause from a real turn + // Mark a non-completing turn's terminal record so a cold reload can tell it from a real turn // boundary (the FE adoption heuristic and hydration read this). A completed turn omits it. + // + // `cancelled` rides here for the same reason `paused` does, and closes a real gap: without + // it a stopped turn is indistinguishable from a finished one in Postgres, so neither the + // frontend nor the release gate can tell a Stop from a completion. Kept as an explicit + // allowlist rather than passing `stopReason` through, so a harness-reported value such as + // `end_turn` or `max_tokens` cannot start appearing on the terminal record by accident. record({ type: "done", - ...(stopReason === "paused" ? { stopReason: "paused" } : {}), + ...(stopReason === "paused" || stopReason === "cancelled" + ? { stopReason } + : {}), ...(runTraceId ? { traceId: runTraceId } : {}), }); if (!emitSpans) return text; diff --git a/services/runner/tests/unit/acquire-abort.test.ts b/services/runner/tests/unit/acquire-abort.test.ts new file mode 100644 index 00000000000..9b2da1e91d4 --- /dev/null +++ b/services/runner/tests/unit/acquire-abort.test.ts @@ -0,0 +1,106 @@ +/** + * A Stop must preempt provider acquisition before the command-delivery timeout. The provider APIs + * do not accept AbortSignal, so the runner races them and compensates resources that arrive late. + * + * Run: pnpm exec vitest run tests/unit/acquire-abort.test.ts + */ +import assert from "node:assert/strict"; +import { describe, it } from "vitest"; + +import { abortableSandboxProvider } from "../../src/environment/abortable-sandbox-provider.ts"; + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +async function mustSettlePromptly(promise: Promise): Promise { + return Promise.race([ + promise, + new Promise((_resolve, reject) => + setTimeout( + () => reject(new Error("acquire did not cancel promptly")), + 4_000, + ), + ), + ]); +} + +describe("abortableSandboxProvider", () => { + for (const providerName of ["local", "daytona"] as const) { + it(`cancels a slow ${providerName} create and deletes the sandbox if it appears late`, async () => { + const created = deferred(); + const cleaned = deferred(); + const destroyed: string[] = []; + const controller = new AbortController(); + const provider = abortableSandboxProvider( + { + name: providerName, + create: () => created.promise, + async destroy(sandboxId: string) { + destroyed.push(sandboxId); + cleaned.resolve(); + }, + async getUrl() { + return "http://sandbox.invalid"; + }, + }, + controller.signal, + () => {}, + ); + + const acquire = provider.create(); + controller.abort(); + await assert.rejects( + () => mustSettlePromptly(acquire), + (error: unknown) => + error instanceof Error && + error.name === "AbortError" && + /acquisition was aborted/.test(error.message), + ); + + created.resolve(`${providerName}-late-id`); + await mustSettlePromptly(cleaned.promise); + assert.deepEqual(destroyed, [`${providerName}-late-id`]); + }); + } + + it("parks a Daytona sandbox whose reconnect finishes after cancellation", async () => { + const reconnected = deferred(); + const cleaned = deferred(); + const controller = new AbortController(); + let paused = 0; + const provider = abortableSandboxProvider( + { + name: "daytona", + async create() { + return "unused"; + }, + async destroy() {}, + reconnect: (_sandboxId: string) => reconnected.promise, + async pause() { + paused += 1; + cleaned.resolve(); + }, + async getUrl() { + return "http://sandbox.invalid"; + }, + }, + controller.signal, + () => {}, + ); + + const acquire = provider.reconnect!("parked-id"); + controller.abort(); + await assert.rejects(() => mustSettlePromptly(acquire), /aborted/); + reconnected.resolve(); + await mustSettlePromptly(cleaned.promise); + assert.equal(paused, 1); + }); +}); diff --git a/services/runner/tests/unit/cancel-continuity.test.ts b/services/runner/tests/unit/cancel-continuity.test.ts new file mode 100644 index 00000000000..61dd41122f5 --- /dev/null +++ b/services/runner/tests/unit/cancel-continuity.test.ts @@ -0,0 +1,409 @@ +/** + * The continuity record a STOPPED turn writes. + * + * A user Stop keeps the sandbox warm (see `harness-cancel-park.test.ts`), but the park is + * process-local: it dies with the runner. What survives a runner restart is the durable turn + * ledger, and `hydrateHarnessSessionFromDurable` re-seeds the in-memory store from it only when + * the latest row carries `end_time` AND `agent_session_id`. A stopped turn used to write neither, + * so a restart after a Stop lost the native harness session and the next message rebuilt cold. + * + * These tests pin the rule that fixes it: the record follows the HARNESS's confirmation, not the + * park decision. A settled cancel means the harness answered the cancelled prompt and is idle, so + * its native transcript holds a short but finished turn — a faithful resume point. An unsettled + * cancel leaves the harness in an unknown state and still falls back to cold replay. + * + * Run: pnpm exec vitest run tests/unit/cancel-continuity.test.ts + */ +import assert from "node:assert/strict"; +import { beforeEach, describe, it } from "vitest"; + +import { runSandboxAgent } from "../../src/engines/sandbox_agent.ts"; +import type { SandboxAgentDeps } from "../../src/engines/sandbox_agent.ts"; +import type { AgentRunRequest } from "../../src/protocol.ts"; +import { SessionContinuityStore } from "../../src/engines/sandbox_agent/session-continuity.ts"; +import { USER_STOP_ABORT_REASON } from "../../src/sessions/stop-signal.ts"; +import { resetRunnerConfigCache } from "../../src/config/runner-config.ts"; + +beforeEach(() => { + process.env.AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS = "local,daytona"; + process.env.AGENTA_RUNNER_DAYTONA_API_KEY = "test-key"; + resetRunnerConfigCache(); +}); + +const AGENT_SESSION_ID = "agent-native-7"; + +interface CancelFakeOpts { + /** + * Whether the sandbox client can send `session/cancel` at all. An unpatched client has no + * `cancelSession`, which is the shipped "unsettled" shape: the harness is never told to stop. + */ + cancellable?: boolean; + /** Trigger the test's abort only after acquisition has completed and prompt has started. */ + onPrompt?: () => void; + /** Model the shell child Codex leaves behind after answering a cancelled prompt. */ + leakedCodexChild?: boolean; + /** Force Codex's best-effort post-cancel reap to fail in a known or unexpected way. */ + codexReapFailure?: "failed" | "unknown"; +} + +/** + * A sandbox whose prompt stays open until the cancel arrives — the real shape of a Stop. The + * abort alone never ends the prompt; only `session/cancel` does. + */ +function fakeCancellableSandbox(opts: CancelFakeOpts = {}) { + const continuityStore = new SessionContinuityStore(); + const calls = { + paused: 0, + destroyed: 0, + appended: [] as Array<{ turnIndex: number; agentSessionId?: string }>, + completed: [] as Array<{ + sessionId: string; + turnIndex: number; + agentSessionId?: string; + endTime: string; + }>, + cancelled: [] as string[], + logs: [] as string[], + lifecycle: [] as string[], + }; + let leakedCodexChildRunning = opts.leakedCodexChild === true; + + let answerPrompt: (() => void) | undefined; + const session = { + id: "harness-session-1", + agentSessionId: AGENT_SESSION_ID, + onEvent() {}, + onPermissionRequest() {}, + prompt() { + const response = new Promise((resolve) => { + answerPrompt = () => resolve({ stopReason: "cancelled" }); + }); + opts.onPrompt?.(); + return response; + }, + }; + + const sandbox: any = { + sandboxId: "daytona/sbx-warm", + sandboxProvider: { destroy: async () => {} }, + sandboxProviderRawId: "sbx-warm", + async createSession() { + return session; + }, + async destroySession() {}, + async pauseSandbox() { + calls.lifecycle.push("park"); + calls.paused += 1; + }, + async destroySandbox() { + calls.destroyed += 1; + }, + async dispose() {}, + async runProcess(request: { command: string; args?: string[] }) { + if (request.command === "ps") { + calls.lifecycle.push("ps"); + if (opts.codexReapFailure === "failed") { + throw new Error("ps unavailable"); + } + return { + stdout: [ + "100 1 120 /x/bin/sandbox-agent server --port 3000", + "110 100 119 node /x/codex-acp", + "120 110 118 /x/bin/codex app-server", + ...(leakedCodexChildRunning ? ["130 120 0 sleep 300"] : []), + ].join("\n"), + exitCode: 0, + }; + } + if (request.command === "kill") { + calls.lifecycle.push("kill"); + leakedCodexChildRunning = false; + return { stdout: "", exitCode: 0 }; + } + return { stdout: "", exitCode: 0 }; + }, + }; + if (opts.codexReapFailure === "unknown") { + Object.defineProperty(sandbox, "runProcess", { + get() { + throw new Error("reap inspection unavailable"); + }, + }); + } + if (opts.cancellable !== false) { + sandbox.cancelSession = async (id: string) => { + calls.lifecycle.push("cancel"); + calls.cancelled.push(id); + // The harness answers the cancelled prompt: this is what `settled` measures. + answerPrompt?.(); + }; + } + + const appendSessionTurn: any = async ( + _sessionId: string, + _harness: string, + turnIndex: number, + turn: { agentSessionId?: string }, + ) => { + calls.appended.push({ turnIndex, agentSessionId: turn.agentSessionId }); + }; + appendSessionTurn.complete = async ( + sessionId: string, + turnIndex: number, + turn: { agentSessionId?: string; endTime: string }, + ) => { + calls.completed.push({ + sessionId, + turnIndex, + agentSessionId: turn.agentSessionId, + endTime: turn.endTime, + }); + }; + + const deps: SandboxAgentDeps = { + log: (message) => { + calls.logs.push(message); + }, + createDaytonaCwd: (durable?: string) => durable ?? "/tmp/agenta-fake-cwd", + createLocalCwd: (durable?: string) => durable ?? "/tmp/agenta-fake-cwd", + resolveSkillDirs: () => ({ skills: [], cleanup: () => {} }), + buildDaemonEnv: () => ({}), + resolveDaemonBinary: () => "/bin/sandbox-agent", + buildSandboxProvider: () => + ({ provider: true, deleteSandbox: async () => {} }) as any, + createPersist: () => ({}) as any, + sessionContinuityStore: continuityStore, + hydrateHarnessSessionFromDurable: async () => {}, + appendSessionTurn, + startSandboxAgent: (async () => sandbox) as any, + prepareWorkspace: (async () => ({ cleanup: async () => {} })) as any, + prepareDaytonaPiAssets: async () => true, + discoverTunnelEndpoint: async () => null, + probeCapabilities: async () => + ({ + source: "probed", + capabilities: { + mcpTools: true, + toolCalls: true, + usage: true, + streamingDeltas: true, + }, + }) as any, + applyModel: async (_s, model) => model ?? "resolved-model", + createOtel: (() => ({ + start() {}, + handleUpdate() {}, + emitEvent() {}, + usage: () => ({ input: 0, output: 0, total: 0, cost: 0 }), + setUsage() {}, + finish: () => "partial answer", + recordError() {}, + output: () => "partial answer", + flush: async () => {}, + events: () => [], + settleOpenToolCalls() {}, + traceId: () => "trace-1", + })) as any, + startToolRelay: (() => ({ stop: async () => {} })) as any, + localRelayHost: (() => "local-relay-host") as any, + sandboxRelayHost: (() => "sandbox-relay-host") as any, + responderFactory: () => ({ + async onPermission() { + return { kind: "allow" } as const; + }, + async onClientTool() { + return { kind: "deny" } as const; + }, + }), + readStoredSandboxPointer: async () => ({ sandboxId: "sbx-warm" }), + }; + + return { + calls, + deps, + continuityStore, + leakedCodexChildRunning: () => leakedCodexChildRunning, + }; +} + +const stopRequest: AgentRunRequest = { + harness: "claude", + sandbox: "daytona", + sessionId: "sess-stop", + streamId: "stream-stop", + messages: [{ role: "user", content: "remember the codeword" }], + telemetry: { + exporters: { otlp: { headers: { authorization: "ApiKey abc" } } }, + } as any, +}; + +/** Build the real timing shape: acquire first, then abort when the harness prompt is in flight. */ +function fakeAbortingSandbox( + opts: CancelFakeOpts = {}, + kind: "user-stop" | "plain" = "user-stop", +) { + const controller = new AbortController(); + const fake = fakeCancellableSandbox({ + ...opts, + onPrompt: () => + kind === "user-stop" + ? controller.abort(USER_STOP_ABORT_REASON) + : controller.abort(), + }); + return { ...fake, signal: controller.signal }; +} + +describe("a stopped turn's continuity record", () => { + it("completes the durable ledger row with an end time and the native session id", async () => { + const { calls, deps, signal } = fakeAbortingSandbox(); + + const result = await runSandboxAgent(stopRequest, undefined, signal, deps); + + assert.equal(result.ok, true); + assert.equal(result.stopReason, "cancelled"); + assert.equal(result.cancelSettled, true, "the harness confirmed the stop"); + assert.deepEqual(calls.cancelled, ["harness-session-1"]); + + assert.equal( + calls.completed.length, + 1, + "a settled Stop completes its ledger row exactly once", + ); + const completed = calls.completed[0]; + assert.equal(completed.sessionId, "sess-stop"); + assert.equal(completed.turnIndex, 0, "it completes the row it started"); + assert.equal( + completed.agentSessionId, + AGENT_SESSION_ID, + "the row carries the harness session the next turn must load", + ); + // `hydrateHarnessSessionFromDurable` refuses a row without this field. + assert.ok( + completed.endTime && !Number.isNaN(Date.parse(completed.endTime)), + "end_time is an ISO instant, not empty", + ); + }); + + it("advances the in-memory resume pointer, so the next turn may load by id", async () => { + const { deps, continuityStore, signal } = fakeAbortingSandbox(); + + await runSandboxAgent(stopRequest, undefined, signal, deps); + + assert.deepEqual(continuityStore.get("sess-stop", "claude"), { + agentSessionId: AGENT_SESSION_ID, + turnIndex: 0, + }); + assert.equal( + continuityStore.latestTurn("sess-stop"), + 0, + "the stopped turn consumed its index", + ); + }); + + it("keeps the sandbox warm as well, so both halves of the resume survive", async () => { + const { calls, deps, signal } = fakeAbortingSandbox(); + + await runSandboxAgent(stopRequest, undefined, signal, deps); + + assert.equal(calls.paused, 1, "a confirmed Stop parks"); + assert.equal(calls.destroyed, 0); + }); + + it("reaps the Codex shell child before parking the warm sandbox", async () => { + const controller = new AbortController(); + const fake = fakeCancellableSandbox({ + leakedCodexChild: true, + onPrompt: () => controller.abort(USER_STOP_ABORT_REASON), + }); + + const result = await runSandboxAgent( + { ...stopRequest, harness: "codex" }, + undefined, + controller.signal, + fake.deps, + ); + + assert.equal(result.ok, true); + assert.equal(result.stopReason, "cancelled"); + assert.equal(fake.leakedCodexChildRunning(), false); + assert.deepEqual(fake.calls.lifecycle, ["cancel", "ps", "kill", "park"]); + }); + + for (const codexReapFailure of ["failed", "unknown"] as const) { + it(`keeps a settled Codex Stop warm after a ${codexReapFailure} reap`, async () => { + const { calls, continuityStore, deps, signal } = fakeAbortingSandbox({ + codexReapFailure, + }); + + const result = await runSandboxAgent( + { ...stopRequest, harness: "codex" }, + undefined, + signal, + deps, + ); + + assert.equal(result.ok, true); + assert.equal(result.cancelSettled, true); + assert.equal(calls.paused, 1, "a settled Stop still parks"); + assert.equal(calls.destroyed, 0); + assert.equal(calls.completed.length, 1, "continuity stays durable"); + assert.equal( + continuityStore.get("sess-stop", "codex")?.agentSessionId, + AGENT_SESSION_ID, + ); + assert.ok(calls.logs.some((line) => line.includes("cleanup_miss=true"))); + }); + } + + it("writes the record even when the abort was not a user Stop and the sandbox is deleted", async () => { + // A disconnect deletes the sandbox, but the harness still confirmed it is idle and its + // native session lives on the durable cwd, so the record stays worth keeping: the next turn + // mounts the same durable directory and may `session/load` into a fresh sandbox. + const { calls, deps, continuityStore, signal } = fakeAbortingSandbox( + {}, + "plain", + ); + + const result = await runSandboxAgent(stopRequest, undefined, signal, deps); + + assert.equal(result.ok, true); + assert.equal(calls.destroyed, 1, "an unlabelled abort still deletes"); + assert.equal(calls.paused, 0); + assert.equal(calls.completed.length, 1); + assert.equal( + continuityStore.get("sess-stop", "claude")?.agentSessionId, + AGENT_SESSION_ID, + ); + }); +}); + +describe("an abort the harness never confirmed", () => { + it("drops the record and leaves the ledger row open", async () => { + // An unpatched client cannot send `session/cancel`, so the harness may still be writing. + // This is the unchanged floor: no record, no completion, cold replay next turn. + const { calls, deps, continuityStore, signal } = fakeAbortingSandbox({ + cancellable: false, + }); + + const result = await runSandboxAgent(stopRequest, undefined, signal, deps); + + assert.equal(result.ok, true); + assert.equal(result.cancelSettled, false); + assert.deepEqual(calls.completed, [], "no end_time for an unknown state"); + assert.equal(continuityStore.get("sess-stop", "claude"), undefined); + assert.equal(calls.destroyed, 1, "unknown means delete"); + assert.equal(calls.paused, 0); + }); + + it("still appended the started row, which alone must never look resumable", async () => { + const { calls, deps, signal } = fakeAbortingSandbox({ + cancellable: false, + }); + + await runSandboxAgent(stopRequest, undefined, signal, deps); + + assert.equal(calls.appended.length, 1, "the turn started, so a row exists"); + assert.equal(calls.appended[0].turnIndex, 0); + assert.deepEqual(calls.completed, []); + }); +}); diff --git a/services/runner/tests/unit/continuation.test.ts b/services/runner/tests/unit/continuation.test.ts index ae8fb9747d9..36807c98721 100644 --- a/services/runner/tests/unit/continuation.test.ts +++ b/services/runner/tests/unit/continuation.test.ts @@ -75,10 +75,10 @@ describe("buildTurnText", () => { }); }); -// S3: on any successful resume rung (HOT continuation OR S1 session/load) the ACP prompt is -// last-message-only; buildTurnText only runs on the cold path. This imports `runTurn`'s own -// decision function, so the pin fails if the shipped rule drifts. -describe("S3 skip-flatten: sendLastMessageOnly = continuation || loaded", () => { +// S3: HOT continuation is intrinsically verified because the live harness never went away. A +// cold `session/load` must additionally prove that native history was replayed; accepting the id +// alone is not enough to discard the reconstructed transcript. +describe("S3 skip-flatten: only verified native history uses last-message-only", () => { it("cold turn (neither flag): the full transcript is sent, not last-message-only", () => { assert.equal(sendLastMessageOnly({}), false); }); @@ -87,11 +87,25 @@ describe("S3 skip-flatten: sendLastMessageOnly = continuation || loaded", () => assert.equal(sendLastMessageOnly({ continuation: true }), true); }); - it("S1 session/load rehydration turn: last-message-only", () => { - assert.equal(sendLastMessageOnly({ loaded: true }), true); + it("S1 session/load that only accepted the id: full reconstructed transcript", () => { + assert.equal(sendLastMessageOnly({ loaded: true }), false); + }); + + it("S1 session/load with observed native history: last-message-only", () => { + assert.equal( + sendLastMessageOnly({ loaded: true, nativeHistoryVerified: true }), + true, + ); }); it("both flags set (should not happen, but never double-flattens): still last-message-only", () => { - assert.equal(sendLastMessageOnly({ continuation: true, loaded: true }), true); + assert.equal( + sendLastMessageOnly({ + continuation: true, + loaded: true, + nativeHistoryVerified: false, + }), + true, + ); }); }); diff --git a/services/runner/tests/unit/control-command-apply.test.ts b/services/runner/tests/unit/control-command-apply.test.ts new file mode 100644 index 00000000000..825d0bc3bfa --- /dev/null +++ b/services/runner/tests/unit/control-command-apply.test.ts @@ -0,0 +1,780 @@ +/** + * The rules a control command obeys on the runner. + * + * A Stop reaches the runner as a durable command naming one execution. Four rules decide what + * the runner does with it, and this file pins all four: + * + * 1. It aborts the named execution when it holds it, which is what keeps the sandbox warm + * (the abort ends the turn `cancelled`, and only a cancelled turn takes the park path). + * 2. It aborts NOTHING when it holds an execution that started after the command was created. + * That is the late-Stop guard, and it is exact because it reads this process's own memory. + * 3. A session it holds parked awaiting an approval releases every gate, answers `stopped`, + * and stays warm as an idle session for the next normal prompt. + * 4. The same command delivered twice aborts once and acknowledges twice. + * 5. It aborts NOTHING when the named execution's prompt has already settled and only its + * teardown is still running. That Stop lost the race by a moment, and aborting a finished + * run would destroy the warm environment teardown was about to park. + */ +import assert from "node:assert/strict"; +import { beforeEach, describe, it } from "vitest"; + +import { + applyCommand, + holdsSession, + reportOutcome, + type ControlCommand, + type ControlOutcome, +} from "../../src/sessions/control-channel.ts"; +import { stopParkedApprovalSession } from "../../src/server.ts"; +import { resetAppliedCommandsForTest } from "../../src/sessions/applied-commands.ts"; +import { shouldPark } from "../../src/engines/sandbox_agent/engine.ts"; +import type { + ParkedApproval, + SessionEnvironment, +} from "../../src/engines/sandbox_agent.ts"; +import { + isUserStopAbort, + USER_STOP_ABORT_REASON, +} from "../../src/sessions/stop-signal.ts"; +import { + findExecution, + noteExecutionSettled, + registerExecution, + resetExecutionsForTest, + noteExecutionProject, + unregisterExecution, + type LiveExecution, +} from "../../src/sessions/execution-registry.ts"; + +const PROJECT = "11111111-1111-4111-8111-111111111111"; +const SESSION = "sess-42"; +const TURN = "turn-A"; + +/** t=1000 is "now"; a command created at t=1000 is contemporary with a run started at t=900. */ +const COMMAND_CREATED_AT = new Date(1000).toISOString(); + +function command(overrides: Partial = {}): ControlCommand { + return { + id: "cmd-1", + projectId: PROJECT, + sessionId: SESSION, + kind: "cancel", + target: { turnId: TURN, expectedTurnId: null }, + createdAt: COMMAND_CREATED_AT, + ...overrides, + }; +} + +function liveRun( + overrides: Partial = {}, +): { execution: LiveExecution; aborts: number[] } { + const aborts: number[] = []; + const execution: LiveExecution = { + projectId: PROJECT, + sessionId: SESSION, + turnId: TURN, + startedAt: 900, + abort: () => aborts.push(Date.now()), + ...overrides, + }; + return { execution, aborts }; +} + +function collector(): { + reported: ControlOutcome[]; + report: (c: ControlCommand, o: ControlOutcome) => Promise; +} { + const reported: ControlOutcome[] = []; + return { + reported, + report: async (_c, o) => { + reported.push(o); + }, + }; +} + +beforeEach(() => { + resetExecutionsForTest(); + resetAppliedCommandsForTest(); +}); + +describe("applyCommand", () => { + it("aborts the live execution the command names and reports it stopped", async () => { + const { execution, aborts } = liveRun(); + const { reported, report } = collector(); + + const outcome = await applyCommand(command(), { + findLive: () => execution, + report, + }); + + assert.equal(aborts.length, 1); + assert.equal(outcome.result, "applied"); + assert.equal(outcome.execution.state, "stopped"); + assert.equal(outcome.execution.id, TURN); + assert.deepEqual(reported, [outcome]); + }); + + it("reports not_running when this process holds no execution or parked approval", async () => { + const { reported, report } = collector(); + + const outcome = await applyCommand(command(), { + findLive: () => undefined, + report, + }); + + assert.equal(outcome.result, "applied"); + assert.equal(outcome.execution.state, "not_running"); + assert.equal(reported.length, 1); + }); + + it("stops a parked approval, clears its gates, and leaves the next prompt warm", async () => { + const { reported, report } = collector(); + const permissionReplies: Array<{ id: string; reply: string }> = []; + const prompts: string[] = []; + const parked = { + state: "awaiting_approval" as "awaiting_approval" | "idle", + gates: new Map([ + ["tool-a", { permissionId: "perm-a" }], + ["tool-b", { permissionId: "perm-b" }], + ]), + session: { + respondPermission: async (id: string, reply: string) => { + permissionReplies.push({ id, reply }); + }, + prompt: async (text: string) => { + prompts.push(text); + }, + }, + }; + + const outcome = await applyCommand(command(), { + findLive: () => undefined, + isParked: (projectId, sessionId) => + projectId === PROJECT && + sessionId === SESSION && + parked.state === "awaiting_approval" + ? { + stop: async () => { + for (const gate of parked.gates.values()) { + await parked.session.respondPermission( + gate.permissionId, + "reject", + ); + } + parked.gates.clear(); + parked.state = "idle"; + }, + } + : undefined, + report, + }); + + assert.equal(outcome.result, "applied"); + assert.equal(outcome.execution.state, "stopped"); + assert.equal(outcome.execution.id, TURN); + assert.deepEqual(permissionReplies, [ + { id: "perm-a", reply: "reject" }, + { id: "perm-b", reply: "reject" }, + ]); + assert.equal(parked.gates.size, 0); + assert.equal(parked.state, "idle"); + + if (parked.state === "idle") { + await parked.session.prompt("what next?"); + } + assert.deepEqual(prompts, ["what next?"]); + assert.deepEqual(reported, [outcome]); + }); + + it("reparks a stopped approval only after the harness cancel settles", async () => { + const journal: string[] = []; + let settlePrompt!: (value: unknown) => void; + const promptPromise = new Promise((resolve) => { + settlePrompt = resolve; + }); + const gate: ParkedApproval = { + gateType: "claude-acp-permission", + permissionId: "perm-a", + toolCallId: "tool-a", + toolName: "commit", + args: {}, + interactionToken: "interaction-a", + promptPromise, + }; + const env = { + sandbox: { + cancelSession: async () => { + journal.push("cancel"); + settlePrompt({ stopReason: "cancelled" }); + }, + }, + session: { + id: "harness-session", + respondPermission: async () => journal.push("reject"), + }, + logger: () => {}, + parkedApprovals: new Map([[gate.toolCallId, gate]]), + parkedApproval: gate, + parkedApprovedExecutions: new Map([["approved", {}]]), + approvalGateCount: 1, + nonParkablePauseCount: 1, + commitAuthorization: {}, + sessionDestroyRequested: false, + clearTurn: () => journal.push("clear"), + } as unknown as SessionEnvironment; + let tornDown = 0; + + await stopParkedApprovalSession({ + environment: env, + repark: async () => { + journal.push("repark"); + return true; + }, + teardown: async () => { + tornDown += 1; + }, + cancelSettleMs: 1, + wait: async () => {}, + }); + + assert.deepEqual(journal, ["reject", "cancel", "clear", "repark"]); + assert.equal(env.parkedApprovals.size, 0); + assert.equal(env.parkedApproval, undefined); + assert.equal(env.sessionDestroyRequested, true); + assert.equal(tornDown, 0); + }); + + it("tears down a stopped approval when the harness cancel does not settle", async () => { + const journal: string[] = []; + const gate: ParkedApproval = { + gateType: "claude-acp-permission", + permissionId: "perm-a", + toolCallId: "tool-a", + toolName: "commit", + args: {}, + interactionToken: "interaction-a", + promptPromise: new Promise(() => {}), + }; + const env = { + sandbox: { + cancelSession: async () => journal.push("cancel"), + }, + session: { + id: "harness-session", + respondPermission: async () => journal.push("reject"), + }, + logger: () => {}, + parkedApprovals: new Map([[gate.toolCallId, gate]]), + parkedApproval: gate, + parkedApprovedExecutions: new Map(), + approvalGateCount: 1, + nonParkablePauseCount: 0, + sessionDestroyRequested: false, + clearTurn: () => journal.push("clear"), + } as unknown as SessionEnvironment; + + await assert.rejects( + stopParkedApprovalSession({ + environment: env, + repark: async () => { + journal.push("repark"); + return true; + }, + teardown: async () => { + journal.push("teardown"); + }, + cancelSettleMs: 1, + wait: async () => { + journal.push("timeout"); + }, + }), + /parked approval harness cancel did not settle/, + ); + + assert.deepEqual(journal, [ + "reject", + "cancel", + "timeout", + "timeout", + "teardown", + ]); + assert.equal(env.parkedApprovals.size, 1); + assert.equal(env.sessionDestroyRequested, true); + }); + + it("reparks a parked approval warm when the sandbox client has no cancelSession", async () => { + // The local provider's sandbox client can lack `cancelSession` (an older runtime), so the + // runner cannot send the ACP session/cancel and `cancelHarnessTurn` answers + // `sent=false reason=client-has-no-cancelSession`. A parked approval runs no turn, and the + // reject below is still the stop signal, so the environment must repark WARM and the Stop must + // report `stopped` — never tear the sandbox down and report a failed cancel. + const journal: string[] = []; + const gate: ParkedApproval = { + gateType: "claude-acp-permission", + permissionId: "perm-a", + toolCallId: "tool-a", + toolName: "commit", + args: {}, + interactionToken: "interaction-a", + // A prompt that never settles: without a cancelSession the runner never waits on it, so a + // pending prompt must not block or fail the repark. + promptPromise: new Promise(() => {}), + }; + const env = { + // No `cancelSession` on the sandbox client. This is the local-runtime case. + sandbox: {}, + session: { + id: "harness-session", + respondPermission: async () => journal.push("reject"), + }, + logger: () => {}, + parkedApprovals: new Map([[gate.toolCallId, gate]]), + parkedApproval: gate, + parkedApprovedExecutions: new Map([["approved", {}]]), + approvalGateCount: 1, + nonParkablePauseCount: 0, + commitAuthorization: {}, + sessionDestroyRequested: false, + clearTurn: () => journal.push("clear"), + } as unknown as SessionEnvironment; + let tornDown = 0; + + await stopParkedApprovalSession({ + environment: env, + repark: async () => { + journal.push("repark"); + return true; + }, + teardown: async () => { + tornDown += 1; + }, + cancelSettleMs: 1, + wait: async () => {}, + }); + + // No cancel was sent, so the environment is reparked straight from the reject and never + // tears down. + assert.deepEqual(journal, ["reject", "clear", "repark"]); + assert.equal(tornDown, 0, "a Stop must never evict the warm sandbox"); + assert.equal(env.parkedApprovals.size, 0); + assert.equal(env.parkedApproval, undefined); + // No cancel notification left the runner, so no destroy was ever requested for the session. + assert.equal(env.sessionDestroyRequested, false); + }); + + it("stops a local parked approval, staying warm, and reports it stopped end to end", async () => { + // The same case as above, but through `applyCommand`, which is what the /cancel route calls. + // It proves the OUTCOME the API settles on: `applied` / `stopped`, which is what writes the + // one terminal `session_executions` row. Before the fix this answered `applied` / `failed`, + // which the API never records as a terminal execution. + const { reported, report } = collector(); + const parked = { state: "awaiting_approval" as "awaiting_approval" | "idle" }; + let reparked = false; + let tornDown = false; + + const env = { + sandbox: {}, // no cancelSession + session: { + id: "harness-session", + respondPermission: async () => {}, + }, + logger: () => {}, + parkedApprovals: new Map([ + [ + "tool-a", + { + gateType: "claude-acp-permission", + permissionId: "perm-a", + toolCallId: "tool-a", + toolName: "commit", + args: {}, + interactionToken: "interaction-a", + promptPromise: new Promise(() => {}), + } as ParkedApproval, + ], + ]), + parkedApproval: undefined, + parkedApprovedExecutions: new Map(), + approvalGateCount: 1, + nonParkablePauseCount: 0, + commitAuthorization: {}, + sessionDestroyRequested: false, + clearTurn: () => {}, + } as unknown as SessionEnvironment; + + const outcome = await applyCommand(command(), { + findLive: () => undefined, + isParked: (projectId, sessionId) => + projectId === PROJECT && + sessionId === SESSION && + parked.state === "awaiting_approval" + ? { + stop: () => + stopParkedApprovalSession({ + environment: env, + repark: async () => { + reparked = true; + parked.state = "idle"; + return true; + }, + teardown: async () => { + tornDown = true; + }, + cancelSettleMs: 1, + wait: async () => {}, + }), + } + : undefined, + report, + }); + + assert.equal(outcome.result, "applied"); + assert.equal(outcome.execution.state, "stopped"); + assert.equal(outcome.execution.id, TURN); + assert.equal(reparked, true, "the warm sandbox returns to the pool"); + assert.equal(tornDown, false, "and is never evicted"); + assert.equal(parked.state, "idle"); + assert.deepEqual(reported, [outcome]); + }); + + it("refuses to abort an execution that started AFTER the command was created", async () => { + const { execution, aborts } = liveRun({ + turnId: "turn-B", + startedAt: 5000, // the command was created at t=1000 + }); + const { reported, report } = collector(); + + const outcome = await applyCommand(command(), { + findLive: () => execution, + report, + }); + + assert.equal(aborts.length, 0, "a newer turn must never be aborted"); + assert.equal(outcome.result, "obsolete"); + assert.equal(outcome.execution.state, "superseded_by_newer_turn"); + assert.equal(reported.length, 1); + }); + + it("reports not_running when it holds a DIFFERENT, older execution", async () => { + const { execution, aborts } = liveRun({ turnId: "turn-Z", startedAt: 500 }); + const { report } = collector(); + + const outcome = await applyCommand(command(), { + findLive: () => execution, + report, + }); + + assert.equal(aborts.length, 0); + assert.equal(outcome.result, "obsolete"); + assert.equal(outcome.execution.state, "not_running"); + assert.equal(outcome.execution.id, TURN); + }); + + it("aborts once and acknowledges twice when the same command is delivered twice", async () => { + const { execution, aborts } = liveRun(); + const { reported, report } = collector(); + + await applyCommand(command(), { findLive: () => execution, report }); + await applyCommand(command(), { findLive: () => execution, report }); + + assert.equal(aborts.length, 1, "a second abort could kill a newer turn"); + assert.equal(reported.length, 2, "a lost acknowledgement must be repairable"); + assert.equal(reported[1].execution.state, "stopped"); + }); + + it("remembers the command before aborting, so a duplicate mid-cancel is still a no-op", async () => { + // The abort itself delivers a second copy of the same command, which is what a retried + // admission looks like on the wire. + let nested: ControlOutcome | undefined; + const { report } = collector(); + const aborts: number[] = []; + const execution: LiveExecution = { + projectId: PROJECT, + sessionId: SESSION, + turnId: TURN, + startedAt: 900, + abort: () => { + aborts.push(1); + }, + }; + + const outcome = await applyCommand(command(), { + findLive: () => execution, + report: async (c, o) => { + if (nested === undefined) { + nested = await applyCommand(c, { findLive: () => execution, report }); + } + }, + }); + + assert.equal(aborts.length, 1); + assert.equal(outcome.execution.state, "stopped"); + assert.equal(nested?.execution.state, "stopped"); + }); + + it("aborts with the user-stop label, which is what lets the sandbox park", async () => { + // The registry hands the applier whatever abort the transport registered. `shouldPark` + // parks only an abort the runner can prove was a cooperative Stop, so an unlabelled abort + // here would end the turn `cancelled` and then DESTROY the sandbox. This pins the contract + // the applier depends on; `server.ts` is where the label is actually attached. + const controller = new AbortController(); + const execution: LiveExecution = { + projectId: PROJECT, + sessionId: SESSION, + turnId: TURN, + startedAt: 900, + abort: () => controller.abort(USER_STOP_ABORT_REASON), + }; + const { report } = collector(); + + await applyCommand(command(), { findLive: () => execution, report }); + + assert.equal(isUserStopAbort(controller.signal), true); + assert.equal( + shouldPark( + { ok: true, stopReason: "cancelled", cancelSettled: true }, + controller.signal, + undefined, + ), + true, + "a Stop delivered as a command must leave the sandbox parkable", + ); + }); + + it("does NOT park when the abort carries no label", () => { + // The regression this guards: the first version of the control route called + // `controller.abort()` with no reason, so every Stop through it destroyed the sandbox. + const controller = new AbortController(); + controller.abort(); + + assert.equal(isUserStopAbort(controller.signal), false); + assert.equal( + shouldPark( + { ok: true, stopReason: "cancelled", cancelSettled: true }, + controller.signal, + undefined, + ), + false, + ); + }); + + it("aborts nothing when the named execution's prompt has already settled", async () => { + // The race the user cannot see: the answer lands, they press Stop a moment later, and the + // entry is still registered because teardown is writing the transcript and parking the + // sandbox. Aborting here stops nothing and makes teardown destroy a healthy environment. + const { execution, aborts } = liveRun({ settled: true }); + const { reported, report } = collector(); + + const outcome = await applyCommand(command(), { + findLive: () => execution, + report, + }); + + assert.equal(aborts.length, 0, "a finished run must not be aborted"); + assert.equal(outcome.result, "obsolete", "the command stopped nothing"); + assert.equal(outcome.execution.state, "not_running"); + assert.equal(outcome.execution.id, TURN); + assert.deepEqual(reported, [outcome], "and it still acknowledges"); + }); + + it("still aborts an execution whose prompt has NOT settled", async () => { + // The guard must be the flag and not the mere presence of teardown, or every Stop becomes + // a no-op and Stop stops working. + const { execution, aborts } = liveRun({ settled: false }); + const { report } = collector(); + + const outcome = await applyCommand(command(), { + findLive: () => execution, + report, + }); + + assert.equal(aborts.length, 1); + assert.equal(outcome.execution.state, "stopped"); + }); + + it("parks the environment of a finished turn that a late Stop did not abort", () => { + // The consequence the fix exists for, stated as the teardown sees it. No abort means no + // aborted signal, so a normally finished turn takes the ordinary park path. + const controller = new AbortController(); + assert.equal( + shouldPark( + { ok: true, stopReason: "end_turn" } as never, + controller.signal, + undefined, + ), + true, + "an un-aborted, cleanly finished turn parks", + ); + // And this is what used to happen instead: the late abort fired, and the same finished + // turn was destroyed rather than parked. + controller.abort(USER_STOP_ABORT_REASON); + assert.equal( + shouldPark( + { ok: true, stopReason: "end_turn" } as never, + controller.signal, + undefined, + ), + false, + "which is why the applier must not abort a settled run", + ); + }); + + it("reports the cancel as failed when the abort itself throws", async () => { + const execution: LiveExecution = { + projectId: PROJECT, + sessionId: SESSION, + turnId: TURN, + startedAt: 900, + abort: () => { + throw new Error("controller is gone"); + }, + }; + const { reported, report } = collector(); + + const outcome = await applyCommand(command(), { + findLive: () => execution, + report, + }); + + assert.equal(outcome.execution.state, "failed"); + assert.equal(outcome.execution.error, "controller is gone"); + assert.equal(reported.length, 1); + }); +}); + +describe("the execution registry", () => { + it("refuses a lookup from another project once the scope is known", () => { + const { execution } = liveRun(); + registerExecution(execution); + + assert.equal(findExecution(PROJECT, SESSION)?.turnId, TURN); + assert.equal( + findExecution("22222222-2222-4222-8222-222222222222", SESSION), + undefined, + "two projects may use the same session id; the project is the tenant boundary", + ); + }); + + it("matches any project until the coordinator has resolved the scope", () => { + // `runContext.project.id` is empty on the live invoke path, so a run is registered before + // its project is known. Refusing every Stop in that window is what made the first version + // of this registry answer 404 for every real Stop. + registerExecution(liveRun({ projectId: undefined }).execution); + + assert.equal(findExecution(PROJECT, SESSION)?.turnId, TURN); + + noteExecutionProject(SESSION, TURN, PROJECT); + assert.equal( + findExecution("22222222-2222-4222-8222-222222222222", SESSION), + undefined, + "once the scope is known, another tenant is refused", + ); + }); + + it("does not let a late scope callback relabel a successor turn", () => { + registerExecution(liveRun({ turnId: "turn-2", projectId: undefined }).execution); + + noteExecutionProject(SESSION, "turn-1", "some-other-project"); + + assert.equal(findExecution(PROJECT, SESSION)?.projectId, undefined); + }); + + it("marks only the turn it names as settled", () => { + registerExecution({ + projectId: PROJECT, + sessionId: SESSION, + turnId: TURN, + startedAt: 900, + abort: () => {}, + }); + + // A late callback from a turn that has already been replaced must not mark the successor + // finished, which would make every Stop on the live turn a no-op. + noteExecutionSettled(SESSION, "some-older-turn"); + assert.equal(findExecution(PROJECT, SESSION)?.settled, undefined); + + noteExecutionSettled(SESSION, TURN); + assert.equal(findExecution(PROJECT, SESSION)?.settled, true); + }); + + it("does not let a finished turn unregister its successor", () => { + const first = liveRun({ turnId: "turn-1" }).execution; + const second = liveRun({ turnId: "turn-2" }).execution; + registerExecution(first); + registerExecution(second); + + unregisterExecution(SESSION, "turn-1"); + + assert.equal(findExecution(PROJECT, SESSION)?.turnId, "turn-2"); + }); +}); + +describe("holdsSession", () => { + it("is true for a live execution", () => { + registerExecution(liveRun().execution); + assert.equal(holdsSession(PROJECT, SESSION), true); + }); + + it("is true for a session parked awaiting an approval, which runs no turn", () => { + // This is the case that has no control channel at all today: a parked session stops + // heartbeating, so the existing Stop signal never reaches it. + assert.equal(holdsSession(PROJECT, SESSION), false); + assert.equal( + holdsSession(PROJECT, SESSION, (projectId, sessionId) => + projectId === PROJECT && sessionId === SESSION + ? { stop: () => {} } + : undefined, + ), + true, + ); + }); + + it("does not match a parked session with the same id in another project", () => { + assert.equal( + holdsSession( + PROJECT, + SESSION, + (projectId, sessionId) => + projectId === "22222222-2222-4222-8222-222222222222" && + sessionId === SESSION + ? { stop: () => {} } + : undefined, + ), + false, + ); + }); + + it("is false for a session this process does not hold, which is what answers 404", () => { + assert.equal( + holdsSession(PROJECT, "other-session", () => undefined), + false, + ); + }); +}); + +describe("reportOutcome", () => { + it("rejects redirects so the runner token cannot be forwarded", async () => { + const previousToken = process.env.AGENTA_RUNNER_TOKEN; + const previousFetch = globalThis.fetch; + let captured: RequestInit | undefined; + process.env.AGENTA_RUNNER_TOKEN = "shared-secret"; + globalThis.fetch = (async (_input, init) => { + captured = init; + return new Response("{}", { status: 200 }); + }) as typeof fetch; + + try { + await reportOutcome(command(), { + result: "applied", + execution: { id: TURN, state: "stopped" }, + }); + } finally { + globalThis.fetch = previousFetch; + if (previousToken === undefined) delete process.env.AGENTA_RUNNER_TOKEN; + else process.env.AGENTA_RUNNER_TOKEN = previousToken; + } + + assert.equal(captured?.redirect, "error"); + }); +}); diff --git a/services/runner/tests/unit/credential-preflight.test.ts b/services/runner/tests/unit/credential-preflight.test.ts index db52ea995bd..7917509065b 100644 --- a/services/runner/tests/unit/credential-preflight.test.ts +++ b/services/runner/tests/unit/credential-preflight.test.ts @@ -8,39 +8,71 @@ import assert from "node:assert/strict"; import { awaitCredentialSubstitution, + buildCredentialPreflightInput, deliversModelSecretOnCreate, - type PreflightSandbox, + type ControlProbeRequest, + type ControlProbeResponse, } from "../../src/engines/sandbox_agent/credential-preflight.ts"; -/** A sandbox whose probe responses play back in order (the last repeats forever). */ -function sandboxAnswering(bodies: (string | Error)[]): { - sandbox: PreflightSandbox; - commands: string[]; -} { +interface HarnessOptions { + /** The model connection's provider and how it is reached; together they pick the shape. */ + provider?: string; + deployment?: string; + /** The real key value the runner holds, used only by its own auth call. */ + controlKey?: string; + /** What that call answers, an error it throws, or "pending" for one that never settles. */ + control?: ControlProbeResponse | Error | "pending"; + baseUrl?: string; + apiKeyVar?: string; + /** Clock milliseconds each sandbox probe consumes, for deadline tests. */ + probeCostMs?: number; + /** Drive the real fetch-backed probe instead of an injected one. */ + fetchImpl?: typeof fetch; + signal?: AbortSignal; +} + +/** + * Drive one preflight against a scripted sandbox (the last body repeats forever) and a + * scripted runner call, on a fake clock that only moves when the code sleeps or probes. + */ +function harness( + bodies: (string | Error)[], + budgetMs = 25_000, + options: HarnessOptions = {}, +) { const commands: string[] = []; + const logs: string[] = []; + const controlRequests: ControlProbeRequest[] = []; let index = 0; - return { - commands, + let clock = 0; + const run = awaitCredentialSubstitution({ sandbox: { async runProcess(request) { commands.push(request.args?.[1] ?? request.command); + clock += options.probeCostMs ?? 0; const body = bodies[Math.min(index, bodies.length - 1)]; index += 1; if (body instanceof Error) throw body; return { exitCode: 0, stdout: body }; }, }, - }; -} - -function harness(bodies: (string | Error)[], budgetMs = 25_000) { - const { sandbox, commands } = sandboxAnswering(bodies); - const logs: string[] = []; - let clock = 0; - const run = awaitCredentialSubstitution({ - sandbox, - baseUrl: "https://gateway.example/", - apiKeyVar: "OPENAI_API_KEY", + baseUrl: options.baseUrl ?? "https://gateway.example/", + apiKeyVar: options.apiKeyVar ?? "OPENAI_API_KEY", + ...(options.provider ? { provider: options.provider } : {}), + ...(options.deployment ? { deployment: options.deployment } : {}), + ...(options.controlKey ? { controlKey: options.controlKey } : {}), + ...(options.signal ? { signal: options.signal } : {}), + ...(options.fetchImpl + ? { fetchImpl: options.fetchImpl } + : { + controlProbe: async (request) => { + controlRequests.push(request); + if (options.control instanceof Error) throw options.control; + if (options.control === "pending") + return new Promise(() => {}); + return options.control ?? { status: 200 }; + }, + }), log: (m) => logs.push(m), budgetMs, pollMs: 2_000, @@ -49,10 +81,59 @@ function harness(bodies: (string | Error)[], budgetMs = 25_000) { clock += ms; }, }); - return { run, logs, commands }; + return { run, logs, commands, controlRequests, elapsed: () => clock }; } +/** The OpenRouter body observed in production: a 401 that never names the key. */ +const BARE_401 = + '{"error":{"message":"No auth credentials found","code":401}}\n401'; + +/** A direct OpenRouter connection, the one the production defect was reported on. */ +const OPENROUTER: HarnessOptions = { + provider: "openrouter", + deployment: "direct", + baseUrl: "https://openrouter.ai/api/v1", + apiKeyVar: "OPENROUTER_API_KEY", + controlKey: "sk-or-real-key-value", +}; + describe("awaitCredentialSubstitution", () => { + it("cancels a slow probe promptly when the turn is Stopped", async () => { + const controller = new AbortController(); + let probeStarted!: () => void; + const started = new Promise((resolve) => { + probeStarted = resolve; + }); + const run = awaitCredentialSubstitution({ + sandbox: { + runProcess: async () => { + probeStarted(); + return new Promise(() => {}); + }, + }, + baseUrl: "https://gateway.example/", + apiKeyVar: "OPENAI_API_KEY", + log: () => {}, + signal: controller.signal, + }); + + await started; + controller.abort(); + await assert.rejects( + () => + Promise.race([ + run, + new Promise((_, reject) => + setTimeout( + () => reject(new Error("preflight did not cancel")), + 4_000, + ), + ), + ]), + /acquisition was aborted/, + ); + }); + it("returns ok immediately when the first probe substitutes", async () => { const { run, logs, commands } = harness([ '{"error":{"message":"you must provide a model parameter"}}', @@ -140,6 +221,524 @@ describe("awaitCredentialSubstitution", () => { }); }); +describe("the differential: judging a provider that does not echo the key", () => { + // The production defect (AGE-4249): on the direct OpenRouter connection a bad bearer comes + // back as a 401 that never names the key, so the masked-echo instrument is blind and the + // preflight fails open on probe 1. The second instrument compares that answer against the + // runner's own call to the provider's documented auth endpoint. + + it("convicts a bare 401 when the auth endpoint accepted the same key", async () => { + const { run, logs, commands, controlRequests } = harness( + [BARE_401], + 3_000, + { ...OPENROUTER, control: { status: 200 } }, + ); + assert.equal(await run, "stuck"); + assert.ok(commands.length >= 1); + assert.equal( + controlRequests.length, + 1, + "exactly one runner call per preflight", + ); + const stuck = logs[logs.length - 1]; + assert.match(stuck, /STUCK/); + assert.match(stuck, /auth endpoint accepted the same key/); + }); + + it("fails open when the auth endpoint refused the key too", async () => { + const { run, logs, commands } = harness([BARE_401], 3_000, { + ...OPENROUTER, + control: { status: 401 }, + }); + assert.equal(await run, "ok"); + assert.equal(commands.length, 1, "a refused key must not be re-probed"); + assert.match(logs[0], /refused the same key/); + assert.match(logs[0], /the key itself is being rejected/); + }); + + it("fails open on every status that is neither 200 nor 401, naming it", async () => { + // Only a positive answer from a purpose-built auth endpoint is acceptance. A 403, a + // missing route, a throttle, or a provider outage says something about the request, not + // about the key, and reading any of them as proof would convict a healthy sandbox. + for (const status of [403, 404, 405, 429, 500, 502]) { + const { run, logs, commands } = harness([BARE_401], 3_000, { + ...OPENROUTER, + control: { status }, + }); + assert.equal(await run, "ok", `status ${status}`); + assert.equal(commands.length, 1, `status ${status}`); + assert.match(logs[0], /gave no verdict/, `status ${status}`); + assert.match(logs[0], new RegExp(`HTTP ${status}`), `status ${status}`); + } + }); + + it("fails open when the runner's own call throws or times out", async () => { + const { run, logs } = harness([BARE_401], 3_000, { + ...OPENROUTER, + control: new Error("control timed out"), + }); + assert.equal(await run, "ok"); + assert.match(logs[0], /gave no verdict \(control timed out\)/); + }); + + it("fails open when the runner's own call returns no status", async () => { + const { run, logs } = harness([BARE_401], 3_000, { + ...OPENROUTER, + control: {}, + }); + assert.equal(await run, "ok"); + assert.match(logs[0], /gave no verdict \(no status\)/); + }); + + it("makes no runner call and fails open when the runner holds no key", async () => { + const { run, logs, controlRequests } = harness([BARE_401], 3_000, { + ...OPENROUTER, + controlKey: undefined, + }); + assert.equal(await run, "ok"); + assert.equal(controlRequests.length, 0); + assert.match(logs[0], /no key to check it against/); + }); + + it("fails open on a sandbox status that is not 401", async () => { + const { run } = harness(['{"data":[{"id":"gpt-5.5"}]}\n200'], 3_000, { + ...OPENROUTER, + control: { status: 200 }, + }); + assert.equal(await run, "ok"); + }); + + it("aborts the runner's call as soon as the preflight stops needing it", async () => { + // A healthy sandbox returns on probe 1 while the runner's call is still in flight. + // Nothing reads it after that, so it must not be left running. + const { run, controlRequests } = harness( + ['{"data":[{"id":"gpt-5.5"}]}\n200'], + 3_000, + { ...OPENROUTER, control: "pending" }, + ); + assert.equal(await run, "ok"); + assert.equal(controlRequests.length, 1); + assert.equal(controlRequests[0].signal.aborted, true); + }); + + it("aborts the runner's call after a conviction too", async () => { + const { run, controlRequests } = harness([BARE_401], 3_000, { + ...OPENROUTER, + control: { status: 200 }, + }); + assert.equal(await run, "stuck"); + assert.equal(controlRequests[0].signal.aborted, true); + }); + + it("never puts the real key in a log line or in the sandbox command", async () => { + const secret = "sk-or-real-key-value-0123456789"; + const { run, logs, commands } = harness([BARE_401], 3_000, { + ...OPENROUTER, + controlKey: secret, + control: { status: 200 }, + }); + await run; + for (const line of [...logs, ...commands]) { + assert.ok(!line.includes(secret), `key leaked into: ${line}`); + } + }); + + it("redacts even a very short key value", async () => { + // The redactor takes no view on what a key looks like. A three-character value is still + // the run's credential, and a length rule would be the one hole a leak walks through. + const secret = "abc"; + const { run, logs } = harness( + [`{"error":{"message":"bad key abc, sorry"}}\n401`], + 3_000, + { ...OPENROUTER, controlKey: secret, control: { status: 401 } }, + ); + assert.equal(await run, "ok"); + assert.ok(logs.length > 0); + for (const line of logs) { + assert.ok(!/abc/.test(line), `key leaked into: ${line}`); + } + }); +}); + +describe("the one deadline", () => { + it("caps each probe by what is left of the grace and never runs past it", async () => { + // Probe 1 starts with the full 10s, so curl gets its own 8s ceiling. Probe 2 starts at 5s + // spent and gets 5s. A third probe would have to run past the deadline, so the loop + // convicts instead of sleeping onto it. + const { run, commands, elapsed } = harness( + ["Received=dtn_****9maz"], + 10_000, + { probeCostMs: 3_000 }, + ); + assert.equal(await run, "stuck"); + assert.equal(commands.length, 2); + assert.match(commands[0], /curl -s -m 8 /); + assert.match(commands[1], /curl -s -m 5 /); + assert.ok(elapsed() <= 10_000, `finished at ${elapsed()}ms`); + }); + + it("starts no probe at all when there is no grace to spend", async () => { + // The body would convict if it were ever read. Reaching the fail-open answer with zero + // commands proves the deadline is checked before a probe is started, not after. + const { run, logs, commands } = harness(["Received=dtn_****9maz"], 0); + assert.equal(await run, "ok"); + assert.equal(commands.length, 0); + assert.match(logs[0], /grace spent after 0 probes/); + }); + + it("sends no key to the provider when there is no grace to spend", async () => { + // The runner's call is started before the loop's own deadline check, so it needs the + // same gate. Otherwise a preflight with no time left would still put the real key on the + // wire for an answer nothing would read. + const { run, commands, controlRequests } = harness( + ["Received=dtn_****9maz"], + 0, + { ...OPENROUTER, control: { status: 200 } }, + ); + assert.equal(await run, "ok"); + assert.equal(controlRequests.length, 0, "no key leaves the runner"); + assert.equal(commands.length, 0); + }); + + it("a slow probe cannot push the total past the budget", async () => { + const { run, commands } = harness(["Received=dtn_****9maz"], 10_000, { + probeCostMs: 9_000, + }); + assert.equal(await run, "stuck"); + assert.equal(commands.length, 1, "one 9s probe already spends the grace"); + }); +}); + +describe("provider shapes", () => { + it("uses the OpenAI-compatible chat probe when the deployment is not direct", async () => { + const { run, commands } = harness([BARE_401], 3_000, { + deployment: "custom", + controlKey: "sk-real-key-value", + }); + await run; + assert.match(commands[0], /-X POST /); + assert.match(commands[0], /https:\/\/gateway\.example\/chat\/completions/); + assert.match(commands[0], /Authorization: Bearer \$OPENAI_API_KEY/); + }); + + it("uses OpenRouter's documented key endpoint on a direct connection", async () => { + const { run, commands, controlRequests } = harness([BARE_401], 3_000, { + ...OPENROUTER, + control: { status: 200 }, + }); + await run; + assert.match(commands[0], /'https:\/\/openrouter\.ai\/api\/v1\/key'/); + assert.ok(!commands[0].includes("-X POST"), "the auth probe is a GET"); + assert.match(commands[0], /Authorization: Bearer \$OPENROUTER_API_KEY/); + assert.equal(controlRequests[0].method, "GET"); + assert.equal(controlRequests[0].url, "https://openrouter.ai/api/v1/key"); + assert.equal(controlRequests[0].body, undefined); + assert.equal( + controlRequests[0].headers.Authorization, + `Bearer ${OPENROUTER.controlKey}`, + ); + }); + + it("uses Anthropic's model list on a direct connection", async () => { + const secret = "sk-ant-real-key-value"; + const { run, commands, controlRequests } = harness([BARE_401], 3_000, { + provider: "anthropic", + deployment: "direct", + baseUrl: "https://api.anthropic.com", + apiKeyVar: "ANTHROPIC_API_KEY", + controlKey: secret, + control: { status: 200 }, + }); + await run; + assert.match( + commands[0], + /'https:\/\/api\.anthropic\.com\/v1\/models\?limit=1'/, + ); + assert.match(commands[0], /x-api-key: \$ANTHROPIC_API_KEY/); + assert.match(commands[0], /anthropic-version: 2023-06-01/); + assert.ok( + !commands[0].includes(secret), + "the key never enters the sandbox command", + ); + assert.equal( + controlRequests[0].url, + "https://api.anthropic.com/v1/models?limit=1", + ); + assert.equal(controlRequests[0].headers["x-api-key"], secret); + assert.equal(controlRequests[0].headers["anthropic-version"], "2023-06-01"); + assert.equal(controlRequests[0].headers.Authorization, undefined); + }); + + it("uses OpenAI's model list on a direct connection", async () => { + const { run, commands, controlRequests } = harness([BARE_401], 3_000, { + provider: "openai", + deployment: "direct", + baseUrl: "https://api.openai.com/v1", + controlKey: "sk-real-key-value", + control: { status: 200 }, + }); + assert.equal(await run, "stuck"); + assert.match(commands[0], /'https:\/\/api\.openai\.com\/v1\/models'/); + assert.equal(controlRequests[0].url, "https://api.openai.com/v1/models"); + }); + + it("never applies the differential to a custom gateway, even on a 200", async () => { + // The LiteLLM credits proxy lands here. Its masked 401 is what convicts a stuck sandbox, + // and a 401 from its chat endpoint has too many other causes to attribute. + const { run, controlRequests } = harness([BARE_401], 3_000, { + provider: "openai", + deployment: "custom", + controlKey: "sk-real-key-value", + control: { status: 200 }, + }); + assert.equal(await run, "ok"); + assert.equal(controlRequests.length, 0, "no runner call is made at all"); + }); + + it("never applies the differential to a tenant gateway labelled direct", async () => { + // A vault custom-provider record for a known family is labelled `direct` by the resolver + // while keeping its own URL. Without the canonical-base check the runner would send the + // real key to that gateway's /models and read the answer as a verdict about our sandbox. + const { run, commands, controlRequests } = harness([BARE_401], 3_000, { + provider: "openai", + deployment: "direct", + baseUrl: "https://tenant-gateway.example/v1", + controlKey: "sk-real-key-value", + control: { status: 200 }, + }); + assert.equal(await run, "ok"); + assert.equal(controlRequests.length, 0, "no key leaves the runner"); + assert.match(commands[0], /chat\/completions/); + }); + + it("matches the canonical base through a trailing slash and an uppercase host", async () => { + for (const baseUrl of [ + "https://api.openai.com/v1/", + "https://API.OpenAI.COM/v1", + " https://api.openai.com/v1 ", + ]) { + const { run, controlRequests } = harness([BARE_401], 3_000, { + provider: "openai", + deployment: "direct", + baseUrl, + controlKey: "sk-real-key-value", + control: { status: 200 }, + }); + assert.equal(await run, "stuck", baseUrl); + assert.equal( + controlRequests[0].url, + "https://api.openai.com/v1/models", + baseUrl, + ); + } + }); + + it("does not match a canonical host reached over plain http", async () => { + const { run, controlRequests } = harness([BARE_401], 3_000, { + provider: "openai", + deployment: "direct", + baseUrl: "http://api.openai.com/v1", + controlKey: "sk-real-key-value", + control: { status: 200 }, + }); + assert.equal(await run, "ok"); + assert.equal(controlRequests.length, 0); + }); + + it("does not match a canonical base carrying a query or credentials", async () => { + // The bare `?` and `#` cases matter because `url.search` and `url.hash` are both empty + // strings for them, so a check that read those two properties would let them through. + for (const baseUrl of [ + "https://api.openai.com/v1?tenant=acme", + "https://api.openai.com/v1?", + "https://api.openai.com/v1#", + "https://api.openai.com/v1#frag", + "https://user:pass@api.openai.com/v1", + ]) { + const { run, controlRequests } = harness([BARE_401], 3_000, { + provider: "openai", + deployment: "direct", + baseUrl, + controlKey: "sk-real-key-value", + control: { status: 200 }, + }); + assert.equal(await run, "ok", baseUrl); + assert.equal(controlRequests.length, 0, baseUrl); + } + }); + + it("keeps a direct provider outside the three on the chat probe", async () => { + const { run, commands, controlRequests } = harness([BARE_401], 3_000, { + provider: "gemini", + deployment: "direct", + controlKey: "sk-real-key-value", + control: { status: 200 }, + }); + assert.equal(await run, "ok"); + assert.match(commands[0], /chat\/completions/); + assert.equal(controlRequests.length, 0); + }); + + it("asks the sandbox probe for the HTTP status", async () => { + const { run, commands } = harness([BARE_401], 3_000, OPENROUTER); + await run; + assert.match(commands[0], /http_code/); + }); + + it("single-quotes the URL, so the sandbox shell cannot rewrite it", async () => { + // Double quotes would let the shell expand `$USER` and run the backtick command before + // curl saw the URL. The probe would then call some other host and this instrument would + // report a verdict about a request it never made. + const { run, commands } = harness([BARE_401], 3_000, { + baseUrl: "https://gateway.example/$USER/`id`/v1", + }); + await run; + assert.ok( + commands[0].includes( + "'https://gateway.example/$USER/`id`/v1/chat/completions'", + ), + commands[0], + ); + assert.ok(commands[0].includes("-d '{}' "), commands[0]); + }); + + it("escapes a single quote inside the URL rather than closing the string", async () => { + const { run, commands } = harness([BARE_401], 3_000, { + baseUrl: "https://gateway.example/o'brien/v1", + }); + await run; + assert.ok( + commands[0].includes( + "'https://gateway.example/o'\\''brien/v1/chat/completions'", + ), + commands[0], + ); + }); + + it("fails open when the binding name is not a shell-safe variable name", async () => { + // The name is interpolated into a shell command. Anything but a variable name is an + // upstream programming error, and the preflight refuses rather than building the string. + const { run, logs, commands } = harness([BARE_401], 3_000, { + ...OPENROUTER, + apiKeyVar: "KEY; curl evil.example", + }); + assert.equal(await run, "ok"); + assert.equal(commands.length, 0, "nothing is ever run in the sandbox"); + assert.match(logs[0], /not a shell-safe environment variable name/); + }); +}); + +describe("the runner's own request", () => { + it("refuses to follow a redirect, so the key cannot be sent to another host", async () => { + const seen: { url: string; init: RequestInit }[] = []; + const fetchImpl = (async ( + url: string | URL | Request, + init: RequestInit, + ) => { + seen.push({ url: String(url), init }); + return new Response("{}", { status: 401 }); + }) as unknown as typeof fetch; + const { run } = harness([BARE_401], 3_000, { ...OPENROUTER, fetchImpl }); + assert.equal(await run, "ok"); + assert.equal(seen.length, 1); + assert.equal(seen[0].url, "https://openrouter.ai/api/v1/key"); + assert.equal(seen[0].init.redirect, "error"); + assert.equal(seen[0].init.method, "GET"); + assert.equal(seen[0].init.body, undefined); + assert.ok(seen[0].init.signal, "the request carries an abort signal"); + }); + + it("gives up on a provider that never answers, instead of waiting forever", async () => { + // The preflight's own abort signal cannot end this call: nothing fires it until the + // preflight has already stopped waiting. Only a timer inside the request can. Before it + // existed, a fetch that never settles held `await control` open for the whole run. + let requestSignal: AbortSignal | undefined; + const fetchImpl = (async ( + _url: string | URL | Request, + init: RequestInit, + ) => { + requestSignal = init.signal ?? undefined; + // Never resolves on its own; only the abort ends it, exactly like a hung provider. + return new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => + reject(new Error("aborted")), + ); + }); + }) as unknown as typeof fetch; + // A tiny budget makes the request's own deadline tiny too, so this finishes in + // milliseconds of real time rather than the eight second default. + const { run, logs } = harness([BARE_401], 20, { + ...OPENROUTER, + fetchImpl, + }); + assert.equal(await run, "ok"); + assert.match(logs[0], /gave no verdict/); + assert.equal(requestSignal?.aborted, true); + }); +}); + +describe("buildCredentialPreflightInput: what the acquire path hands the preflight", () => { + // `acquireEnvironment` cannot be driven without a live provider, so this builder is where + // the kickoff's wiring is pinned. The key value has exactly one destination. + const candidate = { + binding: { name: "OPENROUTER_API_KEY" }, + value: "sk-or-real-key-value", + }; + + it("routes the candidate value only into the runner call's credential", () => { + const input = buildCredentialPreflightInput({ + baseUrl: " https://openrouter.ai/api/v1 ", + candidate, + provider: "openrouter", + deployment: "direct", + }); + assert.equal(input.baseUrl, "https://openrouter.ai/api/v1"); + assert.equal(input.apiKeyVar, "OPENROUTER_API_KEY"); + assert.equal(input.provider, "openrouter"); + assert.equal(input.deployment, "direct"); + assert.equal(input.controlKey, candidate.value); + const serialized = JSON.stringify(input); + assert.equal( + serialized.split(candidate.value).length - 1, + 1, + "the value appears once, as the runner call's credential", + ); + }); + + it("keeps the key out of every log line the preflight then writes", async () => { + const input = buildCredentialPreflightInput({ + baseUrl: "https://openrouter.ai/api/v1", + candidate, + provider: "openrouter", + deployment: "direct", + }); + const logs: string[] = []; + const commands: string[] = []; + let clock = 0; + const verdict = await awaitCredentialSubstitution({ + ...input, + sandbox: { + async runProcess(req) { + commands.push(req.args?.[1] ?? req.command); + return { exitCode: 0, stdout: BARE_401 }; + }, + }, + controlProbe: async () => ({ status: 200 }), + log: (m) => logs.push(m), + budgetMs: 3_000, + pollMs: 2_000, + now: () => clock, + sleep: async (ms) => { + clock += ms; + }, + }); + assert.equal(verdict, "stuck"); + for (const line of [...logs, ...commands]) { + assert.ok(!line.includes(candidate.value), `key leaked into: ${line}`); + } + }); +}); + describe("deliversModelSecretOnCreate: what arms the race guards", () => { // The preflight gates on this AND a declared endpoint; the 401 classifier arms its // credential-race reading on this alone. `acquireEnvironment` cannot be driven without a live diff --git a/services/runner/tests/unit/environment-units.test.ts b/services/runner/tests/unit/environment-units.test.ts index 089fa05d2fc..667fb364b7d 100644 --- a/services/runner/tests/unit/environment-units.test.ts +++ b/services/runner/tests/unit/environment-units.test.ts @@ -18,6 +18,7 @@ import { type AcquireStage, } from "../../src/environment/timing.ts"; import * as workspaceManager from "../../src/environment/workspace-manager.ts"; +import { openSession as openHarnessSession } from "../../src/environment/harness-session-lifecycle.ts"; const SRC = (rel: string) => readFileSync( @@ -492,6 +493,115 @@ describe("harness-session unit: the seam", () => { ); }); + it("does not verify a load that accepted the id but emitted no prior messages", async () => { + const result = await openHarnessSession({ + sandbox: { + resumeSession: async () => ({ id: "local", agentSessionId: "native-1" }), + createSession: async () => ({ id: "must-not-create" }), + }, + persist: { + updateSession: async () => {}, + listEvents: async () => ({ items: [] }), + }, + acpAgent: "pi", + harness: "pi_core", + cwd: "/tmp/session", + sessionInit: {}, + priorAgentSessionId: "native-1", + nativeHistoryDurable: false, + localSessionId: "session-1:pi_core", + continuitySessionKey: "session-1", + log: () => {}, + timingLog: () => {}, + }); + + assert.equal(result.mode, "load"); + assert.equal(result.loadedFromContinuity, true); + assert.equal(result.nativeHistoryVerified, false); + }); + + it("verifies a load only after observing native prior-message events", async () => { + let reads = 0; + const result = await openHarnessSession({ + sandbox: { + resumeSession: async () => ({ id: "local", agentSessionId: "native-1" }), + createSession: async () => ({ id: "must-not-create" }), + }, + persist: { + updateSession: async () => {}, + listEvents: async () => { + reads += 1; + return { + items: + reads === 1 + ? [] + : [ + { + sender: "agent", + payload: { + method: "session/update", + params: { + update: { sessionUpdate: "user_message_chunk" }, + }, + }, + }, + ], + }; + }, + }, + acpAgent: "pi", + harness: "pi_core", + cwd: "/tmp/session", + sessionInit: {}, + priorAgentSessionId: "native-1", + nativeHistoryDurable: true, + localSessionId: "session-1:pi_core", + continuitySessionKey: "session-1", + log: () => {}, + timingLog: () => {}, + }); + + assert.equal(result.mode, "load"); + assert.equal(result.loadedFromContinuity, true); + assert.equal(result.nativeHistoryVerified, true); + }); + + it("does not treat prior prompt events as proof for the current load", async () => { + const priorEvent = { + sender: "agent", + payload: { + method: "session/update", + params: { + update: { sessionUpdate: "user_message_chunk" }, + }, + }, + }; + const result = await openHarnessSession({ + sandbox: { + resumeSession: async () => ({ id: "local", agentSessionId: "native-1" }), + createSession: async () => ({ id: "must-not-create" }), + }, + persist: { + updateSession: async () => {}, + listEvents: async () => ({ items: [priorEvent] }), + }, + acpAgent: "pi", + harness: "pi_core", + cwd: "/tmp/session", + sessionInit: {}, + priorAgentSessionId: "native-1", + nativeHistoryDurable: true, + localSessionId: "session-1:pi_core", + continuitySessionKey: "session-1", + log: () => {}, + timingLog: () => {}, + }); + + assert.equal(result.mode, "load"); + assert.equal(result.loadedFromContinuity, true); + assert.equal(result.nativeHistoryVerified, false); + }); + it("the composer delegates both stages", () => { const source = SRC("engines/sandbox_agent/environment.ts"); assert.ok(source.includes("await probeHarness(")); diff --git a/services/runner/tests/unit/harness-cancel-park.test.ts b/services/runner/tests/unit/harness-cancel-park.test.ts new file mode 100644 index 00000000000..8ccc1d8066b --- /dev/null +++ b/services/runner/tests/unit/harness-cancel-park.test.ts @@ -0,0 +1,338 @@ +/** + * Characterization of the Stop-keeps-warm path. + * + * A user Stop must keep the sandbox and the harness session so the next message resumes warm. + * Three rules make that safe, and this file pins all three: + * + * 1. The runner asks the HARNESS to stop and waits for it to confirm (`cancelHarnessTurn`). + * 2. Only a CONFIRMED stop parks (`shouldPark`); an unconfirmed one still destroys. + * 3. The parked reason is on the teardown allowlist, so the sandbox is stopped, not deleted. + */ +import assert from "node:assert/strict"; +import { afterEach, beforeEach, describe, it } from "vitest"; + +import { + cancelHarnessTurn, + DEFAULT_CANCEL_SETTLE_MS, +} from "../../src/engines/sandbox_agent/cancel-turn.ts"; +import { shouldPark } from "../../src/engines/sandbox_agent/engine.ts"; +import { readKeepaliveConfig } from "../../src/engines/sandbox_agent/session-identity.ts"; +import { + isUserStopAbort, + USER_STOP_ABORT_REASON, +} from "../../src/sessions/stop-signal.ts"; +import { createSandboxAgentOtel } from "../../src/tracing/otel.ts"; +import { teardownDisposition } from "../../src/engines/sandbox_agent/teardown.ts"; +import type { AgentRunResult } from "../../src/protocol.ts"; + +const cancelledTurn = (cancelSettled: boolean): AgentRunResult => ({ + ok: true, + output: "partial answer", + stopReason: "cancelled", + cancelSettled, +}); + +/** An abort that is NOT a user Stop: a disconnect, a future call site, anything unlabelled. */ +const abortedSignal = (): AbortSignal => { + const controller = new AbortController(); + controller.abort(); + return controller.signal; +}; + +/** The cooperative user Stop: the heartbeat interrupt labels its abort. */ +const userStopSignal = (): AbortSignal => { + const controller = new AbortController(); + controller.abort(USER_STOP_ABORT_REASON); + return controller.signal; +}; + +const never = (): Promise => new Promise(() => {}); +const noLog = (): void => {}; + +describe("cancelHarnessTurn", () => { + it("sends the cancel and reports settled when the harness answers the prompt", async () => { + const cancelled: string[] = []; + const result = await cancelHarnessTurn({ + sandbox: { + cancelSession: async (id: string) => { + cancelled.push(id); + }, + }, + sessionId: "sess-1", + promptPromise: Promise.resolve({ stopReason: "cancelled" }), + timeoutMs: 5_000, + wait: never, + log: noLog, + }); + + assert.deepEqual(cancelled, ["sess-1"]); + assert.equal(result.requested, true); + assert.equal(result.settled, true); + }); + + it("reports unsettled when the harness never answers inside the budget", async () => { + const result = await cancelHarnessTurn({ + sandbox: { cancelSession: async () => {} }, + sessionId: "sess-1", + promptPromise: never(), + timeoutMs: 5_000, + wait: async () => {}, + log: noLog, + }); + + assert.equal(result.requested, true); + assert.equal(result.settled, false); + }); + + it("reports unsettled when the prompt rejects instead of answering", async () => { + const result = await cancelHarnessTurn({ + sandbox: { cancelSession: async () => {} }, + sessionId: "sess-1", + promptPromise: Promise.reject(new Error("transport closed")), + timeoutMs: 5_000, + wait: never, + log: noLog, + }); + + assert.equal(result.requested, true); + assert.equal(result.settled, false); + }); + + it("reports neither requested nor settled on an unpatched client", async () => { + const result = await cancelHarnessTurn({ + sandbox: {}, + sessionId: "sess-1", + promptPromise: never(), + timeoutMs: 5_000, + wait: never, + log: noLog, + }); + + assert.equal(result.requested, false); + assert.equal(result.settled, false); + }); + + it("reports unsettled when the cancel itself throws", async () => { + const result = await cancelHarnessTurn({ + sandbox: { + cancelSession: async () => { + throw new Error("daemon gone"); + }, + }, + sessionId: "sess-1", + promptPromise: never(), + timeoutMs: 5_000, + wait: never, + log: noLog, + }); + + assert.equal(result.requested, false); + assert.equal(result.settled, false); + }); + + it("bounds a cancel request that never answers", async () => { + const logs: string[] = []; + const result = await cancelHarnessTurn({ + sandbox: { cancelSession: never }, + sessionId: "sess-1", + promptPromise: Promise.resolve({ stopReason: "cancelled" }), + timeoutMs: 5_000, + wait: async () => {}, + log: (message) => logs.push(message), + }); + + assert.deepEqual(result, { settled: false, requested: false, elapsedMs: 0 }); + assert.ok(logs.some((line) => line.includes("reason=request-timeout"))); + }); + + it("keeps a settle budget a user would wait through", () => { + assert.ok(DEFAULT_CANCEL_SETTLE_MS > 0); + assert.ok(DEFAULT_CANCEL_SETTLE_MS <= 30_000); + }); +}); + +describe("the user-Stop abort label", () => { + it("recognizes only the abort that carries the Stop reason", () => { + assert.equal(isUserStopAbort(userStopSignal()), true); + assert.equal(isUserStopAbort(abortedSignal()), false); + assert.equal(isUserStopAbort(undefined), false); + assert.equal(isUserStopAbort(new AbortController().signal), false); + }); + + it("cannot be forged by a look-alike value", () => { + const controller = new AbortController(); + controller.abort({ agentaAbort: "user-stop" }); + assert.equal(isUserStopAbort(controller.signal), false); + }); +}); + +describe("shouldPark on a user Stop", () => { + it("parks a stopped turn whose harness cancel settled", () => { + assert.equal( + shouldPark(cancelledTurn(true), userStopSignal(), undefined), + true, + ); + }); + + it("destroys a stopped turn whose harness cancel timed out", () => { + assert.equal( + shouldPark(cancelledTurn(false), userStopSignal(), undefined), + false, + ); + }); + + it("destroys an UNLABELLED abort even when the cancel settled", () => { + // The guard that keeps a future `controller.abort()` from silently parking a sandbox + // nobody checked. Only the heartbeat interrupt labels its abort. + assert.equal( + shouldPark(cancelledTurn(true), abortedSignal(), undefined), + false, + ); + }); + + it("destroys an aborted turn that never reported a cancel at all", () => { + const runLimitTrip: AgentRunResult = { ok: false, error: "run limit" }; + assert.equal(shouldPark(runLimitTrip, userStopSignal(), undefined), false); + }); + + it("parks a settled Stop even though the client dropped its stream", () => { + // The case the product actually produces. The browser's Stop button aborts the chat stream + // in the same tick it sends the durable cancel command, so a real Stop ALWAYS reaches this + // predicate with the client already gone. This assertion used to read `false`, and reading + // the disconnect first is what deleted the sandbox on every Stop. + assert.equal( + shouldPark(cancelledTurn(true), userStopSignal(), () => true), + true, + ); + }); + + it("keeps destroying on every disconnect that is not a settled Stop", () => { + // A disconnect with no Stop behind it, an unlabelled abort, and an unconfirmed cancel all + // leave a session nobody asked to keep. The rule the disconnect check exists for is intact. + assert.equal( + shouldPark({ ok: true, stopReason: "end_turn" }, undefined, () => true), + false, + ); + assert.equal( + shouldPark(cancelledTurn(true), abortedSignal(), () => true), + false, + ); + assert.equal( + shouldPark(cancelledTurn(false), userStopSignal(), () => true), + false, + ); + }); + + it("leaves every non-abort verdict as it was", () => { + assert.equal( + shouldPark({ ok: true, stopReason: "end_turn" }, undefined, undefined), + true, + ); + assert.equal( + shouldPark({ ok: false, error: "boom" }, undefined, undefined), + false, + ); + assert.equal( + shouldPark({ ok: true, stopReason: "paused" }, undefined, undefined), + false, + ); + }); +}); + +describe("the cancelled teardown reason", () => { + it("stops the sandbox instead of deleting it", () => { + assert.equal(teardownDisposition("cancelled"), "stop"); + }); + + it("still deletes when clean parking is switched off", () => { + assert.equal(teardownDisposition("cancelled", false), "delete"); + }); + + it("leaves a plain abort deleting", () => { + assert.equal(teardownDisposition("aborted"), "delete"); + }); +}); + +describe("the stopped-session park window", () => { + const ttlEnvNames = [ + "AGENTA_RUNNER_SESSION_TTL_MS", + "AGENTA_RUNNER_SESSION_APPROVAL_TTL_MS", + "AGENTA_RUNNER_SESSION_STOPPED_TTL_MS", + "AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS", + ] as const; + let savedTtlEnv: Record; + + beforeEach(() => { + savedTtlEnv = Object.fromEntries( + ttlEnvNames.map((name) => [name, process.env[name]]), + ); + for (const name of ttlEnvNames) delete process.env[name]; + }); + + afterEach(() => { + for (const name of ttlEnvNames) { + const value = savedTtlEnv[name]; + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + }); + + // A settled Stop gets the same ten-minute human-response window on both providers. The + // ordinary idle windows remain shorter and continue to govern clean completed turns. + it("defaults a local stopped session to the approval window", () => { + const config = readKeepaliveConfig("local"); + assert.equal(config.ttlMs, 60_000); + assert.equal(config.stoppedTtlMs, 600_000); + assert.equal(config.approvalTtlMs, 600_000); + }); + + it("defaults a Daytona stopped session to the ten-minute human-response window", () => { + const config = readKeepaliveConfig("daytona"); + assert.equal(config.ttlMs, 120_000); + assert.equal(config.stoppedTtlMs, 600_000); + }); + + it("moves with its own env var, without touching the ordinary idle window", () => { + process.env.AGENTA_RUNNER_SESSION_STOPPED_TTL_MS = "300000"; + const local = readKeepaliveConfig("local"); + const daytona = readKeepaliveConfig("daytona"); + assert.equal(local.stoppedTtlMs, 300_000); + assert.equal(local.ttlMs, 60_000); + assert.equal(daytona.stoppedTtlMs, 300_000); + assert.equal(daytona.ttlMs, 120_000); + }); +}); + +describe("the terminal done record", () => { + /** Finish a runner-traced turn and hand back the terminal `done` event it recorded. */ + const doneRecordFor = (stopReason?: string): Record => { + const run = createSandboxAgentOtel({ + harness: "pi", + model: "openai/x", + emitSpans: false, + }); + run.start({ prompt: "hi" }); + run.finish(stopReason); + const done = run.events().find((event) => event.type === "done"); + assert.ok(done, "the turn must record exactly one terminal done event"); + return done as unknown as Record; + }; + + it("carries the stop reason for a user Stop", () => { + // Without this, a stopped turn is indistinguishable from a completed one in Postgres, so + // neither the frontend nor the release gate can tell a Stop from a finish. + assert.equal(doneRecordFor("cancelled").stopReason, "cancelled"); + }); + + it("still carries a pause, which is what this field originally existed for", () => { + assert.equal(doneRecordFor("paused").stopReason, "paused"); + }); + + it("omits the field for a completed turn and for every harness-reported reason", () => { + // An explicit two-value allowlist, so `end_turn` / `max_tokens` / a future harness string + // cannot start appearing on the terminal record by accident. + assert.equal(doneRecordFor("end_turn").stopReason, undefined); + assert.equal(doneRecordFor("max_tokens").stopReason, undefined); + assert.equal(doneRecordFor(undefined).stopReason, undefined); + }); +}); diff --git a/services/runner/tests/unit/lifecycle-session-coordinator.test.ts b/services/runner/tests/unit/lifecycle-session-coordinator.test.ts index 1bdf3e60912..7b31c041b26 100644 --- a/services/runner/tests/unit/lifecycle-session-coordinator.test.ts +++ b/services/runner/tests/unit/lifecycle-session-coordinator.test.ts @@ -148,7 +148,12 @@ interface FakeEnv { } function makeEngine() { - const calls = { acquire: 0, cold: 0, turns: [] as FakeEnv[] }; + const calls = { + acquire: 0, + cold: 0, + turns: [] as FakeEnv[], + acquiredRequests: [] as AgentRunRequest[], + }; let nextId = 1; const engine: coordinator.KeepaliveEngine = { @@ -164,6 +169,7 @@ function makeEngine() { }, async acquireEnvironment(request) { calls.acquire += 1; + calls.acquiredRequests.push(request); const applied = appliedStateForRequest(request); const env: FakeEnv = { id: nextId++, @@ -258,6 +264,43 @@ describe("the coordinator works when imported directly", () => { assert.equal(calls.acquire, 2); }); + it("rebuilds on custom credential rotation and removal while carrying session history", async () => { + const { engine, calls } = makeEngine(); + const ctx = makeCtx(engine); + const initial = { + ...turn1, + sandboxCredentials: [{ + binding: { kind: "environment" as const, name: "GITHUB_TOKEN" }, + value: "first-secret-value", + }], + }; + + await coordinator.runWithKeepalive(initial, undefined, undefined, ctx); + await coordinator.runWithKeepalive( + { + ...turn2, + sandboxCredentials: [{ + binding: { kind: "environment", name: "GITHUB_TOKEN" }, + value: "second-secret-value", + }], + }, + undefined, + undefined, + ctx, + ); + await coordinator.runWithKeepalive( + { ...turn2, messages: [...turn2.messages!, { role: "user", content: "again" }] }, + undefined, + undefined, + ctx, + ); + + assert.equal(calls.acquire, 3, "rotation and removal each rebuild the environment"); + assert.equal(calls.acquiredRequests[1].sessionId, "s1"); + assert.deepEqual(calls.acquiredRequests[1].messages, turn2.messages); + assert.equal(calls.acquiredRequests[2].messages?.at(-1)?.content, "again"); + }); + it("routes to cold when the request carries no session id", async () => { const { engine, calls } = makeEngine(); const ctx = makeCtx(engine); diff --git a/services/runner/tests/unit/live-frames.test.ts b/services/runner/tests/unit/live-frames.test.ts new file mode 100644 index 00000000000..ac80600979b --- /dev/null +++ b/services/runner/tests/unit/live-frames.test.ts @@ -0,0 +1,223 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it, vi } from "vitest"; + +import { + LiveFramePublisher, + type LiveFrameEnvelope, +} from "../../src/sessions/live-frames.ts"; + +describe("LiveFramePublisher", () => { + afterEach(() => { + vi.useRealTimers(); + delete process.env.AGENTA_RUNNER_LIVE_FRAMES; + }); + + it("assigns a monotonic frame index across projected progress", async () => { + const frames: LiveFrameEnvelope[] = []; + const publisher = new LiveFramePublisher({ + sessionId: "session-1", + executionId: "execution-1", + auth: () => "Secret test", + enabled: true, + now: () => "2026-09-04T00:00:00.000Z", + send: async (batch) => { + frames.push(...batch); + }, + }); + + publisher.emit({ type: "message_start", id: "message-1" }); + publisher.emit({ type: "message_delta", id: "message-1", delta: "hi" }); + publisher.emit({ type: "tool_call", id: "tool-1", name: "read", input: {} }); + publisher.emit({ + type: "tool_call", + id: "tool-1", + name: "read", + input: { path: "README.md" }, + }); + publisher.emit({ type: "tool_result", id: "tool-1", output: "ok" }); + await publisher.whenIdle(); + + assert.deepEqual( + frames.map((frame) => [frame.frame_index, frame.type, frame.entity_id]), + [ + [0, "text-start", "message-1"], + [1, "text-delta", "message-1"], + [2, "tool-input-start", "tool-1"], + [3, "tool-input-available", "tool-1"], + [4, "tool-input-available", "tool-1"], + [5, "tool-output-available", "tool-1"], + ], + ); + assert.deepEqual( + frames.map((frame) => frame.frame_or_event_id), + [ + "execution-1:0", + "execution-1:1", + "execution-1:2", + "execution-1:3", + "execution-1:4", + "execution-1:5", + ], + ); + }); + + it("drops beyond the bounded queue and logs identifiers only", async () => { + let releaseFirst: (() => void) | undefined; + const firstSend = new Promise((resolve) => { + releaseFirst = resolve; + }); + const logs: string[] = []; + let sends = 0; + const publisher = new LiveFramePublisher({ + sessionId: "session-drop", + executionId: "execution-drop", + auth: () => "Secret test", + enabled: true, + capacity: 1, + flushIntervalMs: 0, + batchCapacity: 1, + send: async () => { + sends += 1; + if (sends === 1) await firstSend; + }, + log: (message) => logs.push(message), + }); + + publisher.emit({ type: "message_delta", id: "message-1", delta: "secret-a" }); + publisher.emit({ type: "message_delta", id: "message-1", delta: "secret-b" }); + publisher.emit({ type: "message_delta", id: "message-1", delta: "secret-c" }); + releaseFirst?.(); + await publisher.whenIdle(); + + assert.equal(sends, 2); + assert.equal(publisher.reportDrops(), 1); + assert.deepEqual(logs, [ + "DROPPED session=session-drop execution=execution-drop count=1", + ]); + assert.ok(!logs[0].includes("secret")); + }); + + it("coalesces a 1,000-chunk stream into tens of ordered calls", async () => { + const calls: LiveFrameEnvelope[][] = []; + const publisher = new LiveFramePublisher({ + sessionId: "session-batch", + executionId: "execution-batch", + auth: () => "Secret test", + enabled: true, + send: async (batch) => { + calls.push(batch); + }, + }); + + for (let index = 0; index < 1_000; index += 1) { + publisher.emit({ + type: "message_delta", + id: "message-1", + delta: `chunk-${index}`, + }); + if ((index + 1) % 50 === 0) await Promise.resolve(); + } + await publisher.whenIdle(); + + const frames = calls.flat(); + assert.equal(calls.length, 20); + assert.equal(frames.length, 1_000); + assert.deepEqual( + frames.map((frame) => frame.frame_index), + Array.from({ length: 1_000 }, (_, index) => index), + ); + assert.equal(publisher.reportDrops(), 0); + }); + + it("flushes on the byte bound without reordering envelopes", async () => { + const calls: LiveFrameEnvelope[][] = []; + const publisher = new LiveFramePublisher({ + sessionId: "session-bytes", + executionId: "execution-bytes", + auth: () => "Secret test", + enabled: true, + batchCapacity: 50, + maxBatchBytes: 600, + send: async (batch) => { + calls.push(batch); + }, + }); + + publisher.emit({ + type: "message_delta", + id: "message-1", + delta: "a".repeat(100), + }); + publisher.emit({ + type: "message_delta", + id: "message-1", + delta: "b".repeat(100), + }); + await publisher.whenIdle(); + + assert.equal(calls.length, 2); + assert.deepEqual( + calls.flat().map((frame) => frame.frame_index), + [0, 1], + ); + }); + + it("sends no live frames when the feature flag is off", async () => { + process.env.AGENTA_RUNNER_LIVE_FRAMES = "false"; + let calls = 0; + const publisher = new LiveFramePublisher({ + sessionId: "session-off", + executionId: "execution-off", + auth: () => "Secret test", + send: async () => { + calls += 1; + }, + }); + + publisher.emit({ type: "message_start", id: "message-1" }); + publisher.emit({ type: "message_delta", id: "message-1", delta: "secret" }); + publisher.emit({ type: "message_end", id: "message-1" }); + await publisher.whenIdle(); + + assert.equal(calls, 0); + }); + it("drops a batch whose ingest POST never answers, instead of hanging the turn", async () => { + // `persist.ts` flush() awaits whenIdle(), so an ingest that stalls before response headers + // would hold turn completion open for as long as the socket stayed alive. + const realFetch = globalThis.fetch; + const signals: AbortSignal[] = []; + globalThis.fetch = ((_url: unknown, init?: { signal?: AbortSignal }) => { + const signal = init?.signal; + if (signal) signals.push(signal); + return new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => reject(signal.reason)); + }); + }) as typeof fetch; + const logs: string[] = []; + try { + const publisher = new LiveFramePublisher({ + sessionId: "session-stall", + executionId: "execution-stall", + auth: () => "Secret test", + enabled: true, + postTimeoutMs: 25, + log: (message) => logs.push(message), + }); + + publisher.emit({ type: "message_start", id: "message-1" }); + publisher.emit({ type: "message_delta", id: "message-1", delta: "hi" }); + await publisher.whenIdle(); + + assert.equal(signals.length, 1, "the ingest POST carries an abort signal"); + assert.equal(signals[0].aborted, true, "and the signal fired on the deadline"); + assert.equal( + publisher.reportDrops(), + 2, + "a timed-out batch counts as dropped, like any other send failure", + ); + assert.ok(logs.some((line) => line.startsWith("DROPPED "))); + } finally { + globalThis.fetch = realFetch; + } + }); +}); diff --git a/services/runner/tests/unit/mount-lifecycle.test.ts b/services/runner/tests/unit/mount-lifecycle.test.ts new file mode 100644 index 00000000000..69a590cc31e --- /dev/null +++ b/services/runner/tests/unit/mount-lifecycle.test.ts @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "vitest"; + +import type { AcquireContext } from "../../src/environment/acquire-context.ts"; +import { + mountLocalAgentCwd, + mountLocalDurableCwd, + type MountDeps, +} from "../../src/environment/mount-lifecycle.ts"; + +const credentials = { + endpoint: "http://store", + region: "eu-central-1", + bucket: "bucket", + prefix: "prefix", + accessKey: "access", + secretKey: "secret", +}; + +const depsFor = ( + signal: AbortSignal, + mountStorage: MountDeps["mountStorage"], +): MountDeps => ({ + mountStorage, + signMount: async () => null, + signAgentMount: async () => null, + daytonaPiDir: "/tmp/pi", + signal, +}); + +const contextFor = (cwd: string, commits: string[]): AcquireContext => + ({ + plan: { + acpAgent: "pi", + isDaytona: false, + workspace: { cwd }, + }, + env: { + mountCreds: credentials, + agentMountCreds: credentials, + }, + sessionForMount: "session-1", + artifactId: "artifact-1", + log: () => {}, + beginCwdMount: () => {}, + markCwdDetachConfirmed: () => {}, + commitLocalMount: (kind: string) => commits.push(kind), + }) as unknown as AcquireContext; + +describe("local mount cancellation", () => { + it("commits a durable cwd mount before observing an abort", async () => { + const cwd = mkdtempSync(join(tmpdir(), "agenta-mount-cwd-")); + const controller = new AbortController(); + const commits: string[] = []; + + try { + await assert.rejects( + mountLocalDurableCwd( + contextFor(cwd, commits), + depsFor(controller.signal, async () => { + controller.abort(); + return true; + }), + "initial", + ), + { name: "AbortError" }, + ); + assert.deepEqual(commits, ["cwd"]); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + it("commits an agent mount before its abort is handled", async () => { + const cwd = mkdtempSync(join(tmpdir(), "agenta-mount-agent-")); + const controller = new AbortController(); + const commits: string[] = []; + + try { + const mounted = await mountLocalAgentCwd( + contextFor(cwd, commits), + depsFor(controller.signal, async () => { + controller.abort(); + return true; + }), + ); + + assert.equal(mounted, false); + assert.deepEqual(commits, ["agent"]); + } finally { + rmSync(cwd, { recursive: true, force: true }); + rmSync(`${cwd}-agent`, { recursive: true, force: true }); + } + }); +}); diff --git a/services/runner/tests/unit/otel-agenta-ingest.test.ts b/services/runner/tests/unit/otel-agenta-ingest.test.ts new file mode 100644 index 00000000000..8af056a0ce5 --- /dev/null +++ b/services/runner/tests/unit/otel-agenta-ingest.test.ts @@ -0,0 +1,95 @@ +/** + * `isAgentaIngest` decides whether an OTLP endpoint is THIS deployment's own ingest, and so + * whether the export credential is attached. Say no about our own host and the batch goes out + * unauthenticated: every runner session call comes back 401, with nothing in the message naming + * a hostname. + * + * That is exactly what a local stack hit. A service in bridge mode cannot reach the host through + * `localhost` — the name resolves to its own container — so the SDK rewrites a configured + * `localhost` API URL to `host.docker.internal` (`agenta/sdk/utils/helpers.py`, `parse_url`). The + * endpoint arriving on the run request then spells the host differently from the configured base, + * and a verbatim comparison called the deployment's own ingest somebody else's collector. + * + * The pin is two-sided: the local aliases are interchangeable, and NOTHING else is — a third-party + * collector on the same host, another port, or another path must still be treated as foreign, or + * the credential leaks to it. + * + * Run: pnpm exec vitest run tests/unit/otel-agenta-ingest.test.ts + */ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { isAgentaIngest } from "../../src/tracing/otel.ts"; + +const TRACES = "/otlp/v1/traces"; +const envKeys = ["AGENTA_API_URL", "AGENTA_API_INTERNAL_URL"] as const; +const saved: Partial> = {}; + +beforeEach(() => { + for (const key of envKeys) { + saved[key] = process.env[key]; + delete process.env[key]; + } +}); + +afterEach(() => { + for (const key of envKeys) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } +}); + +describe("isAgentaIngest", () => { + it("recognizes the configured base itself", () => { + process.env.AGENTA_API_URL = "http://localhost/api"; + expect(isAgentaIngest(`http://localhost/api${TRACES}`)).toBe(true); + }); + + it("recognizes the cloud ingest with nothing configured", () => { + expect(isAgentaIngest(`https://cloud.agenta.ai/api${TRACES}`)).toBe(true); + }); + + // The regression: the SDK's bridge-mode rewrite renames the host, and the credential was + // withheld from our own ingest on that basis alone. + it.each([ + "127.0.0.1", + "0.0.0.0", + "host.docker.internal", + "[::1]", + ])("treats %s as the same host as a configured localhost", (alias) => { + process.env.AGENTA_API_URL = "http://localhost/api"; + expect(isAgentaIngest(`http://${alias}/api${TRACES}`)).toBe(true); + }); + + it("folds the alias in the other direction too", () => { + process.env.AGENTA_API_URL = "http://host.docker.internal/api"; + expect(isAgentaIngest(`http://localhost/api${TRACES}`)).toBe(true); + }); + + it("matches against the internal base, not only the public one", () => { + process.env.AGENTA_API_URL = "https://agenta.example.com/api"; + process.env.AGENTA_API_INTERNAL_URL = "http://localhost:8000/api"; + expect(isAgentaIngest(`http://127.0.0.1:8000/api${TRACES}`)).toBe(true); + }); + + // The other side of the fold: aliasing the host name must not alias anything else, or a + // third-party collector sharing the host would be handed the credential. + it("does not match a different port on the same host", () => { + process.env.AGENTA_API_URL = "http://localhost:8000/api"; + expect(isAgentaIngest(`http://localhost:4318/api${TRACES}`)).toBe(false); + }); + + it("does not match a different path on the same host", () => { + process.env.AGENTA_API_URL = "http://localhost/api"; + expect(isAgentaIngest("http://localhost/collector/v1/traces")).toBe(false); + }); + + it("does not match a foreign host", () => { + process.env.AGENTA_API_URL = "http://localhost/api"; + expect(isAgentaIngest(`http://jaeger.internal/api${TRACES}`)).toBe(false); + }); + + it("rejects an unparseable endpoint rather than throwing", () => { + process.env.AGENTA_API_URL = "http://localhost/api"; + expect(isAgentaIngest("not a url")).toBe(false); + }); +}); diff --git a/services/runner/tests/unit/platform-credential-attribution.test.ts b/services/runner/tests/unit/platform-credential-attribution.test.ts index 71d44b31838..76d4c4687dd 100644 --- a/services/runner/tests/unit/platform-credential-attribution.test.ts +++ b/services/runner/tests/unit/platform-credential-attribution.test.ts @@ -290,10 +290,12 @@ describe("bridge-rewritten localhost ingest", () => { assert.match(lines[0]!, /collector\.thirdparty\.example/); }); - it("leaves a 127.0.0.1 base alone, because the api does not rewrite that host", () => { - // `parse_url` rewrites only `localhost` and `0.0.0.0`, so a 127.0.0.1 deployment matches - // itself and never sees the bridge form. Admitting it would widen the allowlist past the - // platform's own rewrite. + it("admits a 127.0.0.1 base under either spelling of the local host", () => { + // `bridgeRewrittenBase` mirrors only `localhost`/`0.0.0.0`, so on its own a 127.0.0.1 base + // matches itself and nothing else. `isAgentaIngest` then folds every local-host spelling + // (#6392), which subsumes that mirror and admits the bridge form here too. The fold is + // host-spelling only: port and path still have to match exactly, which the two cases below + // this one pin. vi.stubEnv("AGENTA_API_URL", "http://127.0.0.1:8480/api"); const { lines, log } = withLog(); @@ -309,6 +311,21 @@ describe("bridge-rewritten localhost ingest", () => { resetPlatformCredentialWarnings(); assert.equal( platformCredentialForRequest(request(BRIDGE_ENDPOINT), log), + CREDENTIAL, + ); + assert.deepEqual(lines, []); + }); + + it("still drops a 127.0.0.1 base's credential for a foreign port", () => { + // The guard the case above relies on: folding the host name must not fold anything else. + vi.stubEnv("AGENTA_API_URL", "http://127.0.0.1:8480/api"); + const { lines, log } = withLog(); + + assert.equal( + platformCredentialForRequest( + request("http://host.docker.internal:9999/api/otlp/v1/traces"), + log, + ), "", ); assert.equal(lines.length, 1); diff --git a/services/runner/tests/unit/reap-exec.test.ts b/services/runner/tests/unit/reap-exec.test.ts new file mode 100644 index 00000000000..6f190aa0145 --- /dev/null +++ b/services/runner/tests/unit/reap-exec.test.ts @@ -0,0 +1,389 @@ +/** + * The Codex Stop leaves its shell child running; this pins the reap that kills it. + * + * The rules that matter are the two that keep a warm session warm: the `codex app-server` process + * itself is never a candidate, and neither is anything OLDER than the turn that was stopped (an + * stdio MCP server starts with the session, so it always is). Everything else is bookkeeping. + */ +import { describe, expect, it, vi } from "vitest"; + +import { + MAX_REAPED, + findAppServerPid, + findSandboxAgentServerPid, + parseProcessTable, + reapLeakedExecChildren, + reapResultHasCleanupMiss, + selectLeakedExecPids, +} from "../../src/engines/sandbox_agent/reap-exec.ts"; +import { + DAYTONA_SANDBOX_AGENT_PORT, + sandboxAgentServerPort, +} from "../../src/engines/sandbox_agent/provider.ts"; + +const LIVE_PORT = 43_123; + +describe("reapResultHasCleanupMiss", () => { + it("flags failed and unknown cleanup for QA", () => { + expect(reapResultHasCleanupMiss({ killed: 1 })).toBe(false); + expect( + reapResultHasCleanupMiss({ killed: 0, skipped: "nothing-to-reap" }), + ).toBe(false); + expect(reapResultHasCleanupMiss({ killed: 0, skipped: "ps-failed" })).toBe( + true, + ); + expect(reapResultHasCleanupMiss(undefined)).toBe(true); + }); +}); + +/** The real tree, copied from the live probe on the integration stack (2026-09-03). */ +const LIVE_PS = [ + " 1 0 50000 /sbin/docker-init -- docker-entrypoint.sh sh -c node scripts/build-extension.mjs", + " 7 1 49999 node node_modules/.bin/../tsx/dist/cli.mjs watch src/server.ts", + " 58 7 49998 /usr/local/bin/node --require /app/node_modules/.pnpm/tsx@4.19.2/preflight.cjs src/server.ts", + `67965 58 120 /app/node_modules/.pnpm/@sandbox-agent+cli-linux-x64@0.4.2/bin/sandbox-agent server --host 127.0.0.1 --port ${LIVE_PORT}`, + "68015 67965 118 node /root/.local/share/sandbox-agent/bin/agent_processes/codex/node_modules/.bin/codex-acp", + "68022 68015 117 /usr/local/bin/node /root/.local/share/sandbox-agent/bin/agent_processes/codex/node_modules/@openai/codex/bin/codex.js app-server", + "68029 68022 116 /root/.local/share/sandbox-agent/bin/agent_processes/codex/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/bin/codex app-server", + "68164 68029 12 python3 -c import time; time.sleep(300.925793)", +].join("\n"); + +describe("sandboxAgentServerPort", () => { + it("reads the allocated port from a local sandbox handle id", () => { + expect(sandboxAgentServerPort(`local/127.0.0.1:${LIVE_PORT}`)).toBe( + LIVE_PORT, + ); + }); + + it("returns the explicit port configured for Daytona", () => { + expect(sandboxAgentServerPort("daytona/sandbox-1")).toBe( + DAYTONA_SANDBOX_AGENT_PORT, + ); + }); +}); + +describe("parseProcessTable", () => { + it("reads pid, ppid, elapsed seconds and the full argv", () => { + const rows = parseProcessTable(LIVE_PS); + expect(rows).toHaveLength(8); + expect(rows.at(-1)).toEqual({ + pid: 68164, + ppid: 68029, + etimes: 12, + args: "python3 -c import time; time.sleep(300.925793)", + }); + }); + + it("drops a line it cannot read rather than guessing at it", () => { + expect(parseProcessTable("PID PPID ELAPSED COMMAND\nnonsense\n")).toEqual( + [], + ); + }); +}); + +describe("findSandboxAgentServerPid", () => { + it("matches the exact --port value, not another port with the same prefix", () => { + const rows = parseProcessTable( + [ + " 10 1 5 /x/bin/sandbox-agent server --port 4312", + " 11 1 5 /x/bin/sandbox-agent server --port 43123", + ].join("\n"), + ); + expect(findSandboxAgentServerPid(rows, 4312)).toBe(10); + }); +}); + +describe("findAppServerPid", () => { + it("finds the Rust core and not the JavaScript launcher that shares its subcommand", () => { + expect(findAppServerPid(parseProcessTable(LIVE_PS), 67965)).toBe(68029); + }); + + it("answers undefined when nothing matches", () => { + const rows = parseProcessTable(" 10 1 5 node server.js"); + expect(findAppServerPid(rows, 1)).toBeUndefined(); + }); + + it("answers undefined when TWO descendants match, rather than picking one", () => { + const rows = parseProcessTable( + [ + " 10 1 5 /x/bin/sandbox-agent server --port 4312", + " 11 10 5 /a/bin/codex app-server", + " 12 10 5 /b/bin/codex app-server", + ].join("\n"), + ); + expect(findAppServerPid(rows, 10)).toBeUndefined(); + }); +}); + +describe("selectLeakedExecPids", () => { + const rows = parseProcessTable(LIVE_PS); + + it("selects the leaked shell child", () => { + expect( + selectLeakedExecPids(rows, { + appServerPid: 68029, + turnElapsedSeconds: 20, + }), + ).toEqual([68164]); + }); + + it("never selects the app-server itself, nor any of its ancestors", () => { + const selected = selectLeakedExecPids(rows, { + appServerPid: 68029, + turnElapsedSeconds: 100000, + }); + for (const pid of [1, 7, 58, 67965, 68015, 68022, 68029]) { + expect(selected).not.toContain(pid); + } + }); + + it("leaves a process the SESSION started alone: an stdio MCP server outlives the turn", () => { + const withMcp = parseProcessTable( + [LIVE_PS, "68100 68029 90 node /app/mcp/stdio-server.js"].join("\n"), + ); + const selected = selectLeakedExecPids(withMcp, { + appServerPid: 68029, + turnElapsedSeconds: 20, + }); + expect(selected).toEqual([68164]); + expect(selected).not.toContain(68100); + }); + + it("keeps a child born in the same whole second as the prompt", () => { + const rows2 = parseProcessTable( + [ + "68029 68022 116 /x/bin/codex app-server", + "68164 68029 20 sleep 300", + ].join("\n"), + ); + expect( + selectLeakedExecPids(rows2, { + appServerPid: 68029, + turnElapsedSeconds: 20, + }), + ).toEqual([68164]); + }); + + it("follows the tree, so a shell that forked its own child loses both", () => { + const rows2 = parseProcessTable( + [ + "68029 68022 116 /x/bin/codex app-server", + "68164 68029 12 /bin/bash -c sleep 300", + "68165 68164 12 sleep 300", + ].join("\n"), + ); + expect( + selectLeakedExecPids(rows2, { + appServerPid: 68029, + turnElapsedSeconds: 20, + }), + ).toEqual([68164, 68165]); + }); +}); + +describe("reapLeakedExecChildren", () => { + function sandboxWith(stdout: string) { + const calls: Array<{ command: string; args?: string[] }> = []; + return { + calls, + sandbox: { + runProcess: vi.fn( + async (request: { command: string; args?: string[] }) => { + calls.push(request); + return { + stdout: request.command === "ps" ? stdout : "", + exitCode: 0, + }; + }, + ), + }, + }; + } + + it("rounds the turn's age DOWN, so a session helper a hair older survives", async () => { + // The `git fetch` Codex runs to sync its plugins starts about a second before the prompt on + // a cold turn. At 28.9 s of turn, a 29 s-old helper must not be a candidate. + const { sandbox, calls } = sandboxWith( + [ + `67965 58 120 /x/bin/sandbox-agent server --port ${LIVE_PORT}`, + "68029 67965 116 /x/bin/codex app-server", + "68100 68029 29 git -C /w/.codex/.tmp/plugins-clone fetch --depth 1", + "68164 68029 22 sleep 300", + ].join("\n"), + ); + const result = await reapLeakedExecChildren({ + sandbox, + sandboxAgentPort: LIVE_PORT, + turnElapsedMs: 28_900, + log: vi.fn(), + }); + expect(result).toEqual({ killed: 1 }); + expect(calls[1]).toMatchObject({ command: "kill", args: ["-9", "68164"] }); + }); + + it("lists, then kills exactly the leaked pid", async () => { + const { sandbox, calls } = sandboxWith(LIVE_PS); + const log = vi.fn(); + const result = await reapLeakedExecChildren({ + sandbox, + sandboxAgentPort: LIVE_PORT, + turnElapsedMs: 20_000, + log, + }); + expect(result).toEqual({ killed: 1 }); + expect(calls[0].command).toBe("ps"); + expect(calls[1]).toMatchObject({ command: "kill", args: ["-9", "68164"] }); + expect(log).toHaveBeenCalledWith(expect.stringContaining("killed=1")); + expect(log).toHaveBeenCalledWith(expect.stringContaining("pids=68164")); + }); + + it("reaps only the stopped turn beneath the daemon on this sandbox's port", async () => { + const rows = [ + "100 1 120 /x/bin/sandbox-agent server --host 127.0.0.1 --port 41001", + "110 100 119 node /x/codex-acp", + "120 110 118 /x/bin/codex app-server", + "130 120 10 sleep 300", + "200 1 120 /x/bin/sandbox-agent server --host 127.0.0.1 --port 41002", + "210 200 119 node /x/codex-acp", + "220 210 118 /x/bin/codex app-server", + "230 220 10 sleep 300", + ].join("\n"); + const { sandbox, calls } = sandboxWith(rows); + const result = await reapLeakedExecChildren({ + sandbox, + sandboxAgentPort: 41002, + turnElapsedMs: 20_000, + log: vi.fn(), + }); + expect(result).toEqual({ killed: 1 }); + expect(calls[1]).toMatchObject({ command: "kill", args: ["-9", "230"] }); + }); + + it("kills nothing, and says why, when the sandbox has no one-off process API", async () => { + const log = vi.fn(); + expect( + await reapLeakedExecChildren({ + sandbox: {}, + sandboxAgentPort: LIVE_PORT, + turnElapsedMs: 1, + log, + }), + ).toEqual({ + killed: 0, + skipped: "no-run-process", + }); + }); + + it("gives up quietly when `ps` is missing or speaks a different dialect", async () => { + const log = vi.fn(); + const sandbox = { + runProcess: vi.fn(async () => { + throw new Error("ps: unrecognized option -eo"); + }), + }; + expect( + await reapLeakedExecChildren({ + sandbox, + sandboxAgentPort: LIVE_PORT, + turnElapsedMs: 1, + log, + }), + ).toEqual({ killed: 0, skipped: "ps-failed" }); + expect(log).toHaveBeenCalledWith( + expect.stringContaining("skipped=ps-failed"), + ); + }); + + it("kills nothing when the app-server cannot be identified", async () => { + const { sandbox } = sandboxWith(" 10 1 5 node other.js"); + expect( + await reapLeakedExecChildren({ + sandbox, + sandboxAgentPort: LIVE_PORT, + turnElapsedMs: 1, + log: vi.fn(), + }), + ).toEqual({ killed: 0, skipped: "no-app-server" }); + }); + + it("kills nothing when the harness already cleaned up after itself", async () => { + const { sandbox, calls } = sandboxWith( + [ + `67965 58 120 /x/bin/sandbox-agent server --port ${LIVE_PORT}`, + "68029 67965 116 /x/bin/codex app-server", + ].join("\n"), + ); + expect( + await reapLeakedExecChildren({ + sandbox, + sandboxAgentPort: LIVE_PORT, + turnElapsedMs: 1, + log: vi.fn(), + }), + ).toEqual({ killed: 0, skipped: "nothing-to-reap" }); + expect(calls).toHaveLength(1); + }); + + it("refuses to fire when the candidate set is implausibly large", async () => { + const rows = [ + `67965 58 120 /x/bin/sandbox-agent server --port ${LIVE_PORT}`, + "68029 67965 116 /x/bin/codex app-server", + ]; + for (let i = 0; i <= MAX_REAPED; i += 1) { + rows.push(`${70000 + i} 68029 1 worker-${i}`); + } + const { sandbox, calls } = sandboxWith(rows.join("\n")); + expect( + await reapLeakedExecChildren({ + sandbox, + sandboxAgentPort: LIVE_PORT, + turnElapsedMs: 5_000, + log: vi.fn(), + }), + ).toEqual({ killed: 0, skipped: "too-many" }); + expect(calls).toHaveLength(1); + }); + + it("reports a failed kill instead of claiming the leak is gone", async () => { + let seen = 0; + const sandbox = { + runProcess: vi.fn(async () => { + seen += 1; + if (seen === 1) return { stdout: LIVE_PS, exitCode: 0 }; + throw new Error("kill: permission denied"); + }), + }; + expect( + await reapLeakedExecChildren({ + sandbox, + sandboxAgentPort: LIVE_PORT, + turnElapsedMs: 20_000, + log: vi.fn(), + }), + ).toEqual({ killed: 0, skipped: "kill-failed" }); + }); + + it("reports a non-zero kill exit instead of claiming the leak is gone", async () => { + let seen = 0; + const log = vi.fn(); + const sandbox = { + runProcess: vi.fn(async () => { + seen += 1; + return seen === 1 + ? { stdout: LIVE_PS, exitCode: 0 } + : { stdout: "", exitCode: 1 }; + }), + }; + expect( + await reapLeakedExecChildren({ + sandbox, + sandboxAgentPort: LIVE_PORT, + turnElapsedMs: 20_000, + log, + }), + ).toEqual({ killed: 0, skipped: "kill-failed" }); + expect(log).toHaveBeenCalledWith( + expect.stringContaining("kill exited with status 1"), + ); + }); +}); diff --git a/services/runner/tests/unit/redaction-sinks.test.ts b/services/runner/tests/unit/redaction-sinks.test.ts index 11571c10fec..6377226549a 100644 --- a/services/runner/tests/unit/redaction-sinks.test.ts +++ b/services/runner/tests/unit/redaction-sinks.test.ts @@ -15,6 +15,8 @@ import { ExportResultCode, type ExportResult } from "@opentelemetry/core"; import type { ReadableSpan, SpanExporter } from "@opentelemetry/sdk-trace-base"; import { + curatedEnvSecretValues, + modelEnvironmentSecretValues, Redactor, sandboxVisibleSecretValues, seedForRun, @@ -138,6 +140,64 @@ describe("seedForRun (WP1.1 — the deny-set source)", () => { ).not.toContain(PER_RUN_KEY); }); + it("keeps approved public model bindings readable and unknown legacy bindings fail-safe", () => { + expect( + modelEnvironmentSecretValues({ + AWS_REGION: "eu-west-1", + GOOGLE_CLOUD_PROJECT: "plain-project-name", + LEGACY_GATEWAY_AUTH: "legacy-secret-value", + }), + ).toEqual(["legacy-secret-value"]); + }); + + it("keeps public model configuration and credential locator paths readable", () => { + const adcPath = "/run/secrets/service-account"; + const serviceAccountJson = + '{"private_key":"fake-private-key-DO-NOT-USE"}'; + const redactor = seedForRun({ + modelConnection: { + environment: { + AWS_REGION: "eu-west-1", + GOOGLE_CLOUD_PROJECT: "plain-project-name", + }, + credentials: [ + { + binding: { + kind: "environment", + name: "GOOGLE_APPLICATION_CREDENTIALS", + }, + value: adcPath, + usage: "local_use", + }, + { + binding: { + kind: "environment", + name: "GOOGLE_APPLICATION_CREDENTIALS", + }, + value: serviceAccountJson, + usage: "local_use", + }, + ], + }, + }); + + const ordinary = `region=eu-west-1 project=plain-project-name credentials=${adcPath}`; + expect(redactor.redactString(ordinary, "test")).toBe(ordinary); + expect( + redactor.redactString(`credentials=${serviceAccountJson}`, "test"), + ).not.toContain(serviceAccountJson); + }); + + it("does not infer that GOOGLE_APPLICATION_CREDENTIALS paths are secret values", () => { + const adcPath = "/run/secrets/service-account"; + vi.stubEnv("GOOGLE_APPLICATION_CREDENTIALS", adcPath); + vi.stubEnv("OPENAI_API_KEY", PER_RUN_KEY); + + const values = curatedEnvSecretValues(); + expect(values).not.toContain(adcPath); + expect(values).toContain(PER_RUN_KEY); + }); + it("also seeds the run credential from the OTLP auth header", () => { const redactor = seedForRun(runRequest); expect( @@ -266,22 +326,46 @@ describe("persisted transcript sink (WP1.4)", () => { expect(JSON.stringify(postedBodies[0])).not.toContain(PER_RUN_KEY); }); - it("leaves ordinary user content untouched (we redact leaks, not conversation)", async () => { + it("leaves ordinary user content and public model configuration untouched", async () => { + const adcPath = "/run/secrets/service-account"; + const publicRegion = "eu-west-1"; + const redactor = seedForRun({ + ...runRequest, + modelConnection: { + ...runRequest.modelConnection, + environment: { AWS_REGION: publicRegion }, + credentials: [ + ...runRequest.modelConnection.credentials, + { + binding: { + kind: "environment", + name: "GOOGLE_APPLICATION_CREDENTIALS", + }, + value: adcPath, + usage: "local_use", + }, + ], + }, + }); const { emit, flush } = buildPersistingEmitter( "sess-redact-content", () => RUN_CREDENTIAL, undefined, - seedForRun(runRequest), + redactor, ); - // A deliberately-pasted key-SHAPED string that is not a live secret must survive: the - // known-value pass has zero false positives by construction. + // A deliberately-pasted key-shaped string that is not a live secret must survive too. const userPasted = "sk-user-pasted-this-on-purpose-000"; - emit({ type: "message", text: `here is my sample ${userPasted}` }); + emit({ + type: "message", + text: `sample=${userPasted} region=${publicRegion} credentials=${adcPath}`, + }); await flush(); const persisted = JSON.stringify(postedBodies[0]); expect(persisted).toContain(userPasted); + expect(persisted).toContain(publicRegion); + expect(persisted).toContain(adcPath); expect(persisted).not.toContain("[ag:redacted"); }); }); diff --git a/services/runner/tests/unit/sandbox-agent-acp-fetch.test.ts b/services/runner/tests/unit/sandbox-agent-acp-fetch.test.ts index 56a53cd3b02..8b58a885f95 100644 --- a/services/runner/tests/unit/sandbox-agent-acp-fetch.test.ts +++ b/services/runner/tests/unit/sandbox-agent-acp-fetch.test.ts @@ -15,6 +15,7 @@ import assert from "node:assert/strict"; import { createAcpDispatcher, createAcpFetch, + withSandboxGoneReport, } from "../../src/engines/sandbox_agent/acp-fetch.ts"; const envKeys = [ @@ -79,3 +80,49 @@ describe("createAcpFetch", () => { assert.equal(typeof acpFetch, "function"); }); }); + +/** + * The turn's own socket is the first thing to learn that a remote sandbox was deleted: Daytona + * answers `404 SANDBOX_NOT_FOUND` from its proxy while the ACP transport swallows the failure and + * the pending prompt never settles. This wrapper is how that death reaches the liveness probe. + */ +describe("withSandboxGoneReport", () => { + const goneResponse = () => + new Response("not found: sandbox a476c238 not found", { + status: 404, + headers: { "x-daytona-error-code": "SANDBOX_NOT_FOUND" }, + }); + + it("reports a provider answer that names the sandbox as gone", async () => { + const reasons: string[] = []; + const wrapped = withSandboxGoneReport( + (async () => goneResponse()) as unknown as typeof fetch, + { onSandboxGone: (reason) => reasons.push(reason) }, + ); + + const response = await wrapped("http://sandbox/v1/acp/session"); + + assert.equal(reasons.length, 1); + assert.ok(reasons[0].includes("SANDBOX_NOT_FOUND")); + // The body must still be readable by the ACP client that asked for it. + assert.ok((await response.text()).includes("a476c238")); + }); + + it("reports nothing for an ordinary answer", async () => { + const reasons: string[] = []; + const wrapped = withSandboxGoneReport( + (async () => + new Response("{}", { status: 200 })) as unknown as typeof fetch, + { onSandboxGone: (reason) => reasons.push(reason) }, + ); + + await wrapped("http://sandbox/v1/acp/session"); + + assert.equal(reasons.length, 0); + }); + + it("is the identity when no reporter is wired", () => { + const inner = (async () => new Response("{}")) as unknown as typeof fetch; + assert.equal(withSandboxGoneReport(inner), inner); + }); +}); diff --git a/services/runner/tests/unit/sandbox-agent-mount.test.ts b/services/runner/tests/unit/sandbox-agent-mount.test.ts index 49bb094b72c..7eaccf7b900 100644 --- a/services/runner/tests/unit/sandbox-agent-mount.test.ts +++ b/services/runner/tests/unit/sandbox-agent-mount.test.ts @@ -245,6 +245,58 @@ function notMountedThenAlive(): (cwd: string) => Promise { } describe("mountStorage", () => { + it("cancels a slow local mount promptly and stops a geesefs handle that arrives late", async () => { + const controller = new AbortController(); + let mountStarted!: () => void; + const started = new Promise((resolve) => { + mountStarted = resolve; + }); + let finishMount!: (attempt: { stop: () => Promise }) => void; + const lateMount = new Promise<{ stop: () => Promise }>((resolve) => { + finishMount = resolve; + }); + let stopped = 0; + const mount = mountStorage("/work/cwd", CREDS, { + signal: controller.signal, + checkMounted: async () => false, + runGeesefs: async () => { + mountStarted(); + return lateMount; + }, + unmountDeps: { + runUnmount: async () => {}, + checkMountpoint: async () => "gone", + }, + log: SILENT, + }); + + await started; + controller.abort(); + await assert.rejects( + () => + Promise.race([ + mount, + new Promise((_, reject) => + setTimeout( + () => reject(new Error("local mount did not cancel")), + 4_000, + ), + ), + ]), + /acquisition was aborted/, + ); + + finishMount({ + stop: async () => { + stopped += 1; + }, + }); + for (let i = 0; i < 10 && stopped === 0; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + assert.equal(stopped, 1, "the late geesefs process is stopped"); + }); + it("builds the geesefs command with creds in env, not argv", async () => { let seenArgs: string[] = []; let seenEnv: Record = {}; @@ -502,6 +554,87 @@ describe("discoverTunnelEndpoint (remote)", () => { }); describe("mountStorageRemote", () => { + it("cancels a slow Daytona mount command promptly", async () => { + const controller = new AbortController(); + let mountStarted!: () => void; + const started = new Promise((resolve) => { + mountStarted = resolve; + }); + const sandbox = { + runProcess: async (opts: { args?: string[] }) => { + if ((opts.args?.[1] ?? "").includes("geesefs --log-file")) { + mountStarted(); + return new Promise<{ exitCode: number }>(() => {}); + } + return { exitCode: 0 }; + }, + }; + const mount = mountStorageRemote(sandbox, "/home/sandbox/work", CREDS, { + endpoint: "https://abc.ngrok.io", + signal: controller.signal, + log: SILENT, + }); + + await started; + controller.abort(); + await assert.rejects( + () => + Promise.race([ + mount, + new Promise((_, reject) => + setTimeout( + () => reject(new Error("remote mount did not cancel")), + 4_000, + ), + ), + ]), + /acquisition was aborted/, + ); + }); + + it("detaches a remote mount that completes after cancellation", async () => { + const controller = new AbortController(); + const unmountCalls: string[] = []; + let finishMount!: (value: { exitCode: number }) => void; + const mountFinished = new Promise<{ exitCode: number }>((resolve) => { + finishMount = resolve; + }); + let mountStarted!: () => void; + const started = new Promise((resolve) => { + mountStarted = resolve; + }); + const sandbox = { + runProcess: async (opts: { command: string; args?: string[] }) => { + const command = opts.args?.[1] ?? ""; + if (command.includes("geesefs --log-file")) { + mountStarted(); + return mountFinished; + } + if (command.includes("fusermount") || command.includes("umount")) { + unmountCalls.push(command); + } + return { exitCode: 0 }; + }, + }; + const mount = mountStorageRemote(sandbox, "/home/sandbox/work", CREDS, { + endpoint: "https://abc.ngrok.io", + signal: controller.signal, + log: SILENT, + }); + + await started; + controller.abort(); + await assert.rejects(mount, /acquisition was aborted/); + finishMount({ exitCode: 0 }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal( + unmountCalls.length, + 3, + "cleans before mounting, on cancellation, and after the mount completes", + ); + }); + it("detaches an existing mount before starting geesefs", async () => { const commands: string[] = []; const sandbox = { diff --git a/services/runner/tests/unit/sandbox-agent-orchestration.test.ts b/services/runner/tests/unit/sandbox-agent-orchestration.test.ts index 03c3c11103b..75245aceb74 100644 --- a/services/runner/tests/unit/sandbox-agent-orchestration.test.ts +++ b/services/runner/tests/unit/sandbox-agent-orchestration.test.ts @@ -36,20 +36,31 @@ import { shouldSuppressPausedToolCallUpdate, } from "../../src/engines/sandbox_agent/runtime-policy.ts"; import { mountStorage } from "../../src/engines/sandbox_agent/mount.ts"; +import { withSandboxGoneReport } from "../../src/engines/sandbox_agent/acp-fetch.ts"; +import { SANDBOX_GONE_MESSAGE } from "../../src/engines/sandbox_agent/errors.ts"; import { buildPiGateEnvelope } from "../../src/engines/sandbox_agent/pi-gate-envelope.ts"; import { appendPlatformGuidance } from "../../src/engines/sandbox_agent/system-prompt-appendix.ts"; import { platformGuidanceAppendix } from "../../src/engines/sandbox_agent/platform-guidance.ts"; import type { PermissionDecision } from "../../src/responder.ts"; import { + acquireEnvironment, + runTurn, runSandboxAgent, type SandboxAgentDeps, } from "../../src/engines/sandbox_agent.ts"; import { resetRunnerConfigCache } from "../../src/config/runner-config.ts"; +import { USER_STOP_ABORT_REASON } from "../../src/sessions/stop-signal.ts"; import { fakeHarness, flushPromises, type FakeOptions, } from "../utils/sandbox-agent-harness.ts"; +import { + findExecution, + registerExecution, + resetExecutionsForTest, +} from "../../src/sessions/execution-registry.ts"; +import { applyCommand } from "../../src/sessions/control-channel.ts"; // Orchestration cases include Daytona runs: enable it (with a provisioning credential) on top of // the hermetic scrub, then drop the memoized config so the run plan reads the enabled set. @@ -60,6 +71,7 @@ beforeEach(() => { }); afterEach(() => { + resetExecutionsForTest(); vi.unstubAllGlobals(); }); @@ -96,6 +108,79 @@ describe("PendingApprovalPauseController", () => { }); describe("runSandboxAgent orchestration", () => { + for (const providerName of ["local", "daytona"] as const) { + it(`a Stop preempts slow ${providerName} acquisition and cleans a late sandbox`, async () => { + const { deps } = fakeHarness(); + const delegateStart = deps.startSandboxAgent!; + let releaseCreate!: (sandboxId: string) => void; + const slowCreate = new Promise((resolve) => { + releaseCreate = resolve; + }); + let markCreateStarted!: () => void; + const createStarted = new Promise((resolve) => { + markCreateStarted = resolve; + }); + let markCleaned!: () => void; + const cleaned = new Promise((resolve) => { + markCleaned = resolve; + }); + let destroys = 0; + deps.buildSandboxProvider = (() => ({ + name: providerName, + create: () => { + markCreateStarted(); + return slowCreate; + }, + async destroy() { + destroys += 1; + markCleaned(); + }, + async getUrl() { + return "http://sandbox.invalid"; + }, + })) as any; + deps.startSandboxAgent = (async (options: any) => { + await options.sandbox.create(); + return delegateStart(options); + }) as any; + + const controller = new AbortController(); + const acquire = acquireEnvironment( + { + harness: "claude", + sandbox: providerName, + messages: [{ role: "user", content: "start slowly" }], + }, + deps, + controller.signal, + ); + await createStarted; + controller.abort(USER_STOP_ABORT_REASON); + + const result = await Promise.race([ + acquire, + new Promise((_resolve, reject) => + setTimeout( + () => reject(new Error("Stop exceeded the delivery timeout")), + 4_000, + ), + ), + ]); + assert.equal(result.ok, false); + if (result.ok) return; + assert.match(result.error, /acquisition was aborted/); + + releaseCreate(`${providerName}-late-id`); + await Promise.race([ + cleaned, + new Promise((_resolve, reject) => + setTimeout(() => reject(new Error("late sandbox leaked")), 4_000), + ), + ]); + assert.equal(destroys, 1); + }); + } + // NOTE: in-band redaction of the LIVE event stream / result / trace-start input was a // daytona-secret-materialization concept that was not adopted. Redaction happens at the // durable/exported sinks (persisted transcript + exported spans; see redaction-sinks.test.ts), @@ -146,6 +231,74 @@ describe("runSandboxAgent orchestration", () => { assert.equal(calls.workspaceCleanup, 1); }); + it("replays rebuilt history after an evicted local Pi load cannot verify native turns", async () => { + const request: AgentRunRequest = { + harness: "pi_core", + sandbox: "local", + messages: [ + { role: "user", content: "Remember the codeword KIWI-9" }, + { role: "assistant", content: "I will remember it." }, + { role: "user", content: "What was the codeword?" }, + ], + }; + const { calls, deps } = fakeHarness(); + const acquired = await acquireEnvironment(request, deps); + assert.equal(acquired.ok, true); + if (!acquired.ok) return; + + try { + const result = await runTurn( + acquired.env, + request, + undefined, + undefined, + { loaded: true, nativeHistoryVerified: false }, + ); + + assert.equal(result.ok, true); + const prompt = calls.promptBlocks?.[0]?.text ?? ""; + assert.match(prompt, /^Conversation so far:/); + assert.match(prompt, /KIWI-9/); + assert.match(prompt, /The user now says:\nWhat was the codeword\?$/); + } finally { + await acquired.env.destroy(); + } + }); + + it("keeps the last-message-only path for a verified Daytona native load", async () => { + const request: AgentRunRequest = { + harness: "pi_core", + sandbox: "daytona", + messages: [ + { role: "user", content: "Remember the codeword KIWI-9" }, + { role: "assistant", content: "I will remember it." }, + { role: "user", content: "What was the codeword?" }, + ], + }; + const { calls, deps } = fakeHarness(); + deps.prepareDaytonaPiAssets = (async () => true) as any; + const acquired = await acquireEnvironment(request, deps); + assert.equal(acquired.ok, true); + if (!acquired.ok) return; + + try { + const result = await runTurn( + acquired.env, + request, + undefined, + undefined, + { loaded: true, nativeHistoryVerified: true }, + ); + + assert.equal(result.ok, true); + assert.deepEqual(calls.promptBlocks, [ + { type: "text", text: "What was the codeword?" }, + ]); + } finally { + await acquired.env.destroy(); + } + }); + it("passes the live turn credential provider to the trace exporter", async () => { const { calls, deps } = fakeHarness(); let authorization = "Secret initial"; @@ -615,6 +768,48 @@ describe("runSandboxAgent orchestration", () => { rmSync(cwd, { recursive: true, force: true }); }); + it("backs the local Pi transcript directory with the active durable cwd mount", async () => { + const { calls, deps } = fakeHarness(); + deps.signSessionMountCredentials = async () => ({ + region: "us-east-1", + bucket: "bucket", + prefix: "mounts/project/session", + accessKey: "test-access-key", + secretKey: "test-secret-key", + projectId: "project", + }); + deps.mountStorage = async () => true; + deps.unmountStorage = async () => true; + deps.hydrateHarnessSessionFromDurable = async () => {}; + + const request: AgentRunRequest = { + harness: "pi_core", + sandbox: "local", + sessionId: "session-local-rebuild", + runContext: { project: { id: "project" } }, + telemetry: { + exporters: { + otlp: { headers: { authorization: "ApiKey test" } }, + }, + }, + messages: [{ role: "user", content: "continue" }], + }; + const acquired = await acquireEnvironment(request, deps); + assert.equal(acquired.ok, true); + if (!acquired.ok) return; + + try { + assert.equal(acquired.env.nativeHistoryDurable, true); + assert.equal( + (calls.providerArgs[1] as Record) + .PI_CODING_AGENT_SESSION_DIR, + "/tmp/agenta/mounts/project/session/agents/sessions/pi", + ); + } finally { + await acquired.env.destroy(); + } + }); + it("creates the configured Pi transcript directory inside a Daytona cwd", async () => { const { calls, deps } = fakeHarness(); deps.prepareDaytonaPiAssets = (async () => true) as any; @@ -1587,6 +1782,18 @@ describe("runSandboxAgent orchestration", () => { data: { phase: "environment_starting" }, transient: true, }, + { + type: "data", + name: "agent-status", + data: { phase: "preparing_workspace" }, + transient: true, + }, + { + type: "data", + name: "agent-status", + data: { phase: "opening_session" }, + transient: true, + }, { type: "data", name: "agent-status", @@ -2402,6 +2609,118 @@ describe("runSandboxAgent default ApprovalResponder wiring", () => { assert.deepEqual(calls.permissionReplies, []); }); + it("marks a paused turn settled after its cancellable teardown window", async () => { + const { deps } = depsWithDefaultResponder(); + const sessionId = "conv-paused-registry"; + const turnId = "turn-paused-registry"; + registerExecution({ + projectId: "11111111-1111-4111-8111-111111111111", + sessionId, + turnId, + startedAt: Date.now(), + abort: () => {}, + }); + + const result = await runSandboxAgent( + { + harness: "claude", + sessionId, + turnId, + permissions: { default: "ask" }, + messages: [{ role: "user", content: "edit the file" }], + }, + undefined, + undefined, + deps, + ); + + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.stopReason, "paused"); + assert.equal( + findExecution("11111111-1111-4111-8111-111111111111", sessionId)?.settled, + true, + ); + }); + + it("converts a Stop during pause teardown into the turn's cancelled outcome", async () => { + let markPauseTeardownStarted!: () => void; + const pauseTeardownStarted = new Promise((resolve) => { + markPauseTeardownStarted = resolve; + }); + let releasePauseTeardown!: () => void; + const pauseTeardownMayFinish = new Promise((resolve) => { + releasePauseTeardown = resolve; + }); + const { deps } = fakeHarness({ + emitPermission: true, + hangPrompt: true, + afterDestroySession: async () => { + markPauseTeardownStarted(); + await pauseTeardownMayFinish; + }, + }); + delete deps.responderFactory; + const startSandboxAgent = deps.startSandboxAgent!; + deps.startSandboxAgent = async (options) => { + const sandbox = await startSandboxAgent(options); + const cancellable = sandbox as unknown as { + destroySession: (id: string) => Promise; + cancelSession?: (id: string) => Promise; + }; + cancellable.cancelSession = (id) => cancellable.destroySession(id); + return sandbox; + }; + + const projectId = "11111111-1111-4111-8111-111111111111"; + const sessionId = "conv-stop-during-pause-teardown"; + const turnId = "turn-stop-during-pause-teardown"; + const controller = new AbortController(); + registerExecution({ + projectId, + sessionId, + turnId, + startedAt: Date.now(), + abort: () => controller.abort(USER_STOP_ABORT_REASON), + }); + + const turn = runSandboxAgent( + { + harness: "claude", + sessionId, + turnId, + permissions: { default: "ask" }, + messages: [{ role: "user", content: "edit the file" }], + }, + undefined, + controller.signal, + deps, + ); + + await pauseTeardownStarted; + const outcome = await applyCommand( + { + id: "command-stop-during-pause-teardown", + projectId, + sessionId, + kind: "cancel", + target: { turnId, expectedTurnId: turnId }, + createdAt: new Date().toISOString(), + }, + { report: async () => {} }, + ); + assert.equal(outcome.execution.state, "stopped"); + + releasePauseTeardown(); + const result = await turn; + + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.stopReason, "cancelled"); + assert.equal(result.cancelSettled, true); + assert.equal(findExecution(projectId, sessionId)?.settled, true); + }); + it("effective ask with no decision pauses the tool, no harness reply (F-024)", async () => { const { calls, deps } = depsWithDefaultResponder(); @@ -3198,3 +3517,118 @@ describe("runTurn run-limits deadline (split path)", () => { assert.equal(calls.sandboxDestroyed, 1); }); }); + +/** + * The Daytona sandbox-gone path, end to end through the real environment wiring. + * + * On 2026-09-04 an isolated re-run showed the full cost of the gap this closes: the sandbox was + * deleted at 16:26:31, the runner's own socket was told `SANDBOX_NOT_FOUND` at 16:26:37, and the + * turn still beat `running=true` for THIRTY minutes. Nothing detected the death. What finally + * ended the turn was the 30 minute per-tool-call deadline + * (`[run-limits] tool call ... exceeded 1800000ms`), and only then did the turn's error and done + * records persist, 27 minutes after the client had already given up. + * + * Everything downstream of the turn ending is already correct: the error terminal, the records, + * the `running=false` beat that clears the row, the teardown. The only defect was WHEN the turn + * ended. So these tests pin the trigger and the terminal it produces, which is what puts all of + * that 27 minutes earlier. + */ +describe("a sandbox the provider deletes under a running turn", () => { + /** Daytona's real answer for a deleted sandbox, from the runner log of that re-run. */ + const goneAnswer = () => + new Response( + "not found: sandbox 39f3aa96-ddc7-4417-b8ab-71804894edf6 not found, " + + "it may have been deleted or stopped - inspect audit logs for more info", + { status: 404, headers: { "x-daytona-error-code": "SANDBOX_NOT_FOUND" } }, + ); + + /** + * Production's reporter over a fake socket. The double stands in for the network only: the + * wrapper, the latch and the arming are the real ones, so this exercises the wiring rather + * than a copy of it. + */ + function harnessWithGoneSocket(answer: () => Response) { + const fake = fakeHarness({ hangPrompt: true }); + fake.deps.createAcpFetch = ((_dispatcher: unknown, options: any) => + withSandboxGoneReport( + (async () => answer()) as unknown as typeof fetch, + options, + )) as any; + return fake; + } + + /** Let the run reach its prompt, which is where the real turn sits when its sandbox dies. */ + async function waitForStartedTurn(calls: { startOptions: any }) { + for (let i = 0; i < 50 && !calls.startOptions; i += 1) + await flushPromises(); + assert.ok(calls.startOptions, "the run should have started its sandbox"); + await flushPromises(); + } + + it("ends the turn with a sandbox_gone error terminal, from the turn's own socket", async () => { + const { calls, deps, events } = harnessWithGoneSocket(goneAnswer); + + const run = runSandboxAgent( + { + harness: "claude", + sessionId: "conv-sandbox-deleted", + messages: [{ role: "user", content: "run one shell command" }], + } as AgentRunRequest, + undefined, + undefined, + deps, + ); + await waitForStartedTurn(calls); + + // The turn is parked on a prompt that can never settle, exactly as on 2026-09-04. Its own + // socket is the next thing to speak, and what it says is that the sandbox is gone. + await (calls.startOptions.fetch as typeof fetch)( + "http://sandbox/v1/acp/session", + ); + const result = await run; + + // The turn RETURNED rather than hanging for thirty minutes, and it returned as this error. + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.error, SANDBOX_GONE_MESSAGE); + // The terminal the client reads, and the record that persists at this moment. + const errorEvent = events.find((event) => event.type === "error") as any; + assert.ok(errorEvent, "the run should emit an error terminal"); + assert.equal(errorEvent.code, "sandbox_gone"); + // The teardown ran, so the sandbox and its slot are reclaimed here rather than at eviction. + assert.equal(calls.sandboxDestroyed, 1); + }); + + it("keeps running when the same socket merely returns an ordinary error", async () => { + // A 502 from the proxy is a blip, not a death. Nothing must end the turn on it, or a + // transient network fault would kill healthy runs. + const { calls, deps } = harnessWithGoneSocket( + () => new Response("", { status: 502 }), + ); + + const run = runSandboxAgent( + { + harness: "claude", + sessionId: "conv-proxy-blip", + messages: [{ role: "user", content: "run one shell command" }], + } as AgentRunRequest, + undefined, + undefined, + deps, + ); + await waitForStartedTurn(calls); + + await (calls.startOptions.fetch as typeof fetch)( + "http://sandbox/v1/acp/session", + ); + for (let i = 0; i < 20; i += 1) await flushPromises(); + + // Still parked on its prompt: no terminal, no teardown. + assert.equal(calls.sandboxDestroyed, 0); + const settled = await Promise.race([ + run.then(() => "settled" as const), + Promise.resolve("pending" as const), + ]); + assert.equal(settled, "pending"); + }); +}); diff --git a/services/runner/tests/unit/sandbox-agent-provider.test.ts b/services/runner/tests/unit/sandbox-agent-provider.test.ts index a447170a5d8..ceb8ca659c3 100644 --- a/services/runner/tests/unit/sandbox-agent-provider.test.ts +++ b/services/runner/tests/unit/sandbox-agent-provider.test.ts @@ -278,7 +278,7 @@ describe("buildSandboxProvider (enabled-provider gate + unknown-id refusal)", () {}, undefined, undefined, - localOnly, + { config: localOnly }, ), /Unknown sandbox id 'typo-sandbox'/, ); @@ -294,7 +294,7 @@ describe("buildSandboxProvider (enabled-provider gate + unknown-id refusal)", () {}, undefined, undefined, - localOnly, + { config: localOnly }, ), ); }); @@ -310,7 +310,7 @@ describe("buildSandboxProvider (enabled-provider gate + unknown-id refusal)", () {}, undefined, undefined, - localOnly, + { config: localOnly }, ), /not enabled on this deployment/, ); @@ -326,7 +326,7 @@ describe("buildSandboxProvider (enabled-provider gate + unknown-id refusal)", () {}, undefined, undefined, - runnerConfig("local,daytona"), + { config: runnerConfig("local,daytona") }, ), ); }); @@ -356,7 +356,7 @@ describe("buildSandboxProvider (enabled-provider gate + unknown-id refusal)", () {}, undefined, secretPlan, - runnerConfig("local,daytona"), + { config: runnerConfig("local,daytona") }, ) as { materializeMcpServers?: unknown }; // A run with hiding switched off never carries a plan (buildRunPlan builds one only while diff --git a/services/runner/tests/unit/sandbox-credentials.test.ts b/services/runner/tests/unit/sandbox-credentials.test.ts new file mode 100644 index 00000000000..5ae061da9c1 --- /dev/null +++ b/services/runner/tests/unit/sandbox-credentials.test.ts @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import { describe, it } from "vitest"; + +import type { AgentRunRequest } from "../../src/protocol.ts"; +import { daytonaEnvVars } from "../../src/engines/sandbox_agent/daytona.ts"; +import { buildRunPlan } from "../../src/engines/sandbox_agent/run-plan.ts"; +import { + materializeSandboxCredentials, + RESERVED_SANDBOX_CREDENTIAL_NAMES, +} from "../../src/engines/sandbox_agent/sandbox-credentials.ts"; +import { + computeCredentialEpoch, + configFingerprint, +} from "../../src/engines/sandbox_agent/session-identity.ts"; +import { normalizeDesiredState } from "../../src/lifecycle/desired-state.ts"; +import { assignSandboxEnvironment } from "../../src/environment/runtime-lifecycle.ts"; +import { seedForRun } from "../../src/redaction.ts"; + +function request(name = "GITHUB_TOKEN", value = "github-secret-value"): AgentRunRequest { + return { + messages: [{ role: "user", content: "hello" }], + sandboxCredentials: [{ binding: { kind: "environment", name }, value }], + }; +} + +describe("sandbox credentials", () => { + it("validates and composes readable environment bindings for local and Daytona", () => { + const result = materializeSandboxCredentials(request()); + assert.deepEqual(result, { + ok: true, + environment: { GITHUB_TOKEN: "github-secret-value" }, + }); + assert.equal( + daytonaEnvVars({}, result.ok ? result.environment : {}).GITHUB_TOKEN, + "github-secret-value", + ); + }); + + it("rejects malformed, duplicate, reserved, model, and MCP collisions", () => { + const invalid = ["1TOKEN", "TOKEN-NAME", "TOKEN.NAME"]; + for (const name of invalid) assert.equal(materializeSandboxCredentials(request(name)).ok, false); + + for (const name of RESERVED_SANDBOX_CREDENTIAL_NAMES) { + assert.equal(materializeSandboxCredentials(request(name)).ok, false, name); + } + + const duplicate = request(); + duplicate.sandboxCredentials!.push({ + binding: { kind: "environment", name: "GITHUB_TOKEN" }, + value: "other", + }); + assert.equal(materializeSandboxCredentials(duplicate).ok, false); + + const model = request(); + model.modelConnection = { + provider: "openai", + deployment: "direct", + credentialMode: "env", + environment: {}, + credentials: [{ + binding: { kind: "environment", name: "GITHUB_TOKEN" }, + value: "model-secret", + usage: "opaque_http", + }], + }; + assert.equal(materializeSandboxCredentials(model).ok, false); + + for (const name of [ + "AGENTA_AGENT_FUTURE_CONTROL", + "SANDBOX_AGENT_COMMAND", + "PI_CODING_AGENT_FUTURE", + ]) { + assert.equal(materializeSandboxCredentials(request(name)).ok, false, name); + } + }); + + + it("does not treat MCP HTTP headers as environment collisions", () => { + const mcp = request("Authorization"); + mcp.mcpServers = [{ + name: "server", + connection: { + type: "http", + url: "https://example.com/mcp", + headers: { Authorization: "public" }, + }, + policy: { tools: { mode: "all" } }, + }]; + assert.deepEqual(materializeSandboxCredentials(mcp), { + ok: true, + environment: { Authorization: "github-secret-value" }, + }); + }); + + it("rejects collisions with the final runner-owned environment before assignment", () => { + const daemon = { ENABLE_TOOL_SEARCH: "false" }; + const extension = { PI_CODING_AGENT_SKILL_DIR: "/runner/skills" }; + assert.throws( + () => assignSandboxEnvironment([daemon, extension], { ENABLE_TOOL_SEARCH: "secret" }), + /runner-owned environment/, + ); + assert.deepEqual(daemon, { ENABLE_TOOL_SEARCH: "false" }); + assert.throws( + () => assignSandboxEnvironment([daemon, extension], { PI_CODING_AGENT_SKILL_DIR: "secret" }), + /runner-owned environment/, + ); + assert.deepEqual(extension, { PI_CODING_AGENT_SKILL_DIR: "/runner/skills" }); + }); + + it("fails during plan construction before creating a sandbox cwd", () => { + let created = false; + const invalid = request("PATH"); + const result = buildRunPlan(invalid, { + sandboxProvider: "local", + enabledProviders: ["local"], + createLocalCwd: () => { + created = true; + return "/tmp/should-not-exist"; + }, + }); + assert.equal(result.ok, false); + assert.equal(created, false); + }); + + it("seeds custom values into known-value redaction", () => { + const redactor = seedForRun(request()); + assert.doesNotMatch( + redactor.redactString("token=github-secret-value", "test")!, + /github-secret-value/, + ); + }); + + it("keeps values out of configuration identity and includes them in credential epochs", () => { + const first = request("GITHUB_TOKEN", "first-secret-value"); + const rotated = request("GITHUB_TOKEN", "second-secret-value"); + const removed = request(); + delete removed.sandboxCredentials; + + assert.equal(configFingerprint(first), configFingerprint(rotated)); + assert.notEqual(configFingerprint(first), configFingerprint(removed)); + assert.ok( + computeCredentialEpoch(first).direct.equals(computeCredentialEpoch(rotated).direct) === false, + ); + assert.ok( + computeCredentialEpoch(first).direct.equals(computeCredentialEpoch(removed).direct) === false, + ); + + const firstState = normalizeDesiredState(first, configFingerprint(first)); + const rotatedState = normalizeDesiredState(rotated, configFingerprint(rotated)); + assert.equal(firstState.digests.runtime, rotatedState.digests.runtime); + }); +}); diff --git a/services/runner/tests/unit/sandbox-gone.test.ts b/services/runner/tests/unit/sandbox-gone.test.ts new file mode 100644 index 00000000000..0c3b52b651d --- /dev/null +++ b/services/runner/tests/unit/sandbox-gone.test.ts @@ -0,0 +1,214 @@ +/** + * A REMOTE sandbox does not refuse the socket when it dies. + * + * Daytona keeps the proxy host up after the sandbox is deleted and answers every request for it + * with `404` + `x-daytona-error-code: SANDBOX_NOT_FOUND`. The liveness probe reads any HTTP answer + * as alive on purpose, so that answer used to mean "still there": on 2026-09-04 a turn whose + * sandbox was deleted under it kept heartbeating `running=true` for five minutes and only stopped + * because the runner process was terminated. + * + * These tests hold the recognition rule. It has to be narrow in both directions: it must catch the + * provider's verdict, and it must not read a healthy answer, an unrelated error, or a 200 body that + * merely quotes the prose as a death. + */ + +import { describe, it, expect, vi } from "vitest"; + +import { + createSandboxGoneLatch, + sandboxGoneReason, +} from "../../src/engines/sandbox_agent/sandbox-gone.ts"; + +/** The shape the probe and the ACP transport both hand to the predicate. */ +function answer(status: number, headers: Record = {}) { + const lower = new Map( + Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]), + ); + return { + status, + headers: { get: (name: string) => lower.get(name.toLowerCase()) ?? null }, + }; +} + +/** The exact answer Daytona gave for the deleted sandbox on 2026-09-04. */ +const DAYTONA_BODY = + "not found: sandbox a476c238-dfdb-492c-bb4a-0ca15f42fddf not found, " + + "it may have been deleted or stopped - inspect audit logs for more info"; + +describe("sandboxGoneReason", () => { + it("reads the provider's own error code as a death", () => { + const reason = sandboxGoneReason( + answer(404, { "x-daytona-error-code": "SANDBOX_NOT_FOUND" }), + ); + + expect(reason).toBeTruthy(); + expect(reason).toContain("SANDBOX_NOT_FOUND"); + }); + + it("reads the provider's prose as a death when no code header rides along", () => { + expect(sandboxGoneReason(answer(404), DAYTONA_BODY)).toBeTruthy(); + }); + + it("keeps a bare 404 alive: the health route may simply not exist", () => { + expect(sandboxGoneReason(answer(404), "Not Found")).toBeUndefined(); + }); + + it("keeps 401 alive: unauthorised proves something is listening", () => { + expect(sandboxGoneReason(answer(401))).toBeUndefined(); + }); + + it("keeps a 502 alive: a proxy blip is not a deleted sandbox", () => { + expect(sandboxGoneReason(answer(502), "")).toBeUndefined(); + }); + + it("ignores the prose in a SUCCESSFUL answer, which proves the sandbox answered", () => { + expect(sandboxGoneReason(answer(200), DAYTONA_BODY)).toBeUndefined(); + }); + + it("ignores an unrelated provider error code", () => { + expect( + sandboxGoneReason(answer(400, { "x-daytona-error-code": "BAD_REQUEST" })), + ).toBeUndefined(); + }); + + /* + * A stopped or archived sandbox is a RESUMABLE state the provider itself handles, and the + * reconnect ladder can legitimately meet either one while it brings a parked sandbox back. + * Reading them as death would end a turn on a sandbox that is about to answer. + */ + it("keeps a stopped sandbox alive: the provider can resume it", () => { + expect( + sandboxGoneReason( + answer(404, { "x-daytona-error-code": "SANDBOX_STOPPED" }), + ), + ).toBeUndefined(); + }); + + it("keeps an archived sandbox alive, for the same reason", () => { + expect( + sandboxGoneReason( + answer(404, { "x-daytona-error-code": "SANDBOX_ARCHIVED" }), + ), + ).toBeUndefined(); + }); +}); + +/** An armed latch, which is what every caller past acquire holds. */ +function armedLatch() { + const latch = createSandboxGoneLatch(); + latch.arm(); + return latch; +} + +describe("sandbox gone latch", () => { + it("delivers the first reason to a listener that subscribed earlier", () => { + const latch = armedLatch(); + const listener = vi.fn(); + + latch.subscribe(listener); + latch.note("deleted"); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith("deleted"); + expect(latch.reason()).toBe("deleted"); + }); + + it("delivers to a listener that subscribed after the death", () => { + const latch = armedLatch(); + const listener = vi.fn(); + + latch.note("deleted"); + latch.subscribe(listener); + + expect(listener).toHaveBeenCalledWith("deleted"); + }); + + it("keeps one death for one sandbox, however many requests observe it", () => { + const latch = armedLatch(); + const listener = vi.fn(); + latch.subscribe(listener); + + latch.note("first"); + latch.note("second"); + latch.note("third"); + + expect(listener).toHaveBeenCalledTimes(1); + expect(latch.reason()).toBe("first"); + }); + + it("reports nothing while the sandbox still answers", () => { + expect(armedLatch().reason()).toBeUndefined(); + }); + + it("drops a listener that unsubscribed, so a warm sandbox keeps no dead turns", () => { + const latch = armedLatch(); + const finishedTurn = vi.fn(); + const currentTurn = vi.fn(); + + const unsubscribe = latch.subscribe(finishedTurn); + unsubscribe(); + latch.subscribe(currentTurn); + latch.note("deleted"); + + expect(finishedTurn).not.toHaveBeenCalled(); + expect(currentTurn).toHaveBeenCalledTimes(1); + }); + + it("survives a listener that throws, and still tells the others", () => { + const latch = armedLatch(); + const other = vi.fn(); + latch.subscribe(() => { + throw new Error("listener fault"); + }); + latch.subscribe(other); + + latch.note("deleted"); + + expect(other).toHaveBeenCalledTimes(1); + expect(latch.reason()).toBe("deleted"); + }); +}); + +/* + * The startup window. The same fetch carries the SDK's health wait during acquire, which polls a + * sandbox that is still coming up and tolerates a provider error by design. On a warm resume the + * proxy can lag its own control plane and answer "not found" for a sandbox it has not finished + * re-exposing. The latch is one-way, so a report from that window must be discarded, or the first + * turn on a healthy sandbox is killed. + */ +describe("sandbox gone latch before it is armed", () => { + it("discards a gone report seen before acquire resolves", () => { + const latch = createSandboxGoneLatch(); + const listener = vi.fn(); + latch.subscribe(listener); + + latch.note("provider reports the sandbox is gone (HTTP 404)"); + + expect(listener).not.toHaveBeenCalled(); + expect(latch.reason()).toBeUndefined(); + }); + + it("latches the SAME report once acquire has resolved", () => { + const latch = createSandboxGoneLatch(); + const listener = vi.fn(); + latch.subscribe(listener); + + latch.note("provider reports the sandbox is gone (HTTP 404)"); + latch.arm(); + latch.note("provider reports the sandbox is gone (HTTP 404)"); + + expect(listener).toHaveBeenCalledTimes(1); + expect(latch.reason()).toBe( + "provider reports the sandbox is gone (HTTP 404)", + ); + }); + + it("does not remember a discarded report: arming alone declares nothing", () => { + const latch = createSandboxGoneLatch(); + + latch.note("seen during acquire"); + latch.arm(); + + expect(latch.reason()).toBeUndefined(); + }); +}); diff --git a/services/runner/tests/unit/sandbox-lifecycle.test.ts b/services/runner/tests/unit/sandbox-lifecycle.test.ts index 4d2add811d9..e77349e8828 100644 --- a/services/runner/tests/unit/sandbox-lifecycle.test.ts +++ b/services/runner/tests/unit/sandbox-lifecycle.test.ts @@ -36,6 +36,8 @@ interface FakeOpts { * pauseSandbox() throws while retaining its provider handles for the delete fallback. */ pauseThrows?: boolean; + /** Abort after environment acquisition, when the harness prompt starts. */ + onPrompt?: () => void; } function fakeSandbox(sandboxId: string | undefined, opts: FakeOpts = {}) { @@ -59,6 +61,7 @@ function fakeSandbox(sandboxId: string | undefined, opts: FakeOpts = {}) { onEvent() {}, onPermissionRequest() {}, async prompt() { + opts.onPrompt?.(); if (opts.promptThrows) throw new Error("harness exploded"); return { stopReason: opts.stopReason ?? "complete", @@ -366,9 +369,10 @@ describe("remote sandbox teardown", () => { }); it("destroys (not parks) when the run is aborted", async () => { - const { calls, deps } = fakeSandbox("sbx-99"); const controller = new AbortController(); - controller.abort(); + const { calls, deps } = fakeSandbox("sbx-99", { + onPrompt: () => controller.abort(), + }); await runSandboxAgent(daytonaRequest, undefined, controller.signal, deps); assert.equal(calls.paused, 0, "an aborted run must not park"); assert.equal(calls.destroyed, 1); diff --git a/services/runner/tests/unit/sandbox-liveness.test.ts b/services/runner/tests/unit/sandbox-liveness.test.ts new file mode 100644 index 00000000000..e1f30c837cb --- /dev/null +++ b/services/runner/tests/unit/sandbox-liveness.test.ts @@ -0,0 +1,325 @@ +/** + * A sandbox that dies under a running turn must end the turn, not hang it. + * + * The ACP prompt the turn is parked on can never settle once the sandbox process is gone: the + * transport's read loop swallows the severed stream and never rejects the pending request. The + * existing run limits do not save it either — `notePaused()` retires all of them the moment the + * turn parks for a human, which is exactly when a long turn is most likely to outlive its + * sandbox. So the runner probes the sandbox's own HTTP surface, independently of the wedged ACP + * channel. These tests hold the probe's contract: it tolerates a blip, it declares death once, + * and it never fires after the turn released it. Issue #6418. + */ + +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { + DEFAULT_PROBE_FAILURES, + DEFAULT_PROBE_INTERVAL_MS, + DEFAULT_PROBE_TIMEOUT_MS, + PROBE_FAILURES_ENV, + PROBE_INTERVAL_ENV, + httpLivenessProbe, + resolveSandboxLivenessLimits, + SandboxGoneError, + sandboxHealthUrl, + startSandboxLivenessProbe, + type Clock, + type SandboxLivenessLimits, +} from "../../src/engines/sandbox_agent/sandbox-liveness.ts"; +import { SANDBOX_GONE_MARKER } from "../../src/engines/sandbox_agent/errors.ts"; +import { createSandboxGoneLatch } from "../../src/engines/sandbox_agent/sandbox-gone.ts"; + +/** A clock whose timers only run when the test says so, in scheduled order. */ +function fakeClock(): Clock & { tick(): Promise; pending(): number } { + let nextId = 1; + const timers = new Map void; at: number }>(); + let now = 0; + + const clock = { + setTimeout(fn: () => void, ms: number) { + const id = nextId++; + timers.set(id, { fn, at: now + ms }); + return id as unknown as NodeJS.Timeout; + }, + clearTimeout(handle: NodeJS.Timeout) { + timers.delete(handle as unknown as number); + }, + pending: () => timers.size, + /** Run the earliest pending timer, then drain the microtask queue. */ + async tick() { + const entries = [...timers.entries()].sort((a, b) => a[1].at - b[1].at); + const next = entries[0]; + if (!next) return; + timers.delete(next[0]); + now = next[1].at; + next[1].fn(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }, + }; + return clock; +} + +const limits: SandboxLivenessLimits = { + intervalMs: 1_000, + timeoutMs: 500, + failureThreshold: 3, +}; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("sandbox liveness probe", () => { + it.each([ + ["local", "http://127.0.0.1:43123/ui/", "http://127.0.0.1:43123/v1/health"], + [ + "Daytona", + "https://3000-sandbox-id.proxy.daytona.works/ui/", + "https://3000-sandbox-id.proxy.daytona.works/v1/health", + ], + ])( + "derives the daemon health route from a %s inspector URL", + (_provider, inspectorUrl, expected) => { + expect(sandboxHealthUrl({ inspectorUrl })).toBe(expected); + }, + ); + + it("declares the sandbox gone after the threshold of consecutive failures", async () => { + const onGone = vi.fn(); + const probe = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")); + const clock = fakeClock(); + + const handle = startSandboxLivenessProbe({ probe, limits, onGone, clock }); + + // Each pass is one interval timer, then the probe's own timeout timer. + for (let i = 0; i < 3; i++) { + await clock.tick(); // interval fires, probe rejects + await clock.tick(); // the (already settled) probe timeout is cleared/drained + } + + expect(probe).toHaveBeenCalledTimes(3); + expect(onGone).toHaveBeenCalledTimes(1); + expect(onGone.mock.calls[0][0]).toContain(SANDBOX_GONE_MARKER); + handle.dispose(); + }); + + it("tolerates a blip: one failure between successes is not a death", async () => { + const onGone = vi.fn(); + const probe = vi + .fn() + .mockRejectedValueOnce(new Error("transient")) + .mockResolvedValue({ id: "session-1" }); + const clock = fakeClock(); + + const handle = startSandboxLivenessProbe({ probe, limits, onGone, clock }); + + for (let i = 0; i < 8; i++) await clock.tick(); + + expect(onGone).not.toHaveBeenCalled(); + expect(handle.failures()).toBe(0); + handle.dispose(); + }); + + it("counts a probe that hangs as a failure, so a vanished host is not waited on forever", async () => { + const onGone = vi.fn(); + // The exact #6418 shape: the request neither answers nor refuses. + const probe = vi.fn().mockImplementation(() => new Promise(() => {})); + const clock = fakeClock(); + + const handle = startSandboxLivenessProbe({ probe, limits, onGone, clock }); + + // interval -> probe hangs -> its timeout fires, three times over. + for (let i = 0; i < 6; i++) await clock.tick(); + + expect(onGone).toHaveBeenCalledTimes(1); + expect(onGone.mock.calls[0][0]).toContain("probe timed out"); + handle.dispose(); + }); + + it("fires at most once, and never after dispose", async () => { + const onGone = vi.fn(); + const probe = vi.fn().mockRejectedValue(new Error("gone")); + const clock = fakeClock(); + + const handle = startSandboxLivenessProbe({ probe, limits, onGone, clock }); + handle.dispose(); + + for (let i = 0; i < 10; i++) await clock.tick(); + + expect(probe).not.toHaveBeenCalled(); + expect(onGone).not.toHaveBeenCalled(); + expect(clock.pending()).toBe(0); + }); +}); + +/** A latch the environment already armed, which is what every turn past acquire holds. */ +function armedLatch() { + const latch = createSandboxGoneLatch(); + latch.arm(); + return latch; +} + +/** + * The Daytona case. The proxy answers for a deleted sandbox, so no probe ever fails the weak way + * and the three-strike counter never moves. Both routes below end the turn instead. + */ +describe("a sandbox the provider says is gone", () => { + it("ends the turn on the FIRST such answer, without waiting for the threshold", async () => { + const onGone = vi.fn(); + const probe = vi + .fn() + .mockRejectedValue(new SandboxGoneError("sandbox a476c238 not found")); + const clock = fakeClock(); + + const handle = startSandboxLivenessProbe({ probe, limits, onGone, clock }); + + await clock.tick(); // one interval, one probe + await clock.tick(); + + expect(probe).toHaveBeenCalledTimes(1); + expect(onGone).toHaveBeenCalledTimes(1); + expect(onGone.mock.calls[0][0]).toContain(SANDBOX_GONE_MARKER); + expect(onGone.mock.calls[0][0]).toContain("a476c238"); + handle.dispose(); + }); + + it("ends the turn the moment the ACP transport reports it, with no probe at all", async () => { + const onGone = vi.fn(); + const goneSignal = armedLatch(); + const clock = fakeClock(); + + const handle = startSandboxLivenessProbe({ + probe: vi.fn().mockResolvedValue(200), + goneSignal, + limits, + onGone, + clock, + }); + goneSignal.note("provider reports the sandbox is gone (HTTP 404)"); + + expect(onGone).toHaveBeenCalledTimes(1); + expect(onGone.mock.calls[0][0]).toContain(SANDBOX_GONE_MARKER); + handle.dispose(); + }); + + it("honours the transport's report on a sandbox with no health URL to poll", () => { + const onGone = vi.fn(); + const goneSignal = armedLatch(); + const clock = fakeClock(); + + const handle = startSandboxLivenessProbe({ + goneSignal, + limits, + onGone, + clock, + }); + + expect(clock.pending()).toBe(0); // nothing to poll, so nothing is scheduled + goneSignal.note("deleted"); + + expect(onGone).toHaveBeenCalledTimes(1); + handle.dispose(); + }); + + it("still reports one death when the probe and the transport both see it", async () => { + const onGone = vi.fn(); + const goneSignal = armedLatch(); + const probe = vi + .fn() + .mockRejectedValue(new SandboxGoneError("sandbox gone per probe")); + const clock = fakeClock(); + + const handle = startSandboxLivenessProbe({ + probe, + goneSignal, + limits, + onGone, + clock, + }); + goneSignal.note("sandbox gone per transport"); + await clock.tick(); + await clock.tick(); + + expect(onGone).toHaveBeenCalledTimes(1); + handle.dispose(); + }); + + it("hands the listener back on dispose, so a warm sandbox keeps no finished turns", () => { + const goneSignal = armedLatch(); + const finishedTurn = vi.fn(); + const currentTurn = vi.fn(); + const clock = fakeClock(); + + // Turn 1 runs and ends. Turn 2 starts on the SAME warm environment, so the same latch. + startSandboxLivenessProbe({ + goneSignal, + limits, + onGone: finishedTurn, + clock, + }).dispose(); + const handle = startSandboxLivenessProbe({ + goneSignal, + limits, + onGone: currentTurn, + clock, + }); + + goneSignal.note("deleted"); + + expect(finishedTurn).not.toHaveBeenCalled(); + expect(currentTurn).toHaveBeenCalledTimes(1); + handle.dispose(); + }); +}); + +describe("httpLivenessProbe", () => { + const originalFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("rejects with a definitive error when the provider names the sandbox as gone", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response("not found: sandbox a476c238 not found", { + status: 404, + headers: { "x-daytona-error-code": "SANDBOX_NOT_FOUND" }, + }), + ) as unknown as typeof fetch; + + await expect( + httpLivenessProbe("http://sandbox/v1/health")(), + ).rejects.toBeInstanceOf(SandboxGoneError); + }); + + it("keeps reading an ordinary 404 as alive", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue( + new Response("Not Found", { status: 404 }), + ) as unknown as typeof fetch; + + await expect(httpLivenessProbe("http://sandbox/v1/health")()).resolves.toBe( + 404, + ); + }); +}); + +describe("sandbox liveness limits", () => { + it("defaults to one probe per heartbeat interval and three strikes", () => { + expect(resolveSandboxLivenessLimits()).toEqual({ + intervalMs: DEFAULT_PROBE_INTERVAL_MS, + timeoutMs: DEFAULT_PROBE_TIMEOUT_MS, + failureThreshold: DEFAULT_PROBE_FAILURES, + }); + }); + + it("takes an operator override", () => { + vi.stubEnv(PROBE_INTERVAL_ENV, "5000"); + vi.stubEnv(PROBE_FAILURES_ENV, "2"); + + const resolved = resolveSandboxLivenessLimits(); + + expect(resolved.intervalMs).toBe(5_000); + expect(resolved.failureThreshold).toBe(2); + }); +}); diff --git a/services/runner/tests/unit/server.test.ts b/services/runner/tests/unit/server.test.ts index d452842d530..0437d7b096a 100644 --- a/services/runner/tests/unit/server.test.ts +++ b/services/runner/tests/unit/server.test.ts @@ -19,8 +19,17 @@ import { createAgentServer, normalizeKillProjectId, registerShutdownHandler, + runWithKeepalive, + type KeepaliveEngine, type RunAgent, } from "../../src/server.ts"; +import type { SessionEnvironment } from "../../src/engines/sandbox_agent.ts"; +import { SessionPool } from "../../src/engines/sandbox_agent/session-pool.ts"; +import { HEARTBEAT_INTERVAL_SECONDS } from "../../src/sessions/contract.ts"; +import { + liveExecutions, + resetExecutionsForTest, +} from "../../src/sessions/execution-registry.ts"; const TOKEN_ENV = "AGENTA_RUNNER_TOKEN"; const previousToken = process.env[TOKEN_ENV]; @@ -29,6 +38,7 @@ const LIMIT_ENV = "AGENTA_RUNNER_CONCURRENCY_LIMIT"; const previousLimit = process.env[LIMIT_ENV]; afterEach(() => { + resetExecutionsForTest(); vi.restoreAllMocks(); vi.unstubAllEnvs(); if (previousToken === undefined) delete process.env[TOKEN_ENV]; @@ -524,6 +534,400 @@ describe("createAgentServer", () => { } }); + it("persists one stopped ending when user Stop aborts a slow cold acquire", async () => { + let markAcquireStarted!: () => void; + const acquireStarted = new Promise((resolve) => { + markAcquireStarted = resolve; + }); + let runTurnCalls = 0; + const engine: KeepaliveEngine = { + async resolveKeepaliveMount() { + return null; + }, + async acquireEnvironment(_request, signal) { + markAcquireStarted(); + await new Promise((resolve) => { + if (signal?.aborted) return resolve(); + signal?.addEventListener("abort", () => resolve(), { once: true }); + }); + return { ok: false, error: "sandbox acquisition aborted" }; + }, + async runTurn() { + runTurnCalls += 1; + return { ok: true, output: "must not run" }; + }, + async runCold() { + return { ok: false, error: "must not run cold fallback" }; + }, + }; + const run: RunAgent = (request, emit, signal) => + runWithKeepalive(request, emit, signal, { + engine, + pool: new SessionPool({ poolMax: 1 }), + config: { + enabled: true, + ttlMs: 60_000, + approvalTtlMs: 60_000, + poolMax: 1, + }, + }); + const s = await listen(run); + const realFetch = globalThis.fetch.bind(globalThis); + const ingested: Array> = []; + let heartbeatCount = 0; + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async (input, init) => { + const url = String(input); + if (url === `${s.url}/run`) return realFetch(input, init); + if (url.endsWith("/sessions/streams/heartbeat")) { + heartbeatCount += 1; + return Response.json({ + stream: { id: "stream-stop-during-acquire" }, + is_current_turn: heartbeatCount === 1, + }); + } + if (url.endsWith("/sessions/records/ingest")) { + ingested.push(JSON.parse(String(init?.body))); + } + return Response.json({}); + }); + vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] }); + + try { + const responsePromise = fetchSpy(`${s.url}/run`, { + method: "POST", + headers: { accept: "application/x-ndjson", ...AUTH }, + body: JSON.stringify({ + harness: "pi_core", + sandbox: "local", + sessionId: "session-stop-during-acquire", + runContext: { project: { id: "project-1" } }, + telemetry: { + exporters: { + otlp: { + endpoint: `${s.url}/otlp/v1/traces`, + headers: { authorization: "Test platform authorization" }, + }, + }, + }, + messages: [{ role: "user", content: "start slowly" }], + }), + }); + + await acquireStarted; + await vi.advanceTimersByTimeAsync(HEARTBEAT_INTERVAL_SECONDS * 1000); + const response = await responsePromise; + const records = (await response.text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + + assert.equal(runTurnCalls, 0, "the Stop landed before the turn started"); + const endings = ingested.filter( + (record) => record.record_type === "done", + ); + assert.equal(endings.length, 1, "the transcript has one terminal record"); + assert.equal( + ingested.filter((record) => record.record_type === "error").length, + 0, + "a user Stop does not persist an acquire error", + ); + assert.deepEqual(endings[0].attributes, { + type: "done", + stopReason: "cancelled", + }); + assert.equal( + records.filter((record) => record.kind === "result").length, + 1, + "the run outcome is reported once", + ); + assert.equal(records.at(-1)?.result.ok, false); + } finally { + vi.useRealTimers(); + fetchSpy.mockRestore(); + await s.close(); + } + }); + + it("persists an acquire failure error before exactly one ending", async () => { + const acquireError = "sandbox mount failed"; + let runTurnCalls = 0; + const engine: KeepaliveEngine = { + async resolveKeepaliveMount() { + return null; + }, + async acquireEnvironment() { + return { ok: false, error: acquireError }; + }, + async runTurn() { + runTurnCalls += 1; + return { ok: true, output: "must not run" }; + }, + async runCold() { + return { ok: false, error: "must not run cold fallback" }; + }, + }; + const run: RunAgent = (request, emit, signal) => + runWithKeepalive(request, emit, signal, { + engine, + pool: new SessionPool({ poolMax: 1 }), + config: { + enabled: true, + ttlMs: 60_000, + approvalTtlMs: 60_000, + poolMax: 1, + }, + }); + const s = await listen(run); + const realFetch = globalThis.fetch.bind(globalThis); + const ingested: Array> = []; + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async (input, init) => { + const url = String(input); + if (url === `${s.url}/run`) return realFetch(input, init); + if (url.endsWith("/sessions/streams/heartbeat")) { + return Response.json({ + stream: { id: "stream-acquire-failure" }, + is_current_turn: true, + }); + } + if (url.endsWith("/sessions/records/ingest")) { + ingested.push(JSON.parse(String(init?.body))); + } + return Response.json({}); + }); + + try { + const response = await fetchSpy(`${s.url}/run`, { + method: "POST", + headers: { accept: "application/x-ndjson", ...AUTH }, + body: JSON.stringify({ + harness: "pi_core", + sandbox: "local", + sessionId: "session-acquire-failure", + runContext: { project: { id: "project-1" } }, + telemetry: { + exporters: { + otlp: { + endpoint: `${s.url}/otlp/v1/traces`, + headers: { authorization: "Test platform authorization" }, + }, + }, + }, + messages: [{ role: "user", content: "fail during acquire" }], + }), + }); + const records = (await response.text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + + assert.equal(runTurnCalls, 0, "the failed acquire never starts the turn"); + const endingRecords = ingested.filter((record) => + ["error", "done"].includes(record.record_type), + ); + assert.deepEqual( + endingRecords.map((record) => record.record_type), + ["error", "done"], + "the transcript preserves the error before its ending", + ); + assert.deepEqual(endingRecords[0].attributes, { + type: "error", + message: acquireError, + }); + assert.deepEqual(endingRecords[1].attributes, { type: "done" }); + assert.equal( + records.filter((record) => record.kind === "result").length, + 1, + "the failed run outcome is reported once", + ); + assert.equal(records.at(-1)?.result.error, acquireError); + } finally { + fetchSpy.mockRestore(); + await s.close(); + } + }); + + it("keeps the normal Stop path at exactly one persisted ending", async () => { + const normalStop: RunAgent = async (_request, emit) => { + emit?.({ type: "done", stopReason: "cancelled" }); + return { ok: true, stopReason: "cancelled", events: [] }; + }; + const s = await listen(normalStop); + const realFetch = globalThis.fetch.bind(globalThis); + const ingested: Array> = []; + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async (input, init) => { + const url = String(input); + if (url === `${s.url}/run`) return realFetch(input, init); + if (url.endsWith("/sessions/streams/heartbeat")) { + return Response.json({ + stream: { id: "stream-normal-stop" }, + is_current_turn: true, + }); + } + if (url.endsWith("/sessions/records/ingest")) { + ingested.push(JSON.parse(String(init?.body))); + } + return Response.json({}); + }); + + try { + const response = await fetchSpy(`${s.url}/run`, { + method: "POST", + headers: { accept: "application/x-ndjson", ...AUTH }, + body: JSON.stringify({ + harness: "pi_core", + sessionId: "session-normal-stop", + telemetry: { + exporters: { + otlp: { + endpoint: `${s.url}/otlp/v1/traces`, + headers: { authorization: "Test platform authorization" }, + }, + }, + }, + messages: [{ role: "user", content: "stop normally" }], + }), + }); + const records = (await response.text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + + const endings = ingested.filter( + (record) => record.record_type === "done", + ); + assert.equal( + endings.length, + 1, + "the server must not duplicate runTurn's ending", + ); + assert.deepEqual(endings[0].attributes, { + type: "done", + stopReason: "cancelled", + }); + assert.equal( + records.filter( + (record) => record.kind === "event" && record.event?.type === "done", + ).length, + 1, + "the normal Stop still streams its one done event", + ); + assert.equal( + records.filter((record) => record.kind === "result").length, + 1, + "the run outcome is reported once", + ); + } finally { + fetchSpy.mockRestore(); + await s.close(); + } + }); + for (const testCase of [ + { name: "plain", sessionOwned: false, detached: false, aborts: true }, + { name: "session-owned", sessionOwned: true, detached: false, aborts: false }, + { name: "detached", sessionOwned: true, detached: true, aborts: false }, + ]) { + it(`a dropped ${testCase.name} invoke ${testCase.aborts ? "cancels" : "does not cancel"} the turn`, async () => { + vi.stubEnv("AGENTA_API_INTERNAL_URL", "http://api:8000"); + let releaseRun: (() => void) | undefined; + let observedAbort = false; + let completed = false; + let markStarted: (() => void) | undefined; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const run: RunAgent = async (_request, _emit, signal) => { + markStarted?.(); + return await new Promise((resolve) => { + const finish = () => { + if (completed) return; + completed = true; + resolve({ ok: true, output: "done", events: [] }); + }; + releaseRun = finish; + signal?.addEventListener( + "abort", + () => { + observedAbort = true; + finish(); + }, + { once: true }, + ); + }); + }; + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + if (url.endsWith("/sessions/streams/heartbeat")) { + return Response.json({ + stream: { id: "stream-1" }, + is_current_turn: true, + }); + } + return Response.json({}); + }); + const s = await listen(run); + + try { + const request = http.request(`${s.url}/run`, { + method: "POST", + headers: { + ...AUTH, + accept: "application/x-ndjson", + "content-type": "application/json", + }, + }); + request.on("error", () => {}); + request.end( + JSON.stringify({ + harness: "pi_core", + ...(testCase.sessionOwned ? { sessionId: `session-${testCase.name}` } : {}), + ...(testCase.detached ? { detached: true } : {}), + telemetry: { + exporters: { + otlp: { + endpoint: "http://127.0.0.1:8000/otlp/v1/traces", + headers: { authorization: "ApiKey test" }, + }, + }, + }, + messages: [{ role: "user", content: "hello" }], + }), + ); + + await started; + request.destroy(); + await new Promise((resolve) => setTimeout(resolve, 25)); + + if (!testCase.aborts) { + assert.equal(observedAbort, false, "the dropped response must not own turn lifetime"); + assert.equal(completed, false, "the fake turn is still running after disconnect"); + releaseRun?.(); + } + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("run did not settle")), 1_000); + const poll = () => { + if (completed) { + clearTimeout(timeout); + resolve(); + } else setImmediate(poll); + }; + poll(); + }); + assert.equal(observedAbort, testCase.aborts); + } finally { + releaseRun?.(); + await s.close(); + fetchSpy.mockRestore(); + } + }); + } + it("redacts this run's credentials from the stderr stack log when a run throws", async () => { // A per-run provider key rides ONLY the typed request (never process env). When the run // throws with that key captured in the error message/stack (an auth failure echoing it, @@ -578,6 +982,73 @@ describe("createAgentServer", () => { } }); + it("persists one terminal done record when a session-owned run throws", async () => { + const s = await listen(async () => { + throw new Error("engine escaped"); + }); + const realFetch = globalThis.fetch.bind(globalThis); + const ingested: Array> = []; + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async (input, init) => { + const url = String(input); + if (url === `${s.url}/run`) return realFetch(input, init); + if (url.endsWith("/sessions/streams/heartbeat")) { + return Response.json({ + stream: { id: "stream-escaped-run" }, + is_current_turn: true, + }); + } + if (url.endsWith("/sessions/records/ingest")) { + ingested.push(JSON.parse(String(init?.body))); + } + return Response.json({}); + }); + + try { + const response = await fetchSpy(`${s.url}/run`, { + method: "POST", + headers: { accept: "application/x-ndjson", ...AUTH }, + body: JSON.stringify({ + harness: "pi_core", + sessionId: "session-escaped-run", + runContext: { project: { id: "project-1" } }, + telemetry: { + exporters: { + otlp: { + endpoint: `${s.url}/otlp/v1/traces`, + headers: { authorization: "Test platform authorization" }, + }, + }, + }, + messages: [{ role: "user", content: "throw" }], + }), + }); + const records = (await response.text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + + assert.deepEqual( + ingested + .filter((record) => ["error", "done"].includes(record.record_type)) + .map((record) => record.record_type), + ["error", "done"], + ); + assert.equal( + ingested.filter((record) => record.record_type === "done").length, + 1, + ); + assert.equal(records.filter((record) => record.kind === "result").length, 1); + assert.equal(records.at(-1)?.result.error, "engine escaped"); + } finally { + fetchSpy.mockRestore(); + errorSpy.mockRestore(); + await s.close(); + } + }); + it("rejects an over-cap session turn before persistence or attachment claiming", async () => { // Override the cap rather than generating a default-sized batch, so the case stays small. process.env.AGENTA_ATTACHMENTS_MAX_PER_TURN = "2"; @@ -644,6 +1115,7 @@ describe("createAgentServer", () => { records[0].result.error, "A user turn may carry at most 2 attachments.", ); + assert.deepEqual(liveExecutions(), []); } finally { delete process.env.AGENTA_ATTACHMENTS_MAX_PER_TURN; fetchSpy.mockRestore(); diff --git a/services/runner/tests/unit/session-admission.test.ts b/services/runner/tests/unit/session-admission.test.ts new file mode 100644 index 00000000000..c1f7eb3ace5 --- /dev/null +++ b/services/runner/tests/unit/session-admission.test.ts @@ -0,0 +1,639 @@ +/** + * Single-turn admission at the runner's edge (#6417, #5539, #5538). + * + * ============================================================================================ + * THE BUG THESE PIN + * ============================================================================================ + * + * A second user message that reached the runner while a turn was running on the same session + * killed BOTH turns and left the session locked until the 30-minute lease expired: + * + * 1. The runner started the second turn's alive watchdog. Its first heartbeat asked the API's + * atomic `nx` acquire for the session and LOST, so the API answered `is_current_turn: false`. + * 2. The runner read that only as "abort this run later" and carried on into the keepalive pool, + * which found the first turn's environment busy and DESTROYED it (`supersede-busy`). Turn one + * lost its sandbox mid-answer. + * 3. Turn two then aborted on its own watchdog signal. Both turns were dead, and the session read + * as alive under a dead turn's lock. + * + * The arbiter was always right. The runner acted before reading its answer. These tests pin that + * the runner now stops at the edge: a refused turn resolves no session environment, evicts + * nothing, persists nothing, and returns a clear conflict to the caller. + * + * ============================================================================================ + * WHAT THE FAKE MODELS + * ============================================================================================ + * + * A real runner HTTP server (`createAgentServer`) driven over a real socket, plus a fake platform + * API that answers `POST /sessions/streams/heartbeat`. The fake API models exactly one fact: the + * `is_current_turn` field, which is the whole admission answer. Every other API call the turn + * makes (interaction sweep, attachment claim, credential refresh) is answered 200-and-empty, + * because none of them participate in the decision. + * + * Run: pnpm test (or: pnpm exec vitest run tests/unit/session-admission.test.ts) + */ +import { afterEach, beforeEach, describe, it } from "vitest"; +import assert from "node:assert/strict"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; + +import type { AgentRunRequest, AgentRunResult } from "../../src/protocol.ts"; +import { createAgentServer, type RunAgent } from "../../src/server.ts"; +import { + SESSION_TURN_IN_USE_CODE, + SESSION_TURN_IN_USE_MESSAGE, +} from "../../src/sessions/admission.ts"; + +const TEST_TOKEN = "test-runner-token"; +const AUTH = { authorization: `Bearer ${TEST_TOKEN}` }; +const INTERNAL_ENV = "AGENTA_API_INTERNAL_URL"; + +interface Beat { + session_id?: string; + turn_id?: string; + is_running?: boolean; +} + +/** The fake platform API. `admit` decides what its heartbeat answers for each beat. */ +async function startFakeApi( + admit: (beat: Beat) => boolean | Promise, +): Promise<{ + url: string; + beats: Beat[]; + paths: string[]; + close: () => Promise; +}> { + const beats: Beat[] = []; + const paths: string[] = []; + const server = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (c) => chunks.push(c as Buffer)); + req.on("end", async () => { + const path = (req.url ?? "").split("?")[0]; + paths.push(path); + let body: Record = {}; + const raw = Buffer.concat(chunks).toString("utf8"); + if (raw.trim()) { + try { + body = JSON.parse(raw) as Record; + } catch { + body = {}; + } + } + if (path.endsWith("/sessions/streams/heartbeat")) { + const beat = body as Beat; + beats.push(beat); + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + stream: { id: "11111111-1111-1111-1111-111111111111" }, + replica_id: body.replica_id ?? null, + // A turn-end beat (`is_running: false`) is never an admission question. + is_current_turn: + beat.is_running === false ? true : await admit(beat), + }), + ); + return; + } + res.writeHead(200, { "content-type": "application/json" }); + res.end("{}"); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + return { + url: `http://127.0.0.1:${port}`, + beats, + paths, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +async function startRunner( + run: RunAgent, +): Promise<{ url: string; close: () => Promise }> { + process.env.AGENTA_RUNNER_TOKEN = TEST_TOKEN; + const server: Server = createAgentServer(run); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + return { + url: `http://127.0.0.1:${port}`, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +/** A session-owned run request: `sessionId` is the whole gate (`isSessionOwned`). */ +function sessionRequest( + overrides: Partial = {}, +): Record { + return { + harness: "claude", + model: "m1", + sessionId: "session-admission-1", + messages: [{ role: "user", content: "hello" }], + ...overrides, + }; +} + +interface StreamRecord { + kind: string; + event?: { + type: string; + message?: string; + code?: string; + turnId?: string; + name?: string; + }; + result?: { ok: boolean; error?: string }; +} + +async function postRun( + runnerUrl: string, + body: Record, +): Promise<{ status: number; records: StreamRecord[] }> { + const res = await fetch(`${runnerUrl}/run`, { + method: "POST", + headers: { accept: "application/x-ndjson", ...AUTH }, + body: JSON.stringify(body), + }); + const text = await res.text(); + const records = text + .split("\n") + .filter((line) => line.trim()) + .map((line) => JSON.parse(line) as StreamRecord); + return { status: res.status, records }; +} + +const previousInternal = process.env[INTERNAL_ENV]; +const previousToken = process.env.AGENTA_RUNNER_TOKEN; + +beforeEach(() => { + delete process.env[INTERNAL_ENV]; +}); + +afterEach(() => { + if (previousInternal === undefined) delete process.env[INTERNAL_ENV]; + else process.env[INTERNAL_ENV] = previousInternal; + if (previousToken === undefined) delete process.env.AGENTA_RUNNER_TOKEN; + else process.env.AGENTA_RUNNER_TOKEN = previousToken; +}); + +describe("runner admission: a refused turn never reaches the session environment", () => { + it("does not call run() when the first heartbeat reports is_current_turn: false", async () => { + const api = await startFakeApi(() => false); + process.env[INTERNAL_ENV] = api.url; + const runCalls: AgentRunRequest[] = []; + const runner = await startRunner(async (request): Promise => { + runCalls.push(request); + return { ok: true, output: "should never run", events: [] }; + }); + try { + const { records } = await postRun(runner.url, sessionRequest()); + + assert.equal( + runCalls.length, + 0, + "the refused turn must not reach run(), which is what resolves the keepalive pool " + + "and is where the live turn's environment used to be destroyed", + ); + const terminal = records.find((r) => r.kind === "result"); + assert.ok(terminal, "a terminal result record is still written"); + assert.equal(terminal!.result!.ok, false); + assert.equal(terminal!.result!.error, SESSION_TURN_IN_USE_MESSAGE); + } finally { + await runner.close(); + await api.close(); + } + }); + + it("emits an error event carrying the stable session_turn_in_use code", async () => { + // The code is what lets the browser render "not sent, keep your text" instead of the generic + // "The agent run failed" bubble. The message is one line, because the SDK's + // `sanitize_runner_error` keeps only the first line of a runner error. + const api = await startFakeApi(() => false); + process.env[INTERNAL_ENV] = api.url; + const runner = await startRunner(async () => ({ + ok: true, + output: "", + events: [], + })); + try { + const { records } = await postRun(runner.url, sessionRequest()); + + const error = records.find( + (r) => r.kind === "event" && r.event?.type === "error", + ); + assert.ok(error, "the refusal is streamed as an error event"); + assert.equal(error!.event!.code, SESSION_TURN_IN_USE_CODE); + assert.equal(error!.event!.message, SESSION_TURN_IN_USE_MESSAGE); + assert.ok( + !SESSION_TURN_IN_USE_MESSAGE.includes("\n"), + "the message must stay one line to survive sanitize_runner_error", + ); + } finally { + await runner.close(); + await api.close(); + } + }); + + it("makes no interaction-sweep or attachment-claim call for a refused turn", async () => { + // `cancelStaleInteractions` cancels the session's unanswered approval gates, sparing only the + // CALLING turn's own. Running it for a turn that was refused would cancel the LIVE turn's + // pending approval card — a second way the double send broke the running turn. + const api = await startFakeApi(() => false); + process.env[INTERNAL_ENV] = api.url; + const runner = await startRunner(async () => ({ + ok: true, + output: "", + events: [], + })); + try { + await postRun(runner.url, sessionRequest()); + // Give any fire-and-forget call a chance to land before asserting it did not. + await new Promise((resolve) => setTimeout(resolve, 50)); + + const nonHeartbeat = api.paths.filter( + (p) => !p.endsWith("/sessions/streams/heartbeat"), + ); + assert.deepEqual( + nonHeartbeat, + [], + `a refused turn touched the platform beyond its own beats: ${nonHeartbeat.join(", ")}`, + ); + } finally { + await runner.close(); + await api.close(); + } + }); + + it("stops the heartbeat with an owner-scoped end beat for its own turn id", async () => { + // The end beat is safe to send: the API releases `running` only for the turn that owns it, so + // a refused turn's final beat cannot clear the LIVE turn's lock. Sending it is what stops the + // heartbeat interval and releases the credential lease. + const api = await startFakeApi(() => false); + process.env[INTERNAL_ENV] = api.url; + const runner = await startRunner(async () => ({ + ok: true, + output: "", + events: [], + })); + try { + await postRun(runner.url, sessionRequest()); + + assert.equal(api.beats.length, 2, "exactly one start beat and one end beat"); + assert.equal(api.beats[0].is_running, true); + assert.equal(api.beats[1].is_running, false); + assert.equal( + api.beats[0].turn_id, + api.beats[1].turn_id, + "the end beat names the REFUSED turn, never the live one", + ); + } finally { + await runner.close(); + await api.close(); + } + }); +}); + +describe("runner admission: an admitted turn proceeds", () => { + it("runs the turn when the first heartbeat admits it", async () => { + const api = await startFakeApi(() => true); + process.env[INTERNAL_ENV] = api.url; + const runCalls: AgentRunRequest[] = []; + const runner = await startRunner(async (request): Promise => { + runCalls.push(request); + return { ok: true, output: "answered", events: [] }; + }); + try { + const { records } = await postRun(runner.url, sessionRequest()); + + assert.equal(runCalls.length, 1, "the admitted turn runs"); + const terminal = records.find((r) => r.kind === "result"); + assert.equal(terminal!.result!.ok, true); + } finally { + await runner.close(); + await api.close(); + } + }); + + it("admits an approval RESUME while the previous turn is parked, not running", async () => { + // The park case is the one a naive "is anything alive on this session?" gate gets wrong. A + // parked turn still holds `alive` (that is what makes the session reattachable) but has + // released `running`. The API's heartbeat distinguishes them: with no `running` owner it + // treats the stale `alive` as a legitimate handover, tombstones the parked turn, and admits + // the resume. This test pins that the runner honours an ADMIT answer for a resume-shaped + // request rather than refusing on the presence of a prior turn. + const api = await startFakeApi(() => true); + process.env[INTERNAL_ENV] = api.url; + const runCalls: AgentRunRequest[] = []; + const runner = await startRunner(async (request): Promise => { + runCalls.push(request); + return { ok: true, output: "resumed", events: [] }; + }); + try { + const resume = sessionRequest({ + messages: [ + { role: "user", content: "edit the file" }, + { + role: "assistant", + content: [{ type: "tool_call", toolCallId: "call-1", toolName: "edit" }], + }, + { + role: "user", + content: [ + { + type: "tool_result", + toolCallId: "call-1", + output: { approved: true }, + }, + ], + }, + ], + } as unknown as Partial); + const { records } = await postRun(runner.url, resume); + + assert.equal(runCalls.length, 1, "the resume runs"); + const terminal = records.find((r) => r.kind === "result"); + assert.equal(terminal!.result!.ok, true); + } finally { + await runner.close(); + await api.close(); + } + }); + + it("a refused second turn cannot replace the admitted turn's Stop handle", async () => { + let releaseSecondAdmission!: () => void; + const secondAdmissionMayFinish = new Promise((resolve) => { + releaseSecondAdmission = resolve; + }); + let markSecondAdmissionWaiting!: () => void; + const secondAdmissionWaiting = new Promise((resolve) => { + markSecondAdmissionWaiting = resolve; + }); + const api = await startFakeApi(async (beat) => { + if (beat.turn_id !== "turn-B") return true; + markSecondAdmissionWaiting(); + await secondAdmissionMayFinish; + return false; + }); + process.env[INTERNAL_ENV] = api.url; + + let markFirstRunning!: () => void; + const firstRunning = new Promise((resolve) => { + markFirstRunning = resolve; + }); + let markFirstAborted!: () => void; + const firstAborted = new Promise((resolve) => { + markFirstAborted = resolve; + }); + let finishFirstForCleanup!: () => void; + const firstMayFinishForCleanup = new Promise((resolve) => { + finishFirstForCleanup = resolve; + }); + const runCalls: string[] = []; + const runner = await startRunner( + async (request, _emit, signal): Promise => { + runCalls.push(request.turnId ?? "missing"); + assert.equal(request.turnId, "turn-A", "the refused turn never reaches run()"); + markFirstRunning(); + await Promise.race([ + new Promise((resolve) => { + if (signal?.aborted) resolve(); + else signal?.addEventListener("abort", () => resolve(), { once: true }); + }), + firstMayFinishForCleanup, + ]); + if (signal?.aborted) markFirstAborted(); + return { + ok: true, + output: "", + events: [], + ...(signal?.aborted ? { stopReason: "cancelled" as const } : {}), + }; + }, + ); + + const firstRequest = postRun( + runner.url, + sessionRequest({ turnId: "turn-A" }), + ); + let secondRequest: ReturnType | undefined; + try { + await firstRunning; + secondRequest = postRun( + runner.url, + sessionRequest({ turnId: "turn-B" }), + ); + await secondAdmissionWaiting; + + const cancel = await fetch(`${runner.url}/cancel`, { + method: "POST", + headers: { "content-type": "application/json", ...AUTH }, + body: JSON.stringify({ + commandId: "command-stop-A", + projectId: "project-1", + sessionId: "session-admission-1", + targetTurnId: "turn-A", + createdAt: new Date().toISOString(), + }), + }); + + assert.equal(cancel.status, 202, "the runner still holds admitted turn A"); + await firstAborted; + releaseSecondAdmission(); + const [first, second] = await Promise.all([firstRequest, secondRequest]); + assert.equal( + first.records.find((record) => record.kind === "result")?.result?.ok, + true, + ); + assert.equal( + second.records.find((record) => record.kind === "result")?.result?.error, + SESSION_TURN_IN_USE_MESSAGE, + ); + assert.deepEqual(runCalls, ["turn-A"]); + } finally { + releaseSecondAdmission(); + finishFirstForCleanup(); + await Promise.allSettled([ + firstRequest, + ...(secondRequest ? [secondRequest] : []), + ]); + await runner.close(); + await api.close(); + } + }); + + it("fails closed when the coordination plane cannot confirm admission", async () => { + process.env[INTERNAL_ENV] = "http://127.0.0.1:1"; + const runCalls: AgentRunRequest[] = []; + const runner = await startRunner(async (request): Promise => { + runCalls.push(request); + return { ok: true, output: "answered", events: [] }; + }); + try { + const { records } = await postRun(runner.url, sessionRequest()); + + assert.equal(runCalls.length, 0, "an unconfirmed turn must never reach run()"); + const terminal = records.find((r) => r.kind === "result"); + assert.equal(terminal!.result!.ok, false); + assert.equal(terminal!.result!.error, SESSION_TURN_IN_USE_MESSAGE); + } finally { + await runner.close(); + } + }); +}); + +describe("runner admission: the admitted turn id reaches the client", () => { + // The runner mints the turn id per execution, and until now it told no one. The client's + // `start` frame is built and sent before the runner replies at all, so it cannot carry a + // runner-minted id — which is why `expected_execution_id` on the public Cancel has never had a + // first-party caller able to fill it. A Stop could only mean "whatever is running now", never + // "the turn I was watching". The `turn` event is the earliest frame that can carry it. + + it("emits a turn event carrying the admitted turn id, before any other event", async () => { + const api = await startFakeApi(() => true); + process.env[INTERNAL_ENV] = api.url; + const runner = await startRunner(async () => ({ + ok: true, + output: "answered", + events: [], + })); + try { + const { records } = await postRun(runner.url, sessionRequest()); + + const events = records.filter((r) => r.kind === "event"); + assert.ok(events.length > 0, "the run streamed at least one event"); + assert.equal( + events[0].event!.type, + "turn", + "the turn id must arrive FIRST, so a Stop that races the turn's own output can name it", + ); + const turnId = events[0].event!.turnId; + assert.ok(turnId, "the turn event carries an id"); + assert.match( + String(turnId), + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + "the id is the uuid the runner minted", + ); + } finally { + await runner.close(); + await api.close(); + } + }); + + it("emits the SAME id the alive lock was acquired under", async () => { + // The whole point of handing the id out is that a client can name THIS execution to the + // control plane. An id that does not match the one holding the session's locks would name + // nothing, so the two must be the same value, not merely both present. + const api = await startFakeApi(() => true); + process.env[INTERNAL_ENV] = api.url; + const runner = await startRunner(async () => ({ + ok: true, + output: "answered", + events: [], + })); + try { + const { records } = await postRun(runner.url, sessionRequest()); + + const turnEvent = records.find( + (r) => r.kind === "event" && r.event?.type === "turn", + ); + assert.ok(turnEvent, "a turn event was emitted"); + assert.equal( + turnEvent!.event!.turnId, + api.beats[0].turn_id, + "the streamed id must be the id that heartbeat the alive lock", + ); + } finally { + await runner.close(); + await api.close(); + } + }); + + it("emits NO turn event for a refused turn, which owns no execution to name", async () => { + const api = await startFakeApi(() => false); + process.env[INTERNAL_ENV] = api.url; + const runner = await startRunner(async () => ({ + ok: true, + output: "", + events: [], + })); + try { + const { records } = await postRun(runner.url, sessionRequest()); + + assert.ok( + !records.some((r) => r.kind === "event" && r.event?.type === "turn"), + "a refused turn must not hand out an id: it runs nothing and there is nothing to stop", + ); + } finally { + await runner.close(); + await api.close(); + } + }); + it("emits NO session-accepted for a refused detached turn", async () => { + // `session-accepted` is what switches the client to shared delivery: live text stops coming + // from the invoke stream and starts coming from /sessions/{id}/events. A refused turn serves + // no frames on either channel, so a client that already switched renders nothing at all and + // waits out its acceptance deadline instead of showing the refusal. + const api = await startFakeApi(() => false); + process.env[INTERNAL_ENV] = api.url; + const runner = await startRunner(async () => ({ + ok: true, + output: "", + events: [], + })); + try { + const { records } = await postRun( + runner.url, + sessionRequest({ detached: true } as Partial), + ); + + assert.ok( + !records.some( + (r) => r.kind === "event" && r.event?.name === "session-accepted", + ), + "acceptance must never precede the admission verdict", + ); + const error = records.find( + (r) => r.kind === "event" && r.event?.type === "error", + ); + assert.ok(error, "the refusal still reaches the client as an error event"); + assert.equal(error!.event!.code, SESSION_TURN_IN_USE_CODE); + } finally { + await runner.close(); + await api.close(); + } + }); + + it("emits session-accepted for an admitted detached turn, before the turn event", async () => { + const api = await startFakeApi(() => true); + process.env[INTERNAL_ENV] = api.url; + const runner = await startRunner(async () => ({ + ok: true, + output: "answered", + events: [], + })); + try { + const { records } = await postRun( + runner.url, + sessionRequest({ detached: true } as Partial), + ); + + const accepted = records.findIndex( + (r) => r.kind === "event" && r.event?.name === "session-accepted", + ); + const turnEvent = records.findIndex( + (r) => r.kind === "event" && r.event?.type === "turn", + ); + assert.ok(accepted >= 0, "an admitted detached turn still announces itself"); + assert.ok(turnEvent >= 0, "and still hands out its execution id"); + assert.ok( + accepted < turnEvent, + "acceptance stays the first positive frame the shared client reads", + ); + } finally { + await runner.close(); + await api.close(); + } + }); +}); diff --git a/services/runner/tests/unit/session-alive-interrupt.test.ts b/services/runner/tests/unit/session-alive-interrupt.test.ts index 26e45881b75..e466907d7b2 100644 --- a/services/runner/tests/unit/session-alive-interrupt.test.ts +++ b/services/runner/tests/unit/session-alive-interrupt.test.ts @@ -12,7 +12,9 @@ import assert from "node:assert/strict"; const fetchCalls: Array<{ url: string; body: unknown }> = []; let nextIsCurrentTurn: boolean | undefined = true; -vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => { +/** The default heartbeat fake. Re-stubbed per test, because the fail-open cases replace it and + * `vi.restoreAllMocks` does not undo a `vi.stubGlobal`. */ +const recordingFetch = async (url: string, init?: RequestInit) => { const body = init?.body ? JSON.parse(init.body as string) : undefined; fetchCalls.push({ url, body }); const payload: Record = { ok: true }; @@ -20,7 +22,9 @@ vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => { payload.is_current_turn = nextIsCurrentTurn; } return new Response(JSON.stringify(payload), { status: 200 }); -}); +}; + +vi.stubGlobal("fetch", recordingFetch); const { startAliveWatchdog } = await import("../../src/sessions/alive.ts"); @@ -31,6 +35,7 @@ function flushMicrotasks(): Promise { beforeEach(() => { fetchCalls.length = 0; nextIsCurrentTurn = true; + vi.stubGlobal("fetch", recordingFetch); }); afterEach(() => { @@ -138,3 +143,45 @@ describe("startAliveWatchdog onInterrupted", () => { await assert.doesNotReject(() => watchdog.release()); }); }); + +describe("startAliveWatchdog admitted (single-turn admission)", () => { + // The first beat is this turn's ADMISSION request: its `nx` acquire of the `alive` lock is the + // platform's single atomic arbiter of who runs a session. `admitted` reports that one answer so + // `server.ts` can stop a losing turn at the edge, before it resolves a session environment. + // Before this, the same answer only armed `onInterrupted`, and the losing turn still walked into + // the keepalive pool and destroyed the winning turn's warm sandbox (#6417, #5539, #5538). + + it("is true when the first beat admits the turn", async () => { + const watchdog = await startAliveWatchdog("sess-a", "turn-a", "proj-1"); + assert.equal(watchdog.admitted, true); + await watchdog.release(); + }); + + it("is false when the first beat reports is_current_turn: false", async () => { + nextIsCurrentTurn = false; + const watchdog = await startAliveWatchdog("sess-b", "turn-b", "proj-1"); + assert.equal(watchdog.admitted, false); + await watchdog.release(); + }); + + it("fails closed when the admission API is unreachable", async () => { + // Without an affirmative first heartbeat, the runner cannot prove it owns this turn. + vi.stubGlobal("fetch", async () => { + throw new Error("network down"); + }); + const watchdog = await startAliveWatchdog("sess-c", "turn-c", "proj-1"); + assert.equal(watchdog.admitted, false); + await watchdog.release(); + }); + + it("reads the FIRST beat only: a later interruption is a cancel, not a failed admission", async () => { + // A mid-turn `is_current_turn: false` is a Stop/steer/kill. That travels the + // `onInterrupted` -> abort path and must never retroactively un-admit a turn that already ran. + const watchdog = await startAliveWatchdog("sess-d", "turn-d", "proj-1"); + assert.equal(watchdog.admitted, true); + nextIsCurrentTurn = false; + await flushMicrotasks(); + assert.equal(watchdog.admitted, true, "admitted is a fact about the start of the turn"); + await watchdog.release(); + }); +}); diff --git a/services/runner/tests/unit/session-continuity.test.ts b/services/runner/tests/unit/session-continuity.test.ts index dc1c8880095..e48187fd397 100644 --- a/services/runner/tests/unit/session-continuity.test.ts +++ b/services/runner/tests/unit/session-continuity.test.ts @@ -41,7 +41,11 @@ describe("SessionContinuityStore basics", () => { const store = new SessionContinuityStore(); store.record("sess-1", "claude", "agent-1", 3); store.record("sess-1", "pi", "agent-2", 1); - assert.equal(store.latestTurn("sess-1"), 3, "latest stays at the higher turn index"); + assert.equal( + store.latestTurn("sess-1"), + 3, + "latest stays at the higher turn index", + ); assert.deepEqual(store.get("sess-1", "pi"), { agentSessionId: "agent-2", turnIndex: 1, @@ -106,7 +110,10 @@ describe("record/read-back", () => { it("an empty store: eligibleAgentSessionId is undefined (cold path taken)", () => { const store = new SessionContinuityStore(); assert.equal(isHarnessLoadEligible("sess-new", "claude", store), false); - assert.equal(eligibleAgentSessionId("sess-new", "claude", store), undefined); + assert.equal( + eligibleAgentSessionId("sess-new", "claude", store), + undefined, + ); }); }); @@ -227,4 +234,29 @@ describe("assertLocalRunnerOwnership", () => { }, ); }); + + it("tells the user the pin clears itself, since a runner restart is how they meet it", () => { + // The message is surfaced verbatim. A restart leaves the old replica's affinity key alive + // for OWNER_TTL_SECONDS (120s), so every resume in that window lands here and then starts + // working again on its own. Without saying so the error reads as a dead session. + const err = new LocalSandboxNotOwnerError( + "sess-1", + "replica-b", + "replica-a", + ); + + assert.match(err.message, /pinned to the runner instance that started it/); + assert.match(err.message, /clears itself within a couple of minutes/); + assert.match(err.message, /sending again will work/); + // The operator detail survives, after the part a user can act on. + const guidanceEnds = err.message.indexOf("[local sandbox requires"); + assert.ok( + guidanceEnds > 0, + "operator detail should be bracketed at the end", + ); + assert.ok( + !err.message.slice(0, guidanceEnds).includes("replica-a"), + "no replica ids before the guidance — they mean nothing to the person who pressed send", + ); + }); }); diff --git a/services/runner/tests/unit/session-keepalive-approval.test.ts b/services/runner/tests/unit/session-keepalive-approval.test.ts index 2e292f678ac..877cd9e6228 100644 --- a/services/runner/tests/unit/session-keepalive-approval.test.ts +++ b/services/runner/tests/unit/session-keepalive-approval.test.ts @@ -30,6 +30,7 @@ import { } from "../../src/server.ts"; import { SessionPool } from "../../src/engines/sandbox_agent/session-pool.ts"; import { + approvalDecisionForToolCall, computeCredentialEpoch, configFingerprint, mountExpiryMs, @@ -139,6 +140,12 @@ function makeApprovalEngine( reply: string; toolCallId: string; }>, + settledBeforePrompts: [] as Array<{ + permissionId: string; + reply: string; + toolCallId: string; + }>, + prompts: [] as string[], acquiredEnvs: [] as DispatchFakeEnv[], /** One control per approvalPause turn: settle the parked prompt promise from the test. */ promptControls: [] as Array<{ @@ -190,6 +197,7 @@ function makeApprovalEngine( const applyScript = async ( env: DispatchFakeEnv, + request: AgentRunRequest, opts: any, ): Promise => { const idx = calls.turns.length; @@ -216,6 +224,19 @@ function makeApprovalEngine( }); } } + if (opts?.settleApprovalsThenPrompt) { + for (const decision of opts.settleApprovalsThenPrompt.decisions) { + calls.settledBeforePrompts.push({ + permissionId: decision.permissionId, + reply: decision.reply, + toolCallId: decision.toolCallId, + }); + } + const tail = request.messages?.[request.messages.length - 1]; + if (tail?.role === "user" && typeof tail.content === "string") { + calls.prompts.push(tail.content); + } + } if (script.hold) { await new Promise((resolve) => holds.set(idx, resolve)); } @@ -277,8 +298,8 @@ function makeApprovalEngine( calls.acquiredEnvs.push(env); return { ok: true, env: env as unknown as SessionEnvironment }; }, - async runTurn(env, _request, _emit, _signal, opts) { - return applyScript(env as unknown as DispatchFakeEnv, opts); + async runTurn(env, request, _emit, _signal, opts) { + return applyScript(env as unknown as DispatchFakeEnv, request, opts); }, async runCold(_request, _emit, _signal, _presigned) { calls.cold += 1; @@ -547,6 +568,83 @@ describe("runWithKeepalive: approval park + resume", () => { ); }); + it("settles a rewritten denial then prompts a trailing fresh user turn on the warm session", async () => { + const { engine, calls } = makeApprovalEngine([ + { + approvalPause: { + permissionId: "perm-1", + toolCallId: "tc-gate", + toolName: "commit", + }, + toolCallIds: ["tc-gate"], + }, + ]); + const ctx = makeCtx(engine); + await runWithKeepalive(pauseTurn(), undefined, undefined, ctx); + + const request: AgentRunRequest = { + ...pauseTurn(), + messages: [ + { role: "user", content: "do X" }, + { + role: "assistant", + content: [ + { type: "tool_call", toolCallId: "tc-gate", toolName: "commit" }, + { + type: "tool_result", + toolCallId: "tc-gate", + output: { approved: false }, + }, + ], + }, + { role: "user", content: "What was the codeword I gave you?" }, + ], + }; + + const result = await runWithKeepalive( + request, + undefined, + undefined, + ctx, + ); + + assert.equal(result.ok, true); + assert.equal(calls.acquire, 1, "the fresh turn kept the warm environment"); + assert.equal(calls.resumes.length, 0, "it did not take approval-resume"); + assert.deepEqual(calls.settledBeforePrompts, [ + { permissionId: "perm-1", reply: "reject", toolCallId: "tc-gate" }, + ]); + assert.deepEqual(calls.prompts, ["What was the codeword I gave you?"]); + assert.equal(calls.turns[1].opts.continuation, true); + assert.equal(calls.turns[1].env, calls.turns[0].env); + }); + + it("ignores a denied tool result older than the last assistant message", () => { + const request: AgentRunRequest = { + messages: [ + { role: "user", content: "first" }, + { + role: "assistant", + content: [ + { + type: "tool_result", + toolCallId: "tc-gate", + output: { approved: false }, + }, + ], + }, + { role: "user", content: "second" }, + { role: "assistant", content: "finished a later turn" }, + { role: "user", content: "fresh question" }, + ], + }; + + assert.equal( + approvalDecisionForToolCall(request, "tc-gate"), + undefined, + ); + }); + it("logs park-approval and resume-approve/reject", async () => { const cap = captureStderr(); try { @@ -1572,6 +1670,7 @@ function pausableHarness( logs: [] as string[], resolvePrompt: undefined as ((value: unknown) => void) | undefined, promptCount: 0, + prompts: [] as any[], /** Ordered marks for the settle-before-terminal-record invariant (see the test at the end). */ journal: [] as string[], }; @@ -1645,8 +1744,9 @@ function pausableHarness( queueMicrotask(emitPiBatchResults); } }, - prompt(_blocks: any) { + prompt(blocks: any) { calls.promptCount += 1; + calls.prompts.push(blocks); // Stays pending (Claude never resolves prompt on an unanswered gate) until the test resolves // it — modelling the ORIGINAL prompt continuing after the parked gate is answered. return new Promise((resolve) => { @@ -2041,6 +2141,153 @@ describe("runTurn: real approval park + respondPermission resume", () => { await env.destroy(); }); + it("settles a parked denial before sending a fresh prompt to session.prompt", async () => { + const { calls, deps, captured } = pausableHarness(); + const acquired = await acquireEnvironment(engineReq, deps); + assert.equal(acquired.ok, true); + if (!acquired.ok) return; + const env = acquired.env; + + const firstTurn = runTurn(env, engineReq, undefined, undefined, { + approvalParkMode: true, + }); + await flush(); + captured.onEvent!( + updateEvent({ + sessionUpdate: "tool_call", + toolCallId: "tc-gate", + title: "commit", + }), + ); + captured.onPermissionRequest!({ + id: "perm-1", + availableReplies: ["once", "reject"], + toolCall: { toolCallId: "tc-gate", name: "commit", rawInput: {} }, + }); + await flush(); + await firstTurn; + + const parked = env.parkedApproval!; + const resolveOriginalPrompt = calls.resolvePrompt!; + env.clearTurn(); + const freshText = "What was the codeword I gave you?"; + const freshRequest: AgentRunRequest = { + ...engineReq, + messages: [{ role: "user", content: freshText }], + }; + const secondTurn = runTurn( + env, + freshRequest, + undefined, + undefined, + { + approvalParkMode: true, + continuation: true, + settleApprovalsThenPrompt: { + decisions: [ + { + permissionId: parked.permissionId, + reply: "reject", + toolCallId: parked.toolCallId, + toolName: parked.toolName, + args: parked.args, + interactionToken: parked.interactionToken, + promptPromise: parked.promptPromise, + }, + ], + }, + }, + ); + for (let i = 0; i < 20 && calls.permissionReplies.length === 0; i += 1) { + await flush(); + } + assert.deepEqual(calls.permissionReplies, [ + { id: "perm-1", reply: "reject" }, + ]); + + resolveOriginalPrompt({ + stopReason: "complete", + usage: { inputTokens: 1, outputTokens: 1 }, + }); + for (let i = 0; i < 20 && calls.promptCount < 2; i += 1) await flush(); + assert.equal(calls.promptCount, 2, "the fresh text became a new prompt"); + assert.deepEqual(calls.prompts[1], [{ type: "text", text: freshText }]); + calls.resolvePrompt!({ + stopReason: "complete", + usage: { inputTokens: 1, outputTokens: 1 }, + }); + + const result = await secondTurn; + assert.equal(result.ok, true); + assert.equal(result.stopReason, "complete"); + await env.destroy(); + }); + + it("pauses when the harness re-gates after a denial instead of hanging on the old prompt", async () => { + const { calls, deps, captured } = pausableHarness(); + const acquired = await acquireEnvironment(engineReq, deps); + assert.equal(acquired.ok, true); + if (!acquired.ok) return; + const env = acquired.env; + + const firstTurn = runTurn(env, engineReq, undefined, undefined, { + approvalParkMode: true, + }); + await flush(); + captured.onPermissionRequest!({ + id: "perm-1", + availableReplies: ["once", "reject"], + toolCall: { toolCallId: "tc-gate", name: "commit", rawInput: {} }, + }); + await flush(); + await firstTurn; + + const parked = env.parkedApproval!; + env.clearTurn(); + const secondTurn = runTurn( + env, + { ...engineReq, messages: [{ role: "user", content: "try another way" }] }, + undefined, + undefined, + { + approvalParkMode: true, + continuation: true, + settleApprovalsThenPrompt: { + decisions: [ + { + permissionId: parked.permissionId, + reply: "reject", + toolCallId: parked.toolCallId, + toolName: parked.toolName, + args: parked.args, + interactionToken: parked.interactionToken, + promptPromise: parked.promptPromise, + }, + ], + }, + }, + ); + for (let i = 0; i < 20 && calls.permissionReplies.length === 0; i += 1) { + await flush(); + } + captured.onPermissionRequest!({ + id: "perm-2", + availableReplies: ["once", "reject"], + toolCall: { toolCallId: "tc-regated", name: "deploy", rawInput: {} }, + }); + + const result = await secondTurn; + assert.equal(result.ok, true); + assert.equal(result.stopReason, "paused"); + assert.equal( + calls.promptCount, + 1, + "the fresh prompt was not sent behind a new gate", + ); + assert.equal(env.parkedApproval?.toolCallId, "tc-regated"); + await env.destroy(); + }, 1_000); + it("creates and resolves a durable gate row without workflow context", async () => { const posted: Array<{ url: string; body: Record }> = []; const fetchSpy = vi @@ -3399,6 +3646,117 @@ describe("runTurn: real approval park + respondPermission resume", () => { } }); + it("parks a FIRST-turn Pi batch whose allowed sibling can never close", async () => { + // The browser pass of 2026-09-04, sessions d66e2920 (17:32Z) and 6d06f624 (17:57Z). The model + // asked for a Read and a Bash in ONE parallel batch. The Read answered `allow`; the Bash + // parked. Pi will not execute any call in a batch while a sibling gate is open, so the + // allowed Read never closed. This is the FIRST turn, and the carry-and-park branch used to + // require a resume, so the turn took the closure wait instead and sat on the 30-minute + // per-tool-call bound. It never parked, never emitted `done`, and its alive watchdog kept + // beating `running=true`, so every durable continuation aimed at the next turn was refused + // with "Continuation could not establish alive ownership". + // + // A healthy gated turn from the same hour shows the Read's `tool_result` BEFORE the Bash + // gate. Sequential calls leave nothing open at pause time, which is why this only bites a + // parallel batch. + const batch: PiBatchCall[] = [ + { + permissionId: "permission-read", + toolCallId: "tool-read", + toolName: "reader", + args: { path: "notes.md" }, + output: "read output", + }, + { + permissionId: "permission-bash", + toolCallId: "tool-bash", + toolName: "runner", + args: { command: "echo one" }, + output: "bash output", + }, + ]; + const { deps } = pausableHarness({ piBatching: batch }); + deps.createOtel = createSandboxAgentOtel as any; + // The real responder, so the plan below actually decides. The fake one pends every gate and + // would never mark an allowed execution, which is the whole precondition here. + delete (deps as { responderFactory?: unknown }).responderFactory; + const closureWaitMs = 271_828; + deps.resolveRunLimits = () => ({ + totalMs: 1_000_000, + idleMs: 500_000, + ttfbMs: 500_000, + toolCallMs: closureWaitMs, + }); + deps.createRunLimits = () => ({ + onTrip() {}, + noteToolCallStart() {}, + noteToolCallEnd() {}, + wrapEmit: (emit: (event: any) => void) => emit, + notePaused() {}, + dispose() {}, + }); + // Count the closure waits by their bound, and let one that IS armed fire at once, so the red + // is an assertion rather than a 30-minute hang. + const realSetTimeout = globalThis.setTimeout; + let closureWaitCount = 0; + const timeoutSpy = vi.spyOn(globalThis, "setTimeout").mockImplementation((( + handler: (...args: any[]) => void, + timeout?: number, + ...args: any[] + ) => { + if (timeout === closureWaitMs) { + closureWaitCount += 1; + return realSetTimeout(handler, 0, ...args); + } + return realSetTimeout(handler, timeout, ...args); + }) as typeof setTimeout); + let env: SessionEnvironment | undefined; + + try { + const piRequest: AgentRunRequest = { + ...engineReq, + harness: "pi_agenta", + permissions: { default: "ask" }, + customTools: [ + { name: "reader", permission: "allow" }, + { name: "runner", permission: "ask" }, + ], + messages: [{ role: "user", content: "read the file then echo" }], + }; + const acquired = await acquireEnvironment(piRequest, deps); + assert.equal(acquired.ok, true); + if (!acquired.ok) return; + env = acquired.env; + + const result = await runTurn(env, piRequest, undefined, undefined, { + approvalParkMode: true, + }); + + assert.equal( + result.stopReason, + "paused", + "the gated first turn must END as paused, not hang in terminalization", + ); + assert.equal( + closureWaitCount, + 0, + "an allowed sibling of a pending Pi gate can never close, so the turn must not wait", + ); + assert.deepEqual( + [...(env.parkedApprovedExecutions?.keys() ?? [])], + ["tool-read"], + "the allowed call is carried so the resume re-announces it", + ); + assert.ok( + env.parkedApprovals.has("tool-bash"), + "the gated call is parked for the human to answer", + ); + } finally { + timeoutSpy.mockRestore(); + if (env) await env.destroy(); + } + }); + it("records the non-retry sentinel when an approved result misses the bound", async () => { const { calls, deps, captured } = pausableHarness(); deps.resolveRunLimits = () => ({ diff --git a/services/runner/tests/unit/session-keepalive-dispatch.test.ts b/services/runner/tests/unit/session-keepalive-dispatch.test.ts index 0ed6ea11381..19fbd344241 100644 --- a/services/runner/tests/unit/session-keepalive-dispatch.test.ts +++ b/services/runner/tests/unit/session-keepalive-dispatch.test.ts @@ -27,6 +27,7 @@ import { type KeepaliveEngine, } from "../../src/server.ts"; import { SessionPool } from "../../src/engines/sandbox_agent/session-pool.ts"; +import { USER_STOP_ABORT_REASON } from "../../src/sessions/stop-signal.ts"; import { configFingerprint, mountExpiryMs, @@ -777,10 +778,83 @@ describe("runWithKeepalive: never-park rules", () => { ); assert.equal(ctx.pool.size(), 0); }); + + it("a durable Stop re-parks the warm session even though the browser dropped its stream", async () => { + // The regression this file exists to prevent, replayed end to end at the dispatch seam. + // + // Increment 6, 2026-09-04: a warm Daytona session was Stopped from the browser and the next + // message came back cold on a NEW sandbox. The runner log read `[control] aborted` -> + // `harness_cancel sent=true settled=true` -> `prompt stopReason=cancelled` -> + // `[keepalive] evict reason=no-park:cancelled`. Every ingredient of a warm park was present + // and the sandbox was deleted anyway, because `handleStop` aborts the chat stream in the same + // tick it sends the durable cancel, and the park predicate read the disconnect first. + // + // So this test asserts BOTH halves land together: the client is gone AND the run signal + // carries the user-Stop label. Drop either one and it stops describing the product. + let gone = false; + const controller = new AbortController(); + const { engine, calls } = makeEngine({ + turnResults: [ + { ok: true, output: "hi", stopReason: "complete" }, + // What `run-turn.ts` returns for a Stop the harness confirmed. + { + ok: true, + output: "partial", + stopReason: "cancelled", + cancelSettled: true, + }, + ], + }); + const ctx = makeCtx(engine, {}, () => gone); + const key = "proj-1:stop-warm"; + + // Turn 1: an ordinary turn, parked warm for the next message. + await runWithKeepalive( + turn1("stop-warm"), + undefined, + controller.signal, + ctx, + ); + await flush(); + assert.equal(ctx.pool.get(key)?.state, "idle", "turn 1 parked warm"); + const warmEnv = calls.acquiredEnvs[0]; + + // Turn 2: continues on the SAME environment, and the user presses Stop mid-turn. + const origRunTurn = engine.runTurn.bind(engine); + engine.runTurn = async (env, request, emit, signal, opts) => { + gone = true; // the browser aborted its own chat stream + controller.abort(USER_STOP_ABORT_REASON); // the durable command reached this run + return origRunTurn(env, request, emit, signal, opts); + }; + const stopped = await runWithKeepalive( + turn2("stop-warm"), + undefined, + controller.signal, + ctx, + ); + await flush(); + + assert.equal(stopped.stopReason, "cancelled"); + assert.equal(calls.acquire, 1, "the Stop ran on the warm environment"); + assert.equal( + warmEnv.destroyed, + 0, + "a settled user Stop never destroys the sandbox", + ); + assert.equal( + ctx.pool.get(key)?.state, + "idle", + "re-parked warm, so the next message resumes instead of replaying cold", + ); + }); }); describe("runWithKeepalive: races and failures", () => { - it("a busy session is superseded (destroyed, awaited) and the new turn cold-starts", async () => { + it("a busy session REFUSES the racing turn: no eviction, no cold acquire", async () => { + // Single-turn admission (#6417, #5539, #5538). This branch used to `evict` the busy entry + // and cold-start ("supersede-busy"), which tore the sandbox out from under the turn that was + // still streaming on it. Both turns then died and the session stayed locked until the lease + // expired. The racing turn is now refused and the live turn's environment is untouched. const { engine, calls } = makeEngine(); const ctx = makeCtx(engine); await runWithKeepalive(turn1(), undefined, undefined, ctx); @@ -790,12 +864,41 @@ describe("runWithKeepalive: races and failures", () => { ctx.pool.checkoutIdle(key); assert.equal(ctx.pool.get(key)!.state, "busy"); - await runWithKeepalive(turn2(), undefined, undefined, ctx); + const refused = await runWithKeepalive(turn2(), undefined, undefined, ctx); + + assert.equal(refused.ok, false, "the racing turn is refused"); + assert.match( + String(refused.error), + /already running a turn/i, + "the refusal says a turn is already running, so the client can keep the text", + ); + assert.equal(env1.destroyed, 0, "the live turn keeps its warm environment"); + assert.equal(calls.acquire, 1, "no rival environment is acquired"); assert.equal( - env1.destroyed, - 1, - "the busy (racing) session is superseded/destroyed (awaited, no flush needed)", + ctx.pool.get(key)!.state, + "busy", + "the live turn still owns the pool entry", ); + }); + + it("a DESTROYED pool entry is still evicted and the new turn cold-starts", async () => { + // The other half of the old `else if (existing)` branch. A destroyed entry (a drain, or a + // teardown that already ran) has nothing in flight on it, so clearing the key and + // cold-starting is correct and costs nothing warm. Only `busy` refuses. + const { engine, calls } = makeEngine(); + const ctx = makeCtx(engine); + await runWithKeepalive(turn1(), undefined, undefined, ctx); + const key = "proj-1:s1"; + // Marked directly, because every public route that destroys a session also removes it from + // the map. A `destroyed` entry SEATED at its key is the residue of a race: `checkoutIdle` + // leaves its entry in the map while the turn runs, a teardown marks it destroyed underneath, + // and `repark` then refuses to resurrect it (`session-pool.ts`, the `destroyed` guard). + // Reproducing that race would test the pool, not this branch. + ctx.pool.get(key)!.state = "destroyed"; + + const r = await runWithKeepalive(turn2(), undefined, undefined, ctx); + + assert.equal(r.ok, true, "the new turn runs"); assert.equal(calls.acquire, 2, "the new turn cold-starts"); }); diff --git a/services/runner/tests/unit/session-keepalive-engine.test.ts b/services/runner/tests/unit/session-keepalive-engine.test.ts index 5de34915004..51f838e1c20 100644 --- a/services/runner/tests/unit/session-keepalive-engine.test.ts +++ b/services/runner/tests/unit/session-keepalive-engine.test.ts @@ -240,6 +240,18 @@ describe("acquireEnvironment / runTurn split", () => { data: { phase: "environment_starting" }, transient: true, }, + { + type: "data", + name: "agent-status", + data: { phase: "preparing_workspace" }, + transient: true, + }, + { + type: "data", + name: "agent-status", + data: { phase: "opening_session" }, + transient: true, + }, { type: "data", name: "agent-status", diff --git a/services/runner/tests/unit/session-ownership-release.test.ts b/services/runner/tests/unit/session-ownership-release.test.ts new file mode 100644 index 00000000000..b24cc7601b9 --- /dev/null +++ b/services/runner/tests/unit/session-ownership-release.test.ts @@ -0,0 +1,200 @@ +/** + * The shutdown release of `owner:session:` affinity claims. + * + * `claim_owner` on the API side never steals from a live owner, and nothing released the key, + * so a runner that exited while holding claims locked each of those sessions out of its own + * replacement for the rest of the 120-second lease. On the local sandbox provider that is a + * two-minute outage after every restart: the new replica refuses with "is not the owner of + * session ... Refusing to cold-start on the wrong host". + * + * These tests pin the two halves of the fix: the runner learns which sessions it owns from the + * beats it already sends, and the shutdown handler hands each one back with an inverse beat. + * + * Run: pnpm exec vitest run tests/unit/session-ownership-release.test.ts + */ +import { describe, it, beforeEach, afterEach, vi } from "vitest"; +import assert from "node:assert/strict"; + +const fetchCalls: Array<{ + url: string; + body: any; + headers?: RequestInit["headers"]; +}> = []; +let fetchImpl: ( + url: string, + init?: RequestInit, +) => Promise = async () => + new Response(JSON.stringify({}), { status: 200 }); + +vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => { + const body = init?.body ? JSON.parse(init.body as string) : undefined; + fetchCalls.push({ url, body, headers: init?.headers }); + return fetchImpl(url, init); +}); + +const { + claimSessionOwnership, + forgetOwnedSession, + ownedSessionCount, + recordOwnedSession, + releaseOwnedSessions, + releaseSessionOwnership, + REPLICA_ID, +} = await import("../../src/sessions/alive.ts"); +const { OWNER_TTL_SECONDS } = await import("../../src/sessions/contract.ts"); + +/** The API answers a claim beat with the winning replica. */ +const ownedBy = (replica: string) => async () => + new Response(JSON.stringify({ replica_id: replica }), { status: 200 }); + +beforeEach(() => { + fetchCalls.length = 0; + fetchImpl = ownedBy(REPLICA_ID); + process.env.AGENTA_RUNNER_TOKEN = "runner-secret"; +}); + +afterEach(async () => { + // The registry is module state; drop whatever a test left in it. + for (const id of ["sess-1", "sess-2", "sess-other", "sess-fail"]) { + forgetOwnedSession(id); + } + vi.restoreAllMocks(); + delete process.env.AGENTA_RUNNER_TOKEN; +}); + +describe("learning which sessions this replica owns", () => { + it("records a session whose claim this replica won", async () => { + await claimSessionOwnership("sess-1", "Bearer tok-1"); + assert.equal(ownedSessionCount(), 1); + }); + + it("records nothing when another replica owns the session", async () => { + fetchImpl = ownedBy("other-replica"); + await claimSessionOwnership("sess-other", "Bearer tok-1"); + assert.equal( + ownedSessionCount(), + 0, + "a lost claim must never be released later", + ); + }); + + it("records nothing when the claim call itself fails", async () => { + fetchImpl = async () => new Response("nope", { status: 503 }); + await claimSessionOwnership("sess-1", "Bearer tok-1"); + assert.equal(ownedSessionCount(), 0); + }); + + it("forgets a claim older than the affinity lease", async () => { + // Every beat records, so a long-lived runner would otherwise hold one entry (and one + // credential) per session it ever served. A claim older than the lease cannot still be held. + const t0 = 1_000_000; + recordOwnedSession("sess-1", "Bearer tok-1", t0); + assert.equal(ownedSessionCount(t0), 1); + + const expired = t0 + OWNER_TTL_SECONDS * 1000 + 1; + assert.equal(ownedSessionCount(expired), 0); + }); + + it("keeps a claim a later beat refreshed", async () => { + const t0 = 1_000_000; + recordOwnedSession("sess-1", "Bearer tok-1", t0); + const later = t0 + OWNER_TTL_SECONDS * 1000 - 1; + recordOwnedSession("sess-1", "Bearer tok-2", later); + + assert.equal( + ownedSessionCount(later + 10), + 1, + "a refreshed claim must not expire on its FIRST beat's age", + ); + }); +}); + +describe("the shutdown release", () => { + it("sends one inverse beat per owned session", async () => { + await claimSessionOwnership("sess-1", "Bearer tok-1"); + await claimSessionOwnership("sess-2", "Bearer tok-2"); + fetchCalls.length = 0; + + await releaseOwnedSessions(1_000); + + assert.equal(fetchCalls.length, 2); + const sessions = fetchCalls.map((c) => c.body.session_id).sort(); + assert.deepEqual(sessions, ["sess-1", "sess-2"]); + for (const call of fetchCalls) { + assert.ok(call.url.endsWith("/sessions/streams/heartbeat")); + assert.equal(call.body.release_owner, true); + assert.equal(call.body.replica_id, REPLICA_ID); + assert.equal( + (call.headers as Record)["x-agenta-runner-token"], + "runner-secret", + ); + assert.equal( + call.body.turn_id, + undefined, + "a departing runner asserts no turn", + ); + assert.equal( + call.body.is_running, + undefined, + "a departing runner asserts no liveness", + ); + } + }); + + it("forgets a released session, so a repeated shutdown sends nothing", async () => { + await claimSessionOwnership("sess-1", "Bearer tok-1"); + await releaseOwnedSessions(1_000); + assert.equal(ownedSessionCount(), 0); + + fetchCalls.length = 0; + await releaseOwnedSessions(1_000); + assert.deepEqual(fetchCalls, []); + }); + + it("sends nothing at all when this replica owns nothing", async () => { + await releaseOwnedSessions(1_000); + assert.deepEqual(fetchCalls, []); + }); + + it("never throws when the API refuses the release", async () => { + await claimSessionOwnership("sess-fail", "Bearer tok-1"); + fetchImpl = async () => new Response("boom", { status: 500 }); + + await releaseOwnedSessions(1_000); + + // Kept, not dropped: the release did not happen, and the 120-second lease is the fallback. + assert.equal(ownedSessionCount(), 1); + }); + + it("never throws when the API is unreachable", async () => { + await claimSessionOwnership("sess-fail", "Bearer tok-1"); + fetchImpl = async () => { + throw new Error("connect ECONNREFUSED"); + }; + + await releaseOwnedSessions(1_000); + assert.equal(ownedSessionCount(), 1); + }); + + it("returns once the deadline passes even if a release never answers", async () => { + await claimSessionOwnership("sess-1", "Bearer tok-1"); + fetchImpl = () => new Promise(() => {}); + + const started = Date.now(); + await releaseOwnedSessions(50); + + assert.ok( + Date.now() - started < 2_000, + "the shutdown release must never hold the process open", + ); + }); +}); + +describe("releaseSessionOwnership on its own", () => { + it("reports success only when the API accepts the release", async () => { + assert.equal(await releaseSessionOwnership("sess-1", "Bearer t"), true); + + fetchImpl = async () => new Response("no", { status: 404 }); + assert.equal(await releaseSessionOwnership("sess-1", "Bearer t"), false); + }); +}); diff --git a/services/runner/tests/unit/session-pool.test.ts b/services/runner/tests/unit/session-pool.test.ts index 7e6fb815f8d..58566ef3607 100644 --- a/services/runner/tests/unit/session-pool.test.ts +++ b/services/runner/tests/unit/session-pool.test.ts @@ -165,6 +165,7 @@ describe("readKeepaliveConfig", () => { "AGENTA_RUNNER_SESSION_KEEPALIVE", "AGENTA_RUNNER_SESSION_TTL_MS", "AGENTA_RUNNER_SESSION_APPROVAL_TTL_MS", + "AGENTA_RUNNER_SESSION_STOPPED_TTL_MS", "AGENTA_RUNNER_SESSION_POOL_MAX", "AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS", "AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM", @@ -183,13 +184,14 @@ describe("readKeepaliveConfig", () => { } }); - it("defaults: on, 60s idle, 10m approval, cap 8", () => { - // The approval window is the pending-interaction park: 10 minutes so a phone-latency - // answer warm-resumes instead of cold-replaying (mobile approvals plan §4b-4). + it("defaults: on, 60s idle, 10m approval and stopped, cap 8", () => { + // Both human-response windows last 10 minutes so the next action warm-resumes instead of + // cold-replaying (mobile approvals plan §4b-4 and Mahmoud's 2026-09-05 Stop decision). assert.deepEqual(readKeepaliveConfig("local"), { enabled: true, ttlMs: 60_000, approvalTtlMs: 600_000, + stoppedTtlMs: 600_000, poolMax: 8, }); }); @@ -228,6 +230,8 @@ describe("readKeepaliveConfig", () => { assert.deepEqual(readKeepaliveConfig("daytona"), { enabled: true, ttlMs: 120_000, + // The stopped sandbox remains billed for this ten-minute human-response window. + stoppedTtlMs: 600_000, approvalTtlMs: 120_000, poolMax: 20, }); @@ -238,6 +242,7 @@ describe("readKeepaliveConfig", () => { enabled: false, ttlMs: 0, approvalTtlMs: 0, + stoppedTtlMs: 600_000, poolMax: 20, }); process.env.AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS = "45000"; @@ -245,6 +250,7 @@ describe("readKeepaliveConfig", () => { enabled: true, ttlMs: 45_000, approvalTtlMs: 45_000, + stoppedTtlMs: 600_000, poolMax: 20, }); process.env.AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM = "7"; diff --git a/services/runner/tests/unit/session-reconstruct-history.test.ts b/services/runner/tests/unit/session-reconstruct-history.test.ts index ee8b20f3d6f..0cd9fd5c652 100644 --- a/services/runner/tests/unit/session-reconstruct-history.test.ts +++ b/services/runner/tests/unit/session-reconstruct-history.test.ts @@ -126,6 +126,63 @@ describe("reconstructHistoryIfNeeded", () => { assert.equal(fetchCalls, 0, "no query when the log is already known bad"); }); + it("replays a smart-truncated tool result", async () => { + recordsToReturn = [ + { + record_source: "agent", + attributes: { type: "tool_call", id: "toolu_big", name: "Bash", input: {} }, + }, + { + record_source: "agent", + attributes: { + type: "tool_result", + id: "toolu_big", + output: "partial…[truncated]", + _truncated: { fields: ["output"], original_bytes: 80_000 }, + }, + }, + ]; + const req = { messages: [userTurn] } as never; + const out = await reconstructHistoryIfNeeded(req, "sess-1", auth); + + assert.deepEqual(out?.messages, [ + { + role: "assistant", + content: [ + { + type: "tool_call", + toolCallId: "toolu_big", + toolName: "Bash", + input: {}, + }, + { + type: "tool_result", + toolCallId: "toolu_big", + toolName: "Bash", + output: "partial…[truncated]", + isError: undefined, + }, + ], + }, + userTurn, + ]); + }); + + it("refuses reconstruction from a legacy whole-record truncation", async () => { + recordsToReturn = [ + { + record_source: "agent", + attributes: { _truncated: true, _original_bytes: 80_000 }, + }, + ]; + const req = { messages: [userTurn] } as never; + + await assert.rejects( + () => reconstructHistoryIfNeeded(req, "sess-1", auth), + /truncated durable record/, + ); + }); + it("prepends reconstructed prior turns to the inbound message when enabled", async () => { vi.stubEnv("AGENTA_SESSIONS_RECONSTRUCT", "true"); recordsToReturn = [ diff --git a/services/runner/tests/unit/session-steer-mount-loss.test.ts b/services/runner/tests/unit/session-steer-mount-loss.test.ts index 5626470efca..7546c2399bc 100644 --- a/services/runner/tests/unit/session-steer-mount-loss.test.ts +++ b/services/runner/tests/unit/session-steer-mount-loss.test.ts @@ -307,20 +307,27 @@ function approvalReply(toolCallId: string, toolName: string): AgentRunRequest { // --- The scenario the bug report describes ----------------------------------------------- // describe("steer: a second message while a cold turn is running", () => { + // These pinned the OLD outcome: the second turn superseded the first (destroy its environment, + // cold-start a rival). Single-turn admission (#6417, #5539, #5538) replaces that with a refusal, + // which is a strictly better answer to the SAME hazard the reservation was built for. The + // reservation is still what makes the refusal possible: the running cold turn is seated as + // `busy` at its key, so the second turn finds it instead of logging `miss` and cold-acquiring a + // rival environment onto the shared durable cwd. + // + // NOTE ON THE HOLD: the second turn no longer acquires anything, so the first turn's hold is + // released by the REFUSAL settling, not by `onAcquire(2)`. + it("keeps the session's durable cwd, and the next turn succeeds", async () => { const host = makeHost(); const turn1Running = deferred(); - const steerAcquired = deferred(); + const steerSettled = deferred(); const { engine, calls } = makeEngine(host, { hold: async (envId, continuation) => { if (envId !== 1 || continuation) return; turn1Running.resolve(); - // The long turn runs until the steer's environment exists. - await steerAcquired.promise; - }, - onAcquire: (id) => { - if (id === 2) steerAcquired.resolve(); + // The long turn runs until the second message has been answered. + await steerSettled.promise; }, }); const { ctx } = makeCtx(engine); @@ -332,13 +339,26 @@ describe("steer: a second message while a cold turn is running", () => { () => {}, undefined, ctx, - ); - await Promise.all([first, steer]); + ).then((r) => { + steerSettled.resolve(); + return r; + }); + const [firstResult, steerResult] = await Promise.all([first, steer]); + assert.equal(steerResult.ok, false, "the second message is refused"); + assert.match( + String((steerResult as { error?: string }).error), + /already running a turn/i, + ); + assert.equal( + firstResult.ok, + true, + `the running turn was killed by the second message: ${(firstResult as { error?: string }).error}`, + ); assert.equal( host.dirExists, true, - `the durable cwd was destroyed by the steer: ${host.trace.join(" | ")}`, + `the durable cwd was destroyed by the second message: ${host.trace.join(" | ")}`, ); const third = await runWithKeepalive( @@ -350,30 +370,27 @@ describe("steer: a second message while a cold turn is running", () => { assert.equal( third.ok, true, - `the turn after the steer failed: ${(third as { error?: string }).error}`, + `the turn after the refusal failed: ${(third as { error?: string }).error}`, ); - // The steer superseded the first environment rather than running beside it, so exactly two - // environments existed and the third turn reused one of them warm. - assert.equal(calls.acquired.length, 2); + // The refused turn acquired nothing, so exactly ONE environment ever existed and the third + // turn continued it warm. Before admission this was 2 (supersede plus cold rebuild). + assert.equal(calls.acquired.length, 1); }); - it("supersedes the running turn instead of acquiring a rival environment", async () => { + it("refuses the second turn instead of acquiring a rival environment", async () => { const host = makeHost(); const turn1Running = deferred(); - const steerAcquired = deferred(); - // Ordering is the whole fix: the superseded environment's teardown must COMPLETE before the - // steer's acquire mounts, or the steer adopts a mount that is about to be pulled. + const steerSettled = deferred(); const order: string[] = []; const { engine } = makeEngine(host, { hold: async (envId, continuation) => { if (envId !== 1 || continuation) return; turn1Running.resolve(); - await steerAcquired.promise; + await steerSettled.promise; }, onAcquire: (id) => { order.push(`acquire:env${id}`); - if (id === 2) steerAcquired.resolve(); }, }); const { ctx } = makeCtx(engine); @@ -385,40 +402,36 @@ describe("steer: a second message while a cold turn is running", () => { () => {}, undefined, ctx, - ); + ).then((r) => { + steerSettled.resolve(); + return r; + }); await Promise.all([first, steer]); - // env1's unmount+rmSync happened, then env2 mounted fresh — never "already mounted (adopted)". - assert.deepEqual(order, ["acquire:env1", "acquire:env2"]); + // No second acquire at all: nothing to mount, nothing to unmount, nothing to adopt. + assert.deepEqual(order, ["acquire:env1"]); assert.ok( !host.trace.some((line) => line.includes("adopted")), - `the steer adopted the running turn's mount: ${host.trace.join(" | ")}`, + `the refused turn adopted the running turn's mount: ${host.trace.join(" | ")}`, ); assert.equal(host.mounted, true); assert.equal(host.dirExists, true); }); - it("survives a displaced turn that aborts (the API-side heartbeat fix)", async () => { - // The heartbeat bug means the displaced turn is never told it was superseded, so it runs to - // completion beside the steer. Suppose that is fixed and it aborts promptly instead: an - // aborted turn still routes to `env.destroy({ reason: "aborted" })`, which unmounts and - // deletes the shared cwd. Before the reservation, the abort only narrowed the race window. + it("emits no teardown for the refused turn, so the warm session survives", async () => { + // The old supersede path called `env.destroy` on the LIVE turn's environment, which unmounted + // and `rmSync`ed the shared cwd. That is the destruction half of the double-send bug. A + // refusal must touch no environment at all: the running turn keeps its sandbox and its native + // harness session, which is the warm-session constraint this whole slice is bound by. const host = makeHost(); const turn1Running = deferred(); - const steerAcquired = deferred(); + const steerSettled = deferred(); - const { engine } = makeEngine(host, { + const { engine, calls } = makeEngine(host, { hold: async (envId, continuation) => { if (envId !== 1 || continuation) return; turn1Running.resolve(); - await steerAcquired.promise; - }, - resultFor: (envId, continuation) => - envId === 1 && !continuation - ? { ok: false, error: "aborted", stopReason: "aborted" } - : undefined, - onAcquire: (id) => { - if (id === 2) steerAcquired.resolve(); + await steerSettled.promise; }, }); const { ctx } = makeCtx(engine); @@ -430,20 +443,20 @@ describe("steer: a second message while a cold turn is running", () => { () => {}, undefined, ctx, - ); + ).then((r) => { + steerSettled.resolve(); + return r; + }); await Promise.all([first, steer]); - assert.equal(host.dirExists, true, host.trace.join(" | ")); - const third = await runWithKeepalive( - req("still there?"), - () => {}, - undefined, - ctx, - ); assert.equal( - third.ok, - true, - `the turn after an aborted steer failed: ${(third as { error?: string }).error}`, + calls.acquired[0].destroyed, + 0, + `the running turn's environment was destroyed: ${host.trace.join(" | ")}`, + ); + assert.ok( + !host.trace.some((line) => line.includes("teardown")), + `a teardown ran during the refusal: ${host.trace.join(" | ")}`, ); }); }); diff --git a/services/runner/tests/unit/stuck-substitution-rebuild.test.ts b/services/runner/tests/unit/stuck-substitution-rebuild.test.ts new file mode 100644 index 00000000000..20c481bb849 --- /dev/null +++ b/services/runner/tests/unit/stuck-substitution-rebuild.test.ts @@ -0,0 +1,1088 @@ +/** + * A stuck sandbox is rebuilt on the SAME Daytona Secret. + * + * THE DEFECT (production runner logs, 2026-09-01..02). The preflight convicts a sandbox that + * never received its credential-substitution wiring, the acquire path destroys it and retries. + * The retry was stuck again in 4 of 7 observed rebuilds. Every rebuild deleted the stuck + * sandbox's Secret and allocated a NEW one within a second, so the retry never tested the one + * thing Daytona support confirmed works (2026-08-31): a new sandbox on the SAME Secret. + * + * These tests pin the fix at two levels. The provider must be able to destroy a sandbox while + * KEEPING its Secrets, and to create the next sandbox against an inherited lease without calling + * the Secret API at all. The acquire path must carry that lease from one attempt to the next, and + * delete it exactly once when no sandbox ends up owning it. + * + * Ownership is the whole risk here, so most of these cases are about who is allowed to DELETE: + * an attached lease never deletes, an indeterminate one refuses, a failed delete stays retryable + * rather than marking itself done, and overlapping releases share one delete. + * + * The last pair covers what the PERSON sees when every attempt is convicted: the standard + * credential-delivery copy and code, not the preflight's internal sentence. + * + * Run: pnpm exec vitest run tests/unit/stuck-substitution-rebuild.test.ts + */ +import assert from "node:assert/strict"; +import { beforeEach, describe, it } from "vitest"; + +import type { AgentRunRequest } from "../../src/protocol.ts"; +import { + acquireEnvironment, + type SandboxAgentDeps, +} from "../../src/engines/sandbox_agent.ts"; +import { + daytonaWithProcessLocalSecrets, + retainDaytonaSecretsOnDestroy, + takeDaytonaSecretLease, + type DaytonaProviderLike, +} from "../../src/engines/sandbox_agent/daytona-secret-provider.ts"; +import type { DaytonaSecretPlan } from "../../src/engines/sandbox_agent/daytona-secret-plan.ts"; +import { + DaytonaSecretLease, + type DaytonaSecretApi, +} from "../../src/engines/sandbox_agent/daytona-secrets.ts"; +import { STUCK_ACQUIRE_ATTEMPTS } from "../../src/engines/sandbox_agent/credential-preflight.ts"; +import { CREDENTIAL_DELIVERY_FAILED_MESSAGE } from "../../src/engines/sandbox_agent/errors.ts"; +import { resetRunnerConfigCache } from "../../src/config/runner-config.ts"; + +const GENERATION = "create-fingerprint-a"; + +const plan: DaytonaSecretPlan = { + environment: {}, + candidates: [ + { + ordinal: 0, + consumer: { kind: "model" }, + binding: { kind: "environment", name: "ANTHROPIC_API_KEY" }, + allowedHost: "api.anthropic.com", + value: "model-plaintext", + }, + ], +}; + +/** A second plan whose slot set differs, so an inherited lease cannot serve it. */ +const mcpPlan: DaytonaSecretPlan = { + environment: {}, + candidates: [ + ...plan.candidates, + { + ordinal: 1, + consumer: { kind: "http_mcp", server: "linear" }, + binding: { kind: "header", name: "Authorization" }, + allowedHost: "mcp.linear.app", + value: "mcp-plaintext", + }, + ], +}; + +interface SecretApiOptions { + /** Reject the first N deletes, so a release has to retry. */ + failDeletes?: number; +} + +/** A fake vault that counts what the runner asked Daytona to do, and never records a value. */ +function secretApi( + events: string[], + options: SecretApiOptions = {}, +): DaytonaSecretApi { + let count = 0; + let deleteFailures = options.failDeletes ?? 0; + return { + async create(input) { + count += 1; + const id = `secret-${count}`; + events.push(`secret:create:${id}`); + return { + id, + name: input.name, + placeholder: `dtn_secret_${count}`, + hosts: input.hosts, + }; + }, + async update(id) { + events.push(`secret:update:${id}`); + return { id, placeholder: "dtn_secret_1" }; + }, + async delete(id) { + if (deleteFailures > 0) { + deleteFailures -= 1; + events.push(`secret:delete-failed:${id}`); + throw new Error("daytona refused the delete"); + } + events.push(`secret:delete:${id}`); + }, + }; +} + +interface FakeProviderOptions { + /** Reject `create` on the Nth call, after Daytona would have made the remote sandbox. */ + createRejectsOn?: number; + /** Reject the first N `destroy` calls with a non-404, so absence cannot be confirmed. */ + destroyRejectsTimes?: number; +} + +function providerFactory(events: string[], options: FakeProviderOptions = {}) { + let created = 0; + let destroyFailures = options.destroyRejectsTimes ?? 0; + return (_attachments: Record): DaytonaProviderLike => ({ + name: "daytona", + async create() { + created += 1; + if (created === options.createRejectsOn) { + events.push("sandbox:create-failed"); + throw new Error("daemon never came up"); + } + const id = `sandbox-${created}`; + events.push(`sandbox:create:${id}`); + return id; + }, + async destroy(id) { + if (destroyFailures > 0) { + destroyFailures -= 1; + events.push(`sandbox:destroy-failed:${id}`); + throw new Error("daytona API is down"); + } + events.push(`sandbox:destroy:${id}`); + }, + async pause(id) { + events.push(`sandbox:pause:${id}`); + }, + async reconnect(id) { + events.push(`sandbox:reconnect:${id}`); + }, + }); +} + +const secretEvents = (events: string[]) => + events.filter((event) => event.startsWith("secret:")); + +/** Let every already-queued microtask and immediate run, so an interleaving is deterministic. */ +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +describe("the provider destroys a stuck sandbox and keeps its Secrets", () => { + it("hands back a detached lease instead of deleting", async () => { + const events: string[] = []; + const provider = daytonaWithProcessLocalSecrets( + providerFactory(events), + plan, + secretApi(events), + { + registry: new Map(), + cleanupDelayMilliseconds: 1_000, + createFingerprint: GENERATION, + }, + ); + + const id = await provider.create(); + retainDaytonaSecretsOnDestroy(provider, id); + await provider.destroy(id); + + assert.deepEqual(events, [ + "secret:create:secret-1", + "sandbox:create:sandbox-1", + "sandbox:destroy:sandbox-1", + ]); + const lease = takeDaytonaSecretLease(provider); + assert.ok(lease, "the destroy must hand back the lease it kept"); + assert.equal(lease.state, "detached"); + assert.equal(lease.allocation.created.length, 1); + }); + + it("accepts the prefixed id the sandbox-agent handle exposes", async () => { + // The handle reports `"daytona/"` and the registry is keyed by the raw id. The acquire + // path only ever holds the prefixed form, so a retain keyed on it has to match. + const events: string[] = []; + const provider = daytonaWithProcessLocalSecrets( + providerFactory(events), + plan, + secretApi(events), + { + registry: new Map(), + cleanupDelayMilliseconds: 1_000, + createFingerprint: GENERATION, + }, + ); + + const id = await provider.create(); + retainDaytonaSecretsOnDestroy(provider, `daytona/${id}`); + await provider.destroy(id); + + const lease = takeDaytonaSecretLease(provider); + assert.ok(lease, "the prefixed id must name the same sandbox"); + assert.equal(lease.state, "detached"); + assert.deepEqual(secretEvents(events), ["secret:create:secret-1"]); + }); + + it("ignores a retain keyed to a different sandbox", async () => { + const events: string[] = []; + const provider = daytonaWithProcessLocalSecrets( + providerFactory(events), + plan, + secretApi(events), + { + registry: new Map(), + cleanupDelayMilliseconds: 1_000, + createFingerprint: GENERATION, + }, + ); + + const id = await provider.create(); + retainDaytonaSecretsOnDestroy(provider, "some-other-sandbox"); + await provider.destroy(id); + + assert.equal( + takeDaytonaSecretLease(provider), + undefined, + "the retain key names another sandbox, so this cleanup must delete", + ); + assert.deepEqual(secretEvents(events), [ + "secret:create:secret-1", + "secret:delete:secret-1", + ]); + }); + + it("creates the next sandbox against an inherited lease without touching the Secret API", async () => { + const events: string[] = []; + const api = secretApi(events); + const registry = new Map(); + const first = daytonaWithProcessLocalSecrets( + providerFactory(events), + plan, + api, + { + registry, + cleanupDelayMilliseconds: 1_000, + createFingerprint: GENERATION, + }, + ); + const firstId = await first.create(); + retainDaytonaSecretsOnDestroy(first, firstId); + await first.destroy(firstId); + const lease = takeDaytonaSecretLease(first)!; + + const events2: string[] = []; + const second = daytonaWithProcessLocalSecrets( + providerFactory(events2), + plan, + api, + { + registry, + cleanupDelayMilliseconds: 1_000, + createFingerprint: GENERATION, + inheritedLease: lease, + }, + ); + const secondId = await second.create(); + + assert.deepEqual( + events2, + ["sandbox:create:sandbox-1"], + "an inherited lease must not allocate a new Secret", + ); + assert.equal(lease.state, "attached"); + + // The second sandbox now owns the lease, so its own teardown deletes it. The Secret API + // writes into `events`, the sandbox provider into `events2`. + await second.destroy(secondId); + assert.deepEqual(events2.slice(1), ["sandbox:destroy:sandbox-1"]); + assert.deepEqual(secretEvents(events), [ + "secret:create:secret-1", + "secret:delete:secret-1", + ]); + assert.equal(lease.state, "released"); + }); + + it("releases nothing while a live sandbox holds the lease", async () => { + const events: string[] = []; + const api = secretApi(events); + const registry = new Map(); + const first = daytonaWithProcessLocalSecrets( + providerFactory(events), + plan, + api, + { + registry, + cleanupDelayMilliseconds: 1_000, + createFingerprint: GENERATION, + }, + ); + const firstId = await first.create(); + retainDaytonaSecretsOnDestroy(first, firstId); + await first.destroy(firstId); + const lease = takeDaytonaSecretLease(first)!; + + const second = daytonaWithProcessLocalSecrets( + providerFactory([]), + plan, + api, + { + registry, + cleanupDelayMilliseconds: 1_000, + createFingerprint: GENERATION, + inheritedLease: lease, + }, + ); + await second.create(); + + const before = events.length; + await lease.release(); + assert.equal( + events.length, + before, + "release must not delete a Secret a live sandbox is mounted on", + ); + assert.equal(lease.state, "attached"); + }); + + it("leaves the lease indeterminate when an inherited create rejects", async () => { + const events: string[] = []; + const api = secretApi(events); + const registry = new Map(); + const logs: string[] = []; + // The lease keeps the logger of the provider that minted it, so both providers write here. + const first = daytonaWithProcessLocalSecrets( + providerFactory(events), + plan, + api, + { + registry, + cleanupDelayMilliseconds: 1_000, + createFingerprint: GENERATION, + log: (message) => logs.push(message), + }, + ); + const firstId = await first.create(); + retainDaytonaSecretsOnDestroy(first, firstId); + await first.destroy(firstId); + const lease = takeDaytonaSecretLease(first)!; + + // Daytona made the remote sandbox and the daemon never came up. The create rejects with no + // sandbox id, so nothing can prove the remote sandbox is absent. + const second = daytonaWithProcessLocalSecrets( + providerFactory(events, { createRejectsOn: 1 }), + plan, + api, + { + registry, + cleanupDelayMilliseconds: 1_000, + createFingerprint: GENERATION, + inheritedLease: lease, + log: (message) => logs.push(message), + }, + ); + await assert.rejects(() => second.create(), /daemon never came up/); + + assert.equal(lease.state, "indeterminate"); + const before = secretEvents(events).length; + await lease.release(); + assert.deepEqual( + secretEvents(events).length, + before, + "a lease that cannot prove absence must never delete", + ); + assert.equal( + logs.filter((line) => line.includes("reason=create-outcome-unknown")) + .length, + 1, + "exactly one line explains the retained Secrets", + ); + }); + + it("stays releasable when the delete fails, and a second release succeeds", async () => { + const events: string[] = []; + const provider = daytonaWithProcessLocalSecrets( + providerFactory(events), + plan, + secretApi(events, { failDeletes: 1 }), + { + registry: new Map(), + cleanupDelayMilliseconds: 1_000, + createFingerprint: GENERATION, + }, + ); + const id = await provider.create(); + retainDaytonaSecretsOnDestroy(provider, id); + await provider.destroy(id); + const lease = takeDaytonaSecretLease(provider)!; + + await assert.rejects(() => lease.release()); + assert.equal( + lease.state, + "detached", + "a failed delete must leave the lease releasable", + ); + + await lease.release(); + assert.equal(lease.state, "released"); + assert.deepEqual(secretEvents(events), [ + "secret:create:secret-1", + "secret:delete-failed:secret-1", + "secret:delete:secret-1", + ]); + }); + + it("shares one delete between overlapping release calls", async () => { + const events: string[] = []; + const provider = daytonaWithProcessLocalSecrets( + providerFactory(events), + plan, + secretApi(events), + { + registry: new Map(), + cleanupDelayMilliseconds: 1_000, + createFingerprint: GENERATION, + }, + ); + const id = await provider.create(); + retainDaytonaSecretsOnDestroy(provider, id); + await provider.destroy(id); + const lease = takeDaytonaSecretLease(provider)!; + + // Both callers start before either finishes. The second must join the in-flight delete + // rather than issue its own against the same provider ids. + await Promise.all([lease.release(), lease.release()]); + + assert.deepEqual(secretEvents(events), [ + "secret:create:secret-1", + "secret:delete:secret-1", + ]); + assert.equal(lease.state, "released"); + }); + + it("both overlapping callers see a failed delete, and the next release succeeds", async () => { + const events: string[] = []; + const provider = daytonaWithProcessLocalSecrets( + providerFactory(events), + plan, + secretApi(events, { failDeletes: 1 }), + { + registry: new Map(), + cleanupDelayMilliseconds: 1_000, + createFingerprint: GENERATION, + }, + ); + const id = await provider.create(); + retainDaytonaSecretsOnDestroy(provider, id); + await provider.destroy(id); + const lease = takeDaytonaSecretLease(provider)!; + + // One delete, one rejection, delivered to both callers. Neither may conclude it succeeded. + const first = lease.release(); + const second = lease.release(); + await assert.rejects(() => first); + await assert.rejects(() => second); + assert.equal(lease.state, "detached"); + + await lease.release(); + assert.equal(lease.state, "released"); + assert.deepEqual(secretEvents(events), [ + "secret:create:secret-1", + "secret:delete-failed:secret-1", + "secret:delete:secret-1", + ]); + }); + + it("says the indeterminate refusal once, however many callers ask", async () => { + const events: string[] = []; + const logs: string[] = []; + const api = secretApi(events); + const registry = new Map(); + const first = daytonaWithProcessLocalSecrets( + providerFactory(events), + plan, + api, + { + registry, + cleanupDelayMilliseconds: 1_000, + createFingerprint: GENERATION, + log: (message) => logs.push(message), + }, + ); + const firstId = await first.create(); + retainDaytonaSecretsOnDestroy(first, firstId); + await first.destroy(firstId); + const lease = takeDaytonaSecretLease(first)!; + + const second = daytonaWithProcessLocalSecrets( + providerFactory(events, { createRejectsOn: 1 }), + plan, + api, + { + registry, + cleanupDelayMilliseconds: 1_000, + createFingerprint: GENERATION, + inheritedLease: lease, + log: (message) => logs.push(message), + }, + ); + await assert.rejects(() => second.create()); + + await lease.release(); + await lease.release(); + await lease.release(); + + assert.equal( + logs.filter((line) => line.includes("reason=create-outcome-unknown")) + .length, + 1, + "a retried teardown must not read as several separate leaks", + ); + }); + + it("never lets a replacement cleanup timer destroy a reconnected sandbox", async () => { + // THE RACE. A cleanup retry clears its own timer handle before it awaits the destroy, so a + // reconnect arriving in that window finds nothing to cancel and queues behind it. If that + // destroy then fails, the cleanup arms a REPLACEMENT timer while the reconnect is still + // waiting. The reconnect then succeeds, and the replacement later deletes the sandbox it just + // reconnected, along with its Secrets. + const events: string[] = []; + const timers: Array<() => void> = []; + let destroyCalls = 0; + let releaseSecondDestroy: (() => void) | undefined; + const secondDestroyBlocked = new Promise((resolve) => { + releaseSecondDestroy = resolve; + }); + + const provider = daytonaWithProcessLocalSecrets( + (): DaytonaProviderLike => ({ + name: "daytona", + async create() { + events.push("sandbox:create:sandbox-1"); + return "sandbox-1"; + }, + async destroy(id) { + destroyCalls += 1; + if (destroyCalls === 2) await secondDestroyBlocked; + if (destroyCalls <= 2) { + events.push(`sandbox:destroy-failed:${id}`); + throw new Error("daytona API is down"); + } + events.push(`sandbox:destroy:${id}`); + }, + async reconnect(id) { + events.push(`sandbox:reconnect:${id}`); + }, + }), + plan, + secretApi(events), + { + registry: new Map(), + cleanupDelayMilliseconds: 1_000, + createFingerprint: GENERATION, + setCleanupTimer: ((run: () => void) => { + timers.push(run); + return { unref() {} }; + }) as never, + clearCleanupTimer: (() => {}) as never, + }, + ); + + const id = await provider.create(); + // Destroy #1 fails, so the first retry timer is armed. + await assert.rejects(() => provider.destroy(id)); + assert.equal(timers.length, 1); + + // The retry starts and blocks inside destroy #2, having already cleared its timer handle. + timers[0](); + await flush(); + + // The reconnect finds no timer to cancel and queues behind the running cleanup. + const reconnecting = provider.reconnect!(id); + await flush(); + + // Destroy #2 fails, which arms the replacement timer while the reconnect is still waiting. + releaseSecondDestroy!(); + await flush(); + assert.equal(timers.length, 2, "the failed retry arms a replacement"); + + await reconnecting; + assert.ok( + events.includes("sandbox:reconnect:sandbox-1"), + "the reconnect must succeed", + ); + + // The replacement fires. It must find itself invalidated by the reconnect that overtook it. + timers[1](); + await flush(); + + assert.deepEqual( + events.filter((event) => event.startsWith("sandbox:destroy:")), + [], + "the reconnected sandbox must never be destroyed by a stale timer", + ); + assert.deepEqual(secretEvents(events), ["secret:create:secret-1"]); + }); + + it("hands back no lease when the retained destroy itself fails", async () => { + // A destroy that rejects with anything but a 404 cannot prove the sandbox is gone, so the + // cleanup re-raises before it reaches the lease. The Secret and the sandbox are stranded + // together, exactly as a failed destroy has always stranded them, and the caller allocates + // fresh rather than mounting Secrets that may still be attached to a live sandbox. + const events: string[] = []; + const provider = daytonaWithProcessLocalSecrets( + providerFactory(events, { destroyRejectsTimes: 1 }), + plan, + secretApi(events), + { + registry: new Map(), + cleanupDelayMilliseconds: 1_000, + createFingerprint: GENERATION, + }, + ); + const id = await provider.create(); + retainDaytonaSecretsOnDestroy(provider, id); + + await assert.rejects(() => provider.destroy(id), /daytona API is down/); + assert.equal(takeDaytonaSecretLease(provider), undefined); + assert.deepEqual(secretEvents(events), ["secret:create:secret-1"]); + }); + + it("refuses an inherited lease whose slot set does not match the plan", async () => { + const events: string[] = []; + const api = secretApi(events); + const registry = new Map(); + const logs: string[] = []; + const first = daytonaWithProcessLocalSecrets( + providerFactory(events), + plan, + api, + { + registry, + cleanupDelayMilliseconds: 1_000, + createFingerprint: GENERATION, + }, + ); + const firstId = await first.create(); + retainDaytonaSecretsOnDestroy(first, firstId); + await first.destroy(firstId); + const lease = takeDaytonaSecretLease(first)!; + + // The second provider wants a model slot AND an MCP slot. The inherited lease has only the + // model slot, so mounting it would fail later with a missing-placeholder message. + const second = daytonaWithProcessLocalSecrets( + providerFactory([]), + mcpPlan, + api, + { + registry, + cleanupDelayMilliseconds: 1_000, + createFingerprint: GENERATION, + inheritedLease: lease, + log: (message) => logs.push(message), + }, + ); + await second.create(); + + assert.equal(lease.state, "released", "the unusable lease is deleted"); + assert.deepEqual(secretEvents(events), [ + "secret:create:secret-1", + "secret:delete:secret-1", + "secret:create:secret-2", + "secret:create:secret-3", + ]); + assert.equal( + logs.filter((line) => line.includes("inherited lease unusable")).length, + 1, + ); + }); +}); + +describe("the stuck-acquire budget", () => { + it("allows two rebuilds after the first stuck sandbox", () => { + assert.equal(STUCK_ACQUIRE_ATTEMPTS, 3); + }); +}); + +// ---- acquire level ------------------------------------------------------------------- // + +const daytonaRequest: AgentRunRequest = { + harness: "claude", + sandbox: "daytona", + sessionId: "sess-stuck", + streamId: "stream-1", + messages: [{ role: "user", content: "hello" }], + modelConnection: { + provider: "anthropic", + deployment: "direct", + credentialMode: "env", + endpoint: { baseUrl: "https://api.anthropic.com" }, + credentials: [ + { + binding: { kind: "environment", name: "ANTHROPIC_API_KEY" }, + value: "sk-ant-fixture-value", + usage: "opaque_http", + }, + ], + }, + telemetry: { + exporters: { otlp: { headers: { authorization: "ApiKey abc" } } }, + } as never, +}; + +interface AcquireFixtureOptions { + /** Fail the harness session on the Nth attempt, so the attempt fails without being stuck. */ + sessionFailsOnAttempt?: number; + /** Abort this signal as soon as an attempt is convicted stuck. */ + abortWhenStuck?: AbortController; + /** Reject the first N sandbox destroys, so teardown cannot confirm absence. */ + destroyRejectsTimes?: number; + /** Reject the first N Secret deletes, so teardown cannot finish the cleanup. */ + failDeletes?: number; +} + +/** + * Drive the REAL acquire path over the REAL Secret provider, with only the transport faked. + * + * `startSandboxAgent` stands in for the sandbox-agent package: it calls the provider's `create` + * and returns a handle whose `destroySandbox` calls the provider's `destroy`. That is the wiring + * the lease's ownership rules depend on, so a fake that skipped it would prove nothing. + * + * The handle's `sandboxId` is the PREFIXED `"daytona/"` the real client exposes, while + * `destroy` gets the raw id. Faking that asymmetry is load-bearing: a fixture that reported the + * raw id would let a retain keyed on the handle's id pass here and match nothing in production. + */ +function acquireFixture( + verdicts: Array<"ok" | "stuck">, + options: AcquireFixtureOptions = {}, +) { + const events: string[] = []; + const api = secretApi(events, { + ...(options.failDeletes ? { failDeletes: options.failDeletes } : {}), + }); + const registry = new Map(); + const buildFake = providerFactory(events, { + ...(options.destroyRejectsTimes + ? { destroyRejectsTimes: options.destroyRejectsTimes } + : {}), + }); + const preflightCalls: number[] = []; + const logs: string[] = []; + // The provider's cleanup-retry timer, captured instead of scheduled, so a test can run it. + const timers: Array<() => void> = []; + let attempts = 0; + + const deps: SandboxAgentDeps = { + log: (message) => logs.push(message), + createDaytonaCwd: (durable?: string) => + durable ?? "/home/sandbox/agenta-fake-cwd", + createLocalCwd: (durable?: string) => durable ?? "/tmp/agenta-fake-cwd", + resolveSkillDirs: () => ({ skills: [], cleanup: () => {} }), + buildDaemonEnv: () => ({}), + resolveDaemonBinary: () => "/bin/sandbox-agent", + signSessionMountCredentials: (async () => null) as never, + signAgentMountCredentials: (async () => null) as never, + readStoredSandboxPointer: (async () => undefined) as never, + buildSandboxProvider: ((...args: unknown[]) => + daytonaWithProcessLocalSecrets( + buildFake, + args[6] as DaytonaSecretPlan, + api, + { + registry, + cleanupDelayMilliseconds: 1_000, + createFingerprint: GENERATION, + log: (message) => logs.push(message), + setCleanupTimer: ((run: () => void) => { + timers.push(run); + return { unref() {} }; + }) as never, + ...(args[7] as { inheritedLease?: DaytonaSecretLease }), + }, + )) as never, + createPersist: () => ({}) as never, + startSandboxAgent: (async (startOptions: any) => { + attempts += 1; + const thisAttempt = attempts; + const provider = startOptions.sandbox; + const rawSandboxId = await provider.create(); + return { + sandboxId: `daytona/${rawSandboxId}`, + async createSession() { + if (thisAttempt === options.sessionFailsOnAttempt) { + throw new Error("the harness session refused to open"); + } + return { + id: "session-1", + agentSessionId: "agent-fake-1", + onEvent() {}, + onPermissionRequest() {}, + async prompt() { + return { + stopReason: "complete", + usage: { inputTokens: 1, outputTokens: 1 }, + }; + }, + }; + }, + async destroySession() {}, + async destroySandbox() { + await provider.destroy(rawSandboxId); + }, + async dispose() {}, + }; + }) as never, + prepareWorkspace: (async () => ({ cleanup: async () => {} })) as never, + prepareDaytonaPiAssets: (async () => true) as never, + discoverTunnelEndpoint: (async () => null) as never, + probeCapabilities: (async () => ({ + source: "probed", + capabilities: { + mcpTools: true, + toolCalls: true, + usage: true, + streamingDeltas: true, + }, + })) as never, + applyModel: (async (_session: unknown, model: string | undefined) => + model ?? "resolved-model") as never, + startToolRelay: (() => ({ stop: async () => {} })) as never, + localRelayHost: (() => "local-relay-host") as never, + sandboxRelayHost: (() => "sandbox-relay-host") as never, + awaitCredentialSubstitution: (async () => { + preflightCalls.push(preflightCalls.length + 1); + const verdict = verdicts.shift() ?? "ok"; + if (verdict === "stuck") options.abortWhenStuck?.abort(); + return verdict; + }) as never, + }; + + return { deps, events, preflightCalls, logs, timers }; +} + +describe("acquireEnvironment rebuilds a stuck sandbox on the same Secret", () => { + beforeEach(() => { + process.env.AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS = "local,daytona"; + process.env.AGENTA_RUNNER_DAYTONA_API_KEY = "test-key"; + resetRunnerConfigCache(); + }); + + it("allocates one Secret across a stuck attempt and a healthy retry", async () => { + const { deps, events, preflightCalls } = acquireFixture(["stuck", "ok"]); + + const result = await acquireEnvironment(daytonaRequest, deps); + + assert.equal(result.ok, true, `acquire failed: ${(result as any).error}`); + assert.equal(preflightCalls.length, 2, "the retry must run the preflight"); + assert.deepEqual( + secretEvents(events), + ["secret:create:secret-1"], + "the rebuild must reuse the first allocation and delete nothing", + ); + assert.deepEqual( + events.filter((event) => event.startsWith("sandbox:")), + [ + "sandbox:create:sandbox-1", + "sandbox:destroy:sandbox-1", + "sandbox:create:sandbox-2", + ], + ); + + if (result.ok) await result.env.destroy({ reason: "failed-turn" }); + assert.deepEqual( + secretEvents(events), + ["secret:create:secret-1", "secret:delete:secret-1"], + "the Secret is deleted once, at the environment's own teardown", + ); + }); + + it("tries a third sandbox and deletes the Secret once when every attempt is stuck", async () => { + const { deps, events, preflightCalls } = acquireFixture([ + "stuck", + "stuck", + "stuck", + ]); + + const result = await acquireEnvironment(daytonaRequest, deps); + + assert.equal(result.ok, false); + assert.equal( + (result as { stuckSubstitution?: boolean }).stuckSubstitution, + true, + ); + assert.equal(preflightCalls.length, 3, "three attempts, not two"); + assert.deepEqual( + secretEvents(events), + ["secret:create:secret-1", "secret:delete:secret-1"], + "one allocation for the whole run, deleted once when the run gives up", + ); + assert.deepEqual( + events.filter((event) => event.startsWith("sandbox:create")), + [ + "sandbox:create:sandbox-1", + "sandbox:create:sandbox-2", + "sandbox:create:sandbox-3", + ], + ); + }); + + it("reports a doubly stuck acquire as a credential-delivery failure", async () => { + // The user used to read the preflight's internal sentence, coded `agent_run_failed`, which + // the web cannot offer a retry for. Every attempt being convicted means the model key never + // reached the model, which is the class the client already knows how to handle. + const { deps } = acquireFixture(["stuck", "stuck", "stuck"]); + const emitted: Array> = []; + + const result = await acquireEnvironment( + daytonaRequest, + deps, + undefined, + undefined, + (event) => emitted.push(event as Record), + ); + + assert.equal(result.ok, false); + assert.equal( + (result as { error: string }).error, + CREDENTIAL_DELIVERY_FAILED_MESSAGE, + ); + assert.equal( + (result as { error: string }).error.includes("placeholder"), + false, + "the internal sentence belongs in the runner log, not in the chat", + ); + assert.deepEqual( + emitted.filter((event) => event.type === "error"), + [ + { + type: "error", + message: CREDENTIAL_DELIVERY_FAILED_MESSAGE, + code: "credential_delivery_failed", + }, + ], + "exactly one error event, carrying the class the client renders a retry from", + ); + }); + + it("says nothing to the user about an attempt that was rebuilt successfully", async () => { + const { deps } = acquireFixture(["stuck", "ok"]); + const emitted: Array> = []; + + const result = await acquireEnvironment( + daytonaRequest, + deps, + undefined, + undefined, + (event) => emitted.push(event as Record), + ); + + assert.equal(result.ok, true); + assert.deepEqual( + emitted.filter((event) => event.type === "error"), + [], + "a recovered rebuild is not a failure the user should hear about", + ); + if (result.ok) await result.env.destroy({ reason: "failed-turn" }); + }); + + it("logs a failed sandbox delete at teardown and retries the whole cleanup", async () => { + // The environment is already marked destroyed and dropped from the in-flight map by the time + // the rejection is swallowed, so without the retry nothing is left holding the sandbox or its + // Secret. Both used to disappear here without a line in the log. + const { deps, events, logs, timers } = acquireFixture(["ok"], { + destroyRejectsTimes: 1, + }); + + const result = await acquireEnvironment(daytonaRequest, deps); + assert.equal(result.ok, true); + if (!result.ok) return; + await result.env.destroy({ reason: "failed-turn" }); + + assert.ok( + logs.some((line) => line.startsWith("sandbox delete failed sandbox=")), + "the swallowed rejection must reach the operator log", + ); + assert.equal(timers.length, 1, "the cleanup retry must be armed"); + assert.deepEqual(secretEvents(events), ["secret:create:secret-1"]); + + // The later attempt destroys the sandbox and deletes the Secret, exactly once each. + timers[0](); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(secretEvents(events), [ + "secret:create:secret-1", + "secret:delete:secret-1", + ]); + assert.deepEqual( + events.filter((event) => event.startsWith("sandbox:destroy")), + ["sandbox:destroy-failed:sandbox-1", "sandbox:destroy:sandbox-1"], + ); + }); + + it("logs a failed Secret delete at teardown and retries only the delete", async () => { + const { deps, events, logs, timers } = acquireFixture(["ok"], { + failDeletes: 1, + }); + + const result = await acquireEnvironment(daytonaRequest, deps); + assert.equal(result.ok, true); + if (!result.ok) return; + await result.env.destroy({ reason: "failed-turn" }); + + assert.ok( + logs.some((line) => line.startsWith("sandbox delete failed sandbox=")), + "the swallowed rejection must reach the operator log", + ); + assert.ok( + logs.some((line) => + line.includes("cleanup failed sandbox=sandbox-1 reason=secret-delete"), + ), + "the provider names which half of the cleanup failed", + ); + assert.equal(timers.length, 1, "the cleanup retry must be armed"); + + timers[0](); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(secretEvents(events), [ + "secret:create:secret-1", + "secret:delete-failed:secret-1", + "secret:delete:secret-1", + ]); + }); + + it("never returns the lease to its caller", async () => { + const { deps } = acquireFixture(["stuck", "stuck", "stuck"]); + + const result = await acquireEnvironment(daytonaRequest, deps); + + assert.deepEqual( + Object.keys(result).sort(), + ["error", "ok", "stuckSubstitution"], + "the lease is an ownership token and must not leave the acquire loop", + ); + }); + + it("releases the lease once when the signal aborts before the retry", async () => { + const abort = new AbortController(); + const { deps, events, preflightCalls } = acquireFixture(["stuck", "ok"], { + abortWhenStuck: abort, + }); + + const result = await acquireEnvironment(daytonaRequest, deps, abort.signal); + + assert.equal(result.ok, false); + assert.equal(preflightCalls.length, 1, "the abort stops the rebuild"); + assert.deepEqual( + secretEvents(events), + ["secret:create:secret-1", "secret:delete:secret-1"], + "an abandoned lease is deleted exactly once", + ); + }); + + it("deletes the Secret exactly once when the retry fails for another reason", async () => { + // Attempt 1 is stuck and hands over its lease. Attempt 2 adopts it and then fails to open + // the harness session, so its own teardown deletes the Secret. The loop's release must find + // the lease already released and add no second delete. + const { deps, events, preflightCalls } = acquireFixture(["stuck", "ok"], { + sessionFailsOnAttempt: 2, + }); + + const result = await acquireEnvironment(daytonaRequest, deps); + + assert.equal(result.ok, false); + // The preflight is kicked off right after the sandbox exists and only awaited at the end, so + // attempt 2 starts one even though it fails at the session before reading the verdict. + assert.equal(preflightCalls.length, 2); + assert.deepEqual(secretEvents(events), [ + "secret:create:secret-1", + "secret:delete:secret-1", + ]); + }); +}); diff --git a/services/runner/tests/unit/teardown.test.ts b/services/runner/tests/unit/teardown.test.ts index 9ac8bdf785d..47b266dcabb 100644 --- a/services/runner/tests/unit/teardown.test.ts +++ b/services/runner/tests/unit/teardown.test.ts @@ -13,6 +13,8 @@ describe("sandbox teardown disposition", () => { ["kill", "delete"], ["failed-turn", "delete"], ["aborted", "delete"], + // A settled user Stop keeps the sandbox; an unsettled one stays "aborted". + ["cancelled", "stop"], ["compatibility-mismatch", "delete"], // Lifecycle migration, step 1: the four named layers. Only the two whose daemon is sound // may park. See `teardown.ts`. diff --git a/services/runner/tests/unit/turn-settle.test.ts b/services/runner/tests/unit/turn-settle.test.ts new file mode 100644 index 00000000000..061891162bf --- /dev/null +++ b/services/runner/tests/unit/turn-settle.test.ts @@ -0,0 +1,227 @@ +/** + * A turn must reach exactly one terminal outcome, even when `run()` never returns. + * + * The runner writes its terminal record, and releases the alive watchdog, downstream of + * `await run(...)`. A run that never settles therefore leaves the session announcing + * `running=true` every thirty seconds with no ending ever written — issue #6418, and the shape + * behind #6100 and #5327 too. `awaitTurnOrAbandon` bounds that wait. + * + * The contract these tests hold: the happy path is untouched and leaves no timer armed; giving + * up always tries an abort FIRST, because most hangs unwind from one; and the caller is only + * told to write its own ending when the run is genuinely still pending afterwards. + */ + +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { + ABANDON_GRACE_ENV, + DEFAULT_ABANDON_GRACE_MS, + DEFAULT_HARD_DEADLINE_MS, + HARD_DEADLINE_ENV, + awaitTurnOrAbandon, + resolveTurnSettleLimits, + type Clock, + type TurnSettleLimits, +} from "../../src/sessions/turn-settle.ts"; +import { DEFAULT_TOTAL_DEADLINE_MS } from "../../src/engines/sandbox_agent/run-limits.ts"; + +function fakeClock(): Clock & { fireAll(): Promise; pending(): number } { + let nextId = 1; + const timers = new Map void>(); + return { + setTimeout(fn: () => void) { + const id = nextId++; + timers.set(id, fn); + return id as unknown as NodeJS.Timeout; + }, + clearTimeout(handle: NodeJS.Timeout) { + timers.delete(handle as unknown as number); + }, + pending: () => timers.size, + async fireAll() { + for (const [id, fn] of [...timers.entries()]) { + timers.delete(id); + fn(); + } + await new Promise((resolve) => setTimeout(resolve, 0)); + }, + }; +} + +const limits: TurnSettleLimits = { + hardDeadlineMs: 10_000, + abandonGraceMs: 1_000, +}; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("awaitTurnOrAbandon", () => { + it("returns the run's own result and leaves no timer armed", async () => { + const clock = fakeClock(); + const abort = vi.fn(); + + const outcome = await awaitTurnOrAbandon({ + run: Promise.resolve({ ok: true }), + abort, + limits, + clock, + }); + + expect(outcome).toEqual({ settled: true, value: { ok: true } }); + expect(abort).not.toHaveBeenCalled(); + expect(clock.pending()).toBe(0); + }); + + it("rethrows a run that rejects, so the caller's own catch still owns the error", async () => { + const clock = fakeClock(); + + await expect( + awaitTurnOrAbandon({ + run: Promise.reject(new Error("harness blew up")), + abort: vi.fn(), + limits, + clock, + }), + ).rejects.toThrow("harness blew up"); + expect(clock.pending()).toBe(0); + }); + + it("aborts first when the platform says the turn is no longer current", async () => { + const clock = fakeClock(); + let finishRun: ((value: unknown) => void) | undefined; + const run = new Promise((resolve) => { + finishRun = resolve; + }); + // The real run unwinds from its abort; model that. + const abort = vi.fn(() => finishRun?.({ ok: false, error: "cancelled" })); + + const settling = awaitTurnOrAbandon({ + run, + abort, + interrupted: Promise.resolve("stopped by the user"), + limits, + clock, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(abort).toHaveBeenCalledTimes(1); + await expect(settling).resolves.toEqual({ + settled: true, + value: { ok: false, error: "cancelled" }, + }); + expect(clock.pending()).toBe(0); + }); + + it("gives up and hands the caller a reason when the run will not unwind", async () => { + const clock = fakeClock(); + // The wedged case: aborting changes nothing, because the pending ACP request cannot settle. + const run = new Promise(() => {}); + const abort = vi.fn(); + + const settling = awaitTurnOrAbandon({ + run, + abort, + interrupted: Promise.resolve("declared lost by the platform"), + limits, + clock, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(abort).toHaveBeenCalledTimes(1); + await clock.fireAll(); // the grace window closes + + await expect(settling).resolves.toEqual({ + settled: false, + reason: "declared lost by the platform", + }); + expect(clock.pending()).toBe(0); + }); + + it("leaves an abandoned run alive to execute its own teardown when it later settles", async () => { + const clock = fakeClock(); + const teardown = vi.fn(); + let finishRun: ((value: { ok: boolean }) => void) | undefined; + const run = new Promise<{ ok: boolean }>((resolve) => { + finishRun = resolve; + }).finally(teardown); + + const settling = awaitTurnOrAbandon({ + run, + abort: vi.fn(), + interrupted: Promise.resolve("declared lost by the platform"), + limits, + clock, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + await clock.fireAll(); + + await expect(settling).resolves.toEqual({ + settled: false, + reason: "declared lost by the platform", + }); + expect(teardown).not.toHaveBeenCalled(); + + finishRun?.({ ok: false }); + await run; + expect(teardown).toHaveBeenCalledTimes(1); + }); + + it("gives up on the hard deadline even with no interruption signal at all", async () => { + const clock = fakeClock(); + const settling = awaitTurnOrAbandon({ + run: new Promise(() => {}), + abort: vi.fn(), + limits, + clock, + }); + + await clock.fireAll(); // the hard deadline + await clock.fireAll(); // the grace window + + const outcome = await settling; + expect(outcome.settled).toBe(false); + if (outcome.settled) return; + expect(outcome.reason).toContain("hard turn deadline"); + }); + + it("survives an abort that throws", async () => { + const clock = fakeClock(); + const settling = awaitTurnOrAbandon({ + run: new Promise(() => {}), + abort: () => { + throw new Error("controller already closed"); + }, + interrupted: Promise.resolve("lost"), + limits, + clock, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + await clock.fireAll(); + + await expect(settling).resolves.toEqual({ settled: false, reason: "lost" }); + }); +}); + +describe("turn settle limits", () => { + it("keeps the hard deadline above the longest legitimate run", () => { + // A backstop that fired before the run limits would shorten real runs, which is the + // opposite of what users have asked for (issues #6084, #5356). + expect(DEFAULT_HARD_DEADLINE_MS).toBeGreaterThan(DEFAULT_TOTAL_DEADLINE_MS); + expect(resolveTurnSettleLimits()).toEqual({ + hardDeadlineMs: DEFAULT_HARD_DEADLINE_MS, + abandonGraceMs: DEFAULT_ABANDON_GRACE_MS, + }); + }); + + it("takes an operator override", () => { + vi.stubEnv(HARD_DEADLINE_ENV, "120000"); + vi.stubEnv(ABANDON_GRACE_ENV, "5000"); + + expect(resolveTurnSettleLimits()).toEqual({ + hardDeadlineMs: 120_000, + abandonGraceMs: 5_000, + }); + }); +}); diff --git a/services/runner/tests/unit/wire-contract.test.ts b/services/runner/tests/unit/wire-contract.test.ts index f71652fb9dc..a2e8128cee0 100644 --- a/services/runner/tests/unit/wire-contract.test.ts +++ b/services/runner/tests/unit/wire-contract.test.ts @@ -55,8 +55,10 @@ const KNOWN_REQUEST_KEYS = [ "appendSystemPrompt", "skills", "sandboxPermission", + "sandboxCredentials", "harnessFiles", "turnId", + "detached", "projectId", "effectiveParameters", ] as const; diff --git a/services/uv.lock b/services/uv.lock index c892f3a43b6..d0eec0aff2d 100644 --- a/services/uv.lock +++ b/services/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ [[package]] name = "agenta" -version = "0.114.4" +version = "0.115.1" source = { editable = "../sdks/python" } dependencies = [ { name = "agenta-client" }, @@ -72,7 +72,7 @@ dev = [ [[package]] name = "agenta-client" -version = "0.114.4" +version = "0.115.1" source = { editable = "../clients/python" } dependencies = [ { name = "httpx" }, @@ -2356,7 +2356,7 @@ wheels = [ [[package]] name = "services" -version = "0.114.4" +version = "0.115.1" source = { virtual = "." } dependencies = [ { name = "agenta" }, diff --git a/web/.prettierignore b/web/.prettierignore index 081df17556d..2e131ac8158 100644 --- a/web/.prettierignore +++ b/web/.prettierignore @@ -1,2 +1,6 @@ packages/agenta-api-client/ _reference/ + +# Runtime-generated config and unmodified designer reference assets. +**/public/__env.js +storybook/public/agent-custom-secrets/ diff --git a/web/ee/package.json b/web/ee/package.json index 2ff931ff62c..1da5d3cc2e8 100644 --- a/web/ee/package.json +++ b/web/ee/package.json @@ -1,6 +1,6 @@ { "name": "@agenta/ee", - "version": "0.114.4", + "version": "0.115.1", "private": true, "engines": { "node": "24.x" diff --git a/web/ee/tests/playwright/acceptance/members/index.ts b/web/ee/tests/playwright/acceptance/members/index.ts index c0a0d0cf79e..7af43f99df5 100644 --- a/web/ee/tests/playwright/acceptance/members/index.ts +++ b/web/ee/tests/playwright/acceptance/members/index.ts @@ -32,6 +32,23 @@ const lightFastTags = buildAcceptanceTags({ const createInviteEmail = (scope: string) => `${scope}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@agenta.test` +const waitForResendResponse = async (page: any) => { + const response = await page.waitForResponse( + (res: any) => + res.request().method() === "POST" && + res.url().includes("/workspaces/") && + res.url().includes("/invite/resend") && + ![301, 302, 303, 307, 308].includes(res.status()), + {timeout: 15000}, + ) + + if (!response.ok()) { + throw new Error( + `Resend invitation request failed (${response.status()}): ${await response.text()}`, + ) + } +} + const waitForRemoveResponse = async (page: any) => { const response = await page.waitForResponse( (res: any) => @@ -101,20 +118,9 @@ const submitInviteMembersModal = async (inviteModal: any) => { await expect(inviteModal).not.toBeVisible({timeout: 30000}) } -/** - * Invite a member via the EE flow (email sent) and wait for their row to appear - * in the members table with "Invitation Pending" status. - * Returns the invited email so callers can locate the row. - */ -/** - * A row in the members table. - * - * The table is virtualised: the semantic `` carries only the `` and each - * body row is a `[data-row-key]` node outside it, so `locator("tr")` only ever matches - * the header. - */ +/** A member row rendered by the current semantic table. */ const memberRow = (page: any, email: string) => - page.locator("[data-row-key]").filter({hasText: email}).first() + page.getByRole("row").filter({hasText: email}).first() /** * Closes the "Invited user link" dialog that opens after a successful invite. @@ -128,6 +134,7 @@ const dismissInvitedUserLinkDialog = async (page: any) => { await expect(dialog).toBeHidden({timeout: 10000}) } +/** Invites a member and waits for its Pending row state. */ const invitePendingMember = async (page: any, apiHelpers: any, uiHelpers: any): Promise => { const testEmail = createInviteEmail("test-member") @@ -159,7 +166,9 @@ const invitePendingMember = async (page: any, apiHelpers: any, uiHelpers: any): // refreshed at all — which is why callers then failed to find the row. Close it // first, then wait for the row itself. await dismissInvitedUserLinkDialog(page) - await expect(memberRow(page, testEmail)).toBeVisible({timeout: 15000}) + const row = memberRow(page, testEmail) + await expect(row).toBeVisible({timeout: 15000}) + await expect(row.getByText("Pending", {exact: true})).toBeVisible({timeout: 15000}) return testEmail } @@ -263,14 +272,16 @@ const membersTests = () => { }) await scenarios.and("the user clicks Resend invitation", async () => { - await page - .locator(".ant-dropdown-menu-item") - .filter({hasText: "Resend invitation"}) - .click() + await Promise.all([ + waitForResendResponse(page), + page.getByRole("menuitem", {name: "Resend invitation", exact: true}).click(), + ]) }) await scenarios.then("a success confirmation is shown", async () => { - await expect(page.getByText("Invitation sent!")).toBeVisible({timeout: 10000}) + await expect(page.getByText("Invitation sent!", {exact: true})).toBeVisible({ + timeout: 10000, + }) }) }, ) @@ -301,11 +312,8 @@ const membersTests = () => { }) await scenarios.and("the user clicks Remove and confirms", async () => { - await page.locator(".ant-dropdown-menu-item").filter({hasText: "Remove"}).click() + await page.getByRole("menuitem", {name: "Remove", exact: true}).click() - // `AlertPopup` calls `modal.confirm` from `@agenta/ui/app-message`, which - // renders a Radix `AlertDialog`. Its content carries role="alertdialog", - // a distinct role from "dialog" — so `getByRole("dialog")` never matches. const confirmDialog = page.getByRole("alertdialog", {name: "Remove member"}) await expect(confirmDialog).toBeVisible({timeout: 10000}) await Promise.all([ diff --git a/web/mobile/package.json b/web/mobile/package.json index 85ce3d84980..76e021f8ad1 100644 --- a/web/mobile/package.json +++ b/web/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@agenta/mobile", - "version": "0.114.4", + "version": "0.115.1", "private": true, "engines": { "node": "24.x" diff --git a/web/mobile/src/features/agents/AgentOverviewScreen.tsx b/web/mobile/src/features/agents/AgentOverviewScreen.tsx index e4dc1e504d4..8c225f878a0 100644 --- a/web/mobile/src/features/agents/AgentOverviewScreen.tsx +++ b/web/mobile/src/features/agents/AgentOverviewScreen.tsx @@ -13,6 +13,7 @@ import {useAtomValue} from "jotai" import {PageTitle} from "@/components/PageTitle" import {ScreenScaffold} from "@/components/ScreenScaffold" +import {Skeleton} from "@/components/ui/skeleton" import {FOCUS_RING} from "@/lib/interactive" import {useBindProjectContext} from "../context/useBindProjectContext" @@ -99,9 +100,15 @@ export const AgentOverviewScreen = ({ {/* The heading-3 rung (24px/1.3333) every other title in this app gets — Sessions, Agents, Templates. At `text-sm` the agent's name read as a breadcrumb, so the page had no title at all. */} -

- {name} -

+ {/* The "Agent" fallback is for an agent that never resolves; while + the roster is still in flight it read as a real name. */} + {agentsQuery.isPending && !agent ? ( + + ) : ( +

+ {name} +

+ )} {/* The same verbs the desktop header offers; rename and delete fall through to the shared implementations here, since /m has no app-management modals of its own. @@ -122,13 +129,12 @@ export const AgentOverviewScreen = ({ {/* THE shared overview body — the same cards, order and chrome the desktop page renders. Read-only host: configuration is edited in the desktop playground, so no `onEditConfig`. */} - {/* `flex flex-col` is load-bearing: the body's columns size off `flex-1` + - `h-full`, so a plain block here leaves them with no definite height and - the left column scrolls inside a stunted box. */} {/* The shared page column (`pageContentWidthClass`), same as Sessions and Agents: this page used to opt out of the cap at `lg` and stretched ~300px wider than every other screen, which also inflated the body's right rail past the width its rows are designed for. */} + {/* `min-h-0 flex-1` is load-bearing: the body IS the scroller, so it needs a + definite height to scroll within. */}
diff --git a/web/mobile/src/features/app/ContextSync.tsx b/web/mobile/src/features/app/ContextSync.tsx index b388b25aef2..b84227f858e 100644 --- a/web/mobile/src/features/app/ContextSync.tsx +++ b/web/mobile/src/features/app/ContextSync.tsx @@ -1,7 +1,7 @@ import {useEffect} from "react" import {useProfile} from "@agenta/entities/profile" -import {activeUserIdAtom, setProjectIdAtom, setUserAtom} from "@agenta/shared/state" +import {activeUserIdAtom, setProjectIdAtom, setSessionAtom, setUserAtom} from "@agenta/shared/state" import {useSetAtom} from "jotai" import {useRouter} from "next/router" @@ -13,6 +13,7 @@ export const ContextSync = () => { const setProjectId = useSetAtom(setProjectIdAtom) const setActiveUserId = useSetAtom(activeUserIdAtom) const setSharedUser = useSetAtom(setUserAtom) + const setSession = useSetAtom(setSessionAtom) const {user, isPending: profilePending} = useProfile() // The identity half of the app context, and this app's answer to the desktop's @@ -34,6 +35,23 @@ export const ContextSync = () => { setActiveUserId(user?.id ?? null) }, [profilePending, user?.id, setActiveUserId]) + // The auth half of the same context, and the other half of the desktop's `SessionListener`. + // `sessionAtom` defaults to FALSE and every entity query gates on it, so a host that never + // sets it leaves those queries permanently disabled — and a disabled TanStack v5 query reports + // `isPending: true` forever, with no request and no error. That is what left the agent's + // Configuration panel on its skeleton on `/m`: `workflowQueryAtomFamily` never ran, so the + // revision resolved to `data: null, isPending: true` and the panel's loading gate never + // cleared. The operational sections below it render from other atoms, which is why only the + // config rows looked stuck. + // + // Driven off the SETTLED profile rather than the route: desktop can pre-set it from a + // ProtectedRoute-guarded URL, but a project id in a mobile URL is not proof of auth, and + // optimistically claiming a session would 401-storm every gated query behind it. + useEffect(() => { + if (profilePending) return + setSession(!!user) + }, [profilePending, user, setSession]) + const {workspace_id, project_id} = router.query const workspaceId = typeof workspace_id === "string" ? workspace_id : null const projectId = typeof project_id === "string" ? project_id : null diff --git a/web/mobile/src/features/chat/AttachmentPart.tsx b/web/mobile/src/features/chat/AttachmentPart.tsx deleted file mode 100644 index 2f8a3fe7fec..00000000000 --- a/web/mobile/src/features/chat/AttachmentPart.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import {Paperclip} from "lucide-react" - -interface FilePart { - url?: string - mediaType?: string - filename?: string -} - -const isImage = (mediaType?: string) => Boolean(mediaType?.startsWith("image/")) - -/** - * One attachment on a replayed message. The URL is the durable content endpoint, so it carries - * the session cookie — an loads it directly, anything else stays a labelled link rather - * than a download this screen cannot preview. - */ -export const AttachmentPart = ({part}: {part: FilePart}) => { - const {url, mediaType, filename} = part - if (!url) return null - - if (isImage(mediaType)) { - return ( - - {/* Plain : next/image would need the API host allow-listed and cannot - optimize a cookie-authenticated content endpoint. */} - {filename - - ) - } - - return ( - - - {filename || mediaType || "attachment"} - - ) -} diff --git a/web/mobile/src/features/chat/ChatScreen.tsx b/web/mobile/src/features/chat/ChatScreen.tsx index ca656f4aa8e..39d1a661c01 100644 --- a/web/mobile/src/features/chat/ChatScreen.tsx +++ b/web/mobile/src/features/chat/ChatScreen.tsx @@ -77,9 +77,9 @@ export const ChatScreen = ({ // Only a FIRST load has nothing to hold — that is the one time a spinner is honest. const showLoading = resolving && !heldEntityId const liveness = useLivenessPoll(projectId) - const running = Boolean( - liveness.data?.find((s) => s.session_id === sessionId)?.flags?.is_running, - ) + const liveStream = liveness.data?.find((s) => s.session_id === sessionId) + const running = Boolean(liveStream?.flags?.is_running) + const sharedReader = Boolean(liveStream?.capabilities?.shared_reader) // The conversation is ALWAYS mounted — the mode only decides what sits beside it (and, on a // narrow frame, which of the two is on screen). Unmounting it on a mode flip would drop a // streaming turn. @@ -99,6 +99,11 @@ export const ChatScreen = ({ projectId={projectId} workspaceId={workspaceId} running={running} + stopStateLoading={liveness.isLoading} + sessionTurnId={liveStream?.turn_id} + stoppingTurnId={liveStream?.stopping_turn_id} + sharedReader={sharedReader} + livenessUpdatedAt={liveness.dataUpdatedAt} agentId={resolvedAgentId} /> ) : ( diff --git a/web/mobile/src/features/chat/Composer.tsx b/web/mobile/src/features/chat/Composer.tsx index 126aa8e953f..bf8df8cbadf 100644 --- a/web/mobile/src/features/chat/Composer.tsx +++ b/web/mobile/src/features/chat/Composer.tsx @@ -1,6 +1,8 @@ -import {useRef, type MutableRefObject} from "react" +import {useEffect, useRef, type MutableRefObject} from "react" +import {describeAccepted} from "@agenta/chat/assets" import { + AttachmentDropOverlay, ChatComposer, MicPermissionNotice, RecordingBar, @@ -32,8 +34,10 @@ export const Composer = ({ disabled = false, waitingOnUser = false, streaming = false, + stopping = false, onStop, inputRef, + placeholder, }: { sessionId: string onSend: (input: {text: string; parts?: FileUIPart[]}) => void | Promise @@ -43,9 +47,13 @@ export const Composer = ({ waitingOnUser?: boolean /** A run is streaming from this device — the send button becomes Stop. */ streaming?: boolean + /** The durable Stop request has not settled yet. */ + stopping?: boolean onStop?: () => void /** Lets the host write into the input — a rewind puts the rewound message back to edit. */ inputRef?: MutableRefObject + /** Full placeholder override — used when the composer is gated (no model key). */ + placeholder?: string }) => { const attachments = useComposerAttachments({sessionId}) const ownInputRef = useRef(null) @@ -62,6 +70,8 @@ export const Composer = ({ // is still in flight; a second pass would re-send the same staged tray. if (sending.current) return sending.current = true + // The message is written; anything still coming in belongs to no draft. + voice.endDictation() // Close the on-screen keyboard. It covered the transcript while you typed, and the reply // to the message you just sent is the thing you want to see next. The helper defers the // blur past the editor's own clear, whose reconcile would otherwise re-focus the input and @@ -95,7 +105,6 @@ export const Composer = ({ // through the composer's own inline channel. richInputRef.current?.setMarkdown(text) attachments.setRejections([{name: "Message", reason: "wasn't sent — try again."}]) - attachments.setAttachmentsOpen(true) } } @@ -109,6 +118,7 @@ export const Composer = ({ voiceRecorder, voiceWillSend, startVoiceMessage, + dictationStopRef, dictating, setDictating, setDictationError, @@ -120,6 +130,31 @@ export const Composer = ({ // unusable composer is a dead end for a file. const attachmentsBlocked = () => voiceRecorder.active || disabled + // Desktop accepts a drop anywhere on the canvas and highlights only the composer. Mobile owns + // no element above itself, so it reaches for the shared `.ag-canvas` root and binds there — + // the overlay still paints over the composer alone. + const dropHostRef = useRef(null) + const dropHandlersRef = useRef(attachments.bindDropTarget(attachmentsBlocked)) + dropHandlersRef.current = attachments.bindDropTarget(attachmentsBlocked) + useEffect(() => { + const host = dropHostRef.current?.closest(".ag-canvas") + if (!host) return + const enter = (e: Event) => dropHandlersRef.current.onDragEnter(e as never) + const over = (e: Event) => dropHandlersRef.current.onDragOver(e as never) + const leave = (e: Event) => dropHandlersRef.current.onDragLeave(e as never) + const drop = (e: Event) => dropHandlersRef.current.onDrop(e as never) + host.addEventListener("dragenter", enter) + host.addEventListener("dragover", over) + host.addEventListener("dragleave", leave) + host.addEventListener("drop", drop) + return () => { + host.removeEventListener("dragenter", enter) + host.removeEventListener("dragover", over) + host.removeEventListener("dragleave", leave) + host.removeEventListener("drop", drop) + } + }, []) + return (
@@ -128,7 +163,16 @@ export const Composer = ({ message={micError} onDismiss={dismissMicError} /> -
+
+ } diff --git a/web/mobile/src/features/chat/ConfigPane.tsx b/web/mobile/src/features/chat/ConfigPane.tsx index 861a8d05dbd..2a24d06ce9f 100644 --- a/web/mobile/src/features/chat/ConfigPane.tsx +++ b/web/mobile/src/features/chat/ConfigPane.tsx @@ -19,12 +19,20 @@ import {DrillInBridgeProvider} from "./DrillInBridgeProvider" * state on the desktop side, and the header takes them as slots precisely so a surface that * cannot offer them simply does not. */ -export const ConfigPane = ({entityId, sessionId}: {entityId: string; sessionId: string}) => { +export const ConfigPane = ({ + entityId, + sessionId, + projectId, +}: { + entityId: string + sessionId: string + projectId: string +}) => { const setConfigCollapsed = useSetAtom(configPanelCollapsedAtom) return (
- + { +export const DrillInBridgeProvider = ({ + children, + sessionId, + projectId, +}: PropsWithChildren<{sessionId: string; projectId: string}>) => { const workflowReference = useWorkflowReferenceBridge() - const components = useMemo(() => ({workflowReference}), [workflowReference]) + const canEditSecrets = useProjectPermission(projectId, "edit_secret") + const pinRevision = useSetAtom(selectedRevisionAtomFamily(sessionId)) + const onWorkflowRevisionCommitted = useCallback( + (revisionId: string) => pinRevision(revisionId), + [pinRevision], + ) + const components = useMemo( + () => ({ + workflowReference, + permissions: {canEditSecrets}, + onWorkflowRevisionCommitted, + }), + [workflowReference, canEditSecrets, onWorkflowRevisionCommitted], + ) return {children} } diff --git a/web/mobile/src/features/chat/LiveConversation.tsx b/web/mobile/src/features/chat/LiveConversation.tsx index 18ef21dd862..a7e93553f35 100644 --- a/web/mobile/src/features/chat/LiveConversation.tsx +++ b/web/mobile/src/features/chat/LiveConversation.tsx @@ -5,30 +5,52 @@ import { BOTTOM_FADE_OVERLAY_STYLE, EDGE_FADE_MASK, jumpGateOpen, + latestTurnId, + resolveStopExecution, + shouldShowStopControl, } from "@agenta/chat/assets" +import {getPendingSecretInteractions} from "@agenta/chat/clientTools" import { ConnectionDock, - ElicitationDock, ConnectionFocusProvider, + ConnectionWarningStrip, + ElicitationDock, + QueuedMessagesDock, RunningElsewhereStrip, } from "@agenta/chat/components" +import type {QueuedMessage} from "@agenta/chat/hooks" import { useAgentConversation, useAgentModelKeyStatus, useConnectionDock, useElicitationDock, } from "@agenta/chat/hooks" -import {getPendingApprovals, type TurnViewModel} from "@agenta/chat/model" +import { + getInteractionAvailability, + getLivePendingApprovals, + isSessionTurnStopping, + type TurnViewModel, +} from "@agenta/chat/model" +import {getSessionTurnId} from "@agenta/chat/state" +import {cancelSessionExecution} from "@agenta/entities/session" import {AgentIntroCard} from "@agenta/entity-ui/agent" -import {modal} from "@agenta/ui/app-message" -import {ChatJumpToLatest} from "@agenta/ui/components/presentational" +import {SecretRequestDock} from "@agenta/entity-ui/clientTools" +import {message, modal} from "@agenta/ui/app-message" +import { + ChatBubble, + ChatBubbleAvatar, + ChatJumpToLatest, + turnRowClass, +} from "@agenta/ui/components/presentational" import type {RichChatInputHandle} from "@agenta/ui/rich-chat-input" -import {useSetAtom} from "jotai" +import {useAtomValue, useSetAtom} from "jotai" +import {User} from "lucide-react" import {ContentRail} from "@/components/ContentRail" import {ScreenScaffold} from "@/components/ScreenScaffold" -import {takePendingTaskAtom} from "../home/pendingTask" +import {useProjectPermission} from "../context/useProjectPermission" +import {pendingTasksAtom, takePendingTaskAtom} from "../home/pendingTask" import {AppShell} from "../nav/AppShell" import {ApprovalDock} from "./ApprovalDock" @@ -39,10 +61,12 @@ import { PENDING_TASK_NOT_SENT_MESSAGE, pendingTaskDecision, } from "./pendingTaskPolicy" +import {selectedRevisionAtomFamily} from "./selectedRevision" import {ChatLoading} from "./states/ChatStates" import {StopButton} from "./StopButton" +import {cancelledStopAction} from "./stopHereState" import {TurnRow} from "./TurnRow" -import {showTrailingWorkingPulse} from "./turnStatus" +import {deriveMobileRemoteTurnPresentation, showTrailingWorkingPulse} from "./turnStatus" import {TurnStatusLine} from "./TurnStatusLine" import {useApprovalActions, type ApprovalActions} from "./useApprovalActions" import {useSessionWatch} from "./useSessionWatch" @@ -65,6 +89,11 @@ export const LiveConversation = ({ projectId, workspaceId, running, + stopStateLoading, + sessionTurnId, + stoppingTurnId, + sharedReader, + livenessUpdatedAt, agentId, embedded = false, }: { @@ -74,12 +103,39 @@ export const LiveConversation = ({ workspaceId: string /** Backend liveness (cross-device) — shows the running strip even when this device idles. */ running: boolean + /** Initial liveness load and durable Stop ownership for remount recovery. */ + stopStateLoading: boolean + sessionTurnId?: string | null + stoppingTurnId?: string | null + /** Backend-advertised ability to receive display-only live frames from another sender. */ + sharedReader: boolean + /** React Query timestamp used to reject the sender's stale post-settle liveness snapshot. */ + livenessUpdatedAt: number /** Scopes the session tab rail to this agent's sessions. */ agentId?: string | null /** Rendered inside a workspace pane — the shell and its rail belong to the parent. */ embedded?: boolean }) => { - const conversation = useAgentConversation({entityId, sessionId}) + const conversation = useAgentConversation({ + entityId, + sessionId, + sharedReaderAdvertised: sharedReader, + sharedReaderRunning: running, + sharedReaderLivenessUpdatedAt: livenessUpdatedAt, + }) + const canEditSecrets = useProjectPermission(projectId, "edit_secret") + const pinRevision = useSetAtom(selectedRevisionAtomFamily(sessionId)) + const adoptSecretRevision = useCallback( + (next: string) => { + conversation.adoptRevision(next) + pinRevision(next) + }, + [conversation.adoptRevision, pinRevision], + ) + const pendingSecret = useMemo( + () => getPendingSecretInteractions(conversation.messages)[0], + [conversation.messages], + ) // The connect-model gate — desktop parity. The engine deliberately leaves this to the skin // (`useAgentConversation` says so): a keyless project must be told to add a key BEFORE the @@ -112,6 +168,23 @@ export const LiveConversation = ({ // the composer, and rewind (far below) refills it the same way. const composerRef = useRef(null) + // Editing borrows the composer: the row's text goes in, the draft it displaces is stashed. + const {beginEdit, cancelEdit} = conversation + const editQueued = useCallback( + (message: QueuedMessage) => { + const input = composerRef.current + beginEdit(message.id, input?.getMarkdown() ?? "") + input?.setMarkdown(message.text) + input?.focus() + }, + [beginEdit], + ) + const cancelQueuedEdit = useCallback(() => { + const input = composerRef.current + input?.setMarkdown(cancelEdit()) + input?.focus() + }, [cancelEdit]) + // A task started from Home lands here as a stashed message: the session did not exist when // it was typed, and the first send is what creates it. Ref-guarded and the slot is consumed // on read, so a re-render (or React 18's double-invoke in dev) cannot send it twice. Held @@ -121,10 +194,16 @@ export const LiveConversation = ({ // vault says one already exists). The guard holds the SESSION it // fired for, not a bare flag: this component survives a session switch, and a flag would // swallow the next session's stashed task. + + // Peek at the parked task WITHOUT consuming it — used only for display while the gate holds. + // `takePendingTaskAtom` removes the entry; this read leaves it in place for the send effect. + const pendingTasks = useAtomValue(pendingTasksAtom) + const heldTaskText = pendingTasks[sessionId]?.text ?? null + const takePendingTask = useSetAtom(takePendingTaskAtom) const sentPendingTaskFor = useRef(null) const [pendingTaskError, setPendingTaskError] = useState(null) - const {isHydrating, send} = conversation + const {isHydrating, revalidate, send, stop, voidPendingResume} = conversation useEffect(() => { const decision = pendingTaskDecision({ sessionId, @@ -157,22 +236,218 @@ export const LiveConversation = ({ takePendingTask, ]) - // Push-invalidation: a records change (another device's turn, a steer resume) folds into - // the engine's transcript under its adopt guards. - const watch = useSessionWatch({sessionId, projectId, onRecordsChanged: conversation.revalidate}) - // The watch relay is the primary cross-device signal; when it cannot connect, fall back to a - // slow revalidate poll only while the backend says the session is running elsewhere. + const streamingHere = conversation.status === "submitted" || conversation.status === "streaming" + const remoteTurn = deriveMobileRemoteTurnPresentation({ + livenessRunning: running, + snapshotRunning: conversation.runningFromSnapshot || conversation.acceptedRunPending, + sharedReaderAdvertised: sharedReader, + readerReady: conversation.readerReady, + ownedContinuation: conversation.acceptedRunPending, + }) + const showingTurnActivity = streamingHere || remoteTurn.showActivity + const streamingHereRef = useRef(streamingHere) + streamingHereRef.current = streamingHere + const hitlPendingRef = useRef(conversation.hitlPending) + hitlPendingRef.current = conversation.hitlPending + const [stoppingHere, setStoppingHere] = useState(false) + const stopWatchdogTimerRef = useRef | null>(null) + const expectedStopExecutionIdRef = useRef(undefined) + const retryStopRef = useRef(false) + const stopSessionIdRef = useRef(sessionId) + stopSessionIdRef.current = sessionId + const stopping = + stoppingHere || + isSessionTurnStopping({ + currentTurnId: sessionTurnId ?? latestTurnId(conversation.messages), + stoppingTurnId, + }) || + (stopStateLoading && conversation.hitlPending) + const settleParkedStop = useCallback(() => { + if (stopWatchdogTimerRef.current) clearTimeout(stopWatchdogTimerRef.current) + stopWatchdogTimerRef.current = null + retryStopRef.current = false + expectedStopExecutionIdRef.current = undefined + // Server acceptance makes the local stop a render-only latch. + stop() + setStoppingHere(false) + }, [stop]) + + useEffect(() => { + if (stopWatchdogTimerRef.current) clearTimeout(stopWatchdogTimerRef.current) + stopWatchdogTimerRef.current = null + retryStopRef.current = false + expectedStopExecutionIdRef.current = undefined + setStoppingHere(false) + }, [sessionId]) + + // Push invalidation folds cross-device changes into the guarded transcript. + const watch = useSessionWatch({ + sessionId, + projectId, + onRecordsChanged: revalidate, + sharedReaderAdvertised: sharedReader, + }) + // Poll slowly while a cross-device run cannot be watched live. useEffect(() => { if (watch.connected || !running) return - const timer = setInterval(() => conversation.revalidate(), 7_500) + const timer = setInterval(() => revalidate(), 7_500) return () => clearInterval(timer) - }, [watch.connected, running, conversation.revalidate]) + }, [watch.connected, running, revalidate]) + useEffect(() => { + if (streamingHere || !stopWatchdogTimerRef.current) return + clearTimeout(stopWatchdogTimerRef.current) + stopWatchdogTimerRef.current = null + retryStopRef.current = false + expectedStopExecutionIdRef.current = undefined + setStoppingHere(false) + }, [streamingHere]) + useEffect( + () => () => { + if (stopWatchdogTimerRef.current) clearTimeout(stopWatchdogTimerRef.current) + }, + [], + ) + const stopResolutionRef = useRef(null) + useEffect( + () => () => { + stopResolutionRef.current?.abort() + }, + [sessionId], + ) - // The engine's own dock latches the shown set; the mobile dock renders the raw pending list - // (same source function, same index-0 ordering) and acts through the engine. + // Composer Stop cancels on the server before changing local presentation. + const stopHere = useCallback(() => { + if (stopping) return + // Fence a delayed approval release even when cancellation cannot be requested yet. + voidPendingResume() + if (!projectId || !sessionId) return + setStoppingHere(true) + const wasParked = !streamingHereRef.current && conversation.hitlPending + const isRetry = retryStopRef.current + const expectedExecutionId = isRetry + ? expectedStopExecutionIdRef.current + : getSessionTurnId(sessionId) + retryStopRef.current = false + let resolutionController: AbortController | null = null + const cancel = async () => { + let resolvedExecutionId = expectedExecutionId + if (!isRetry && !resolvedExecutionId && streamingHereRef.current) { + const controller = new AbortController() + resolutionController = controller + stopResolutionRef.current?.abort() + stopResolutionRef.current = controller + const resolution = await resolveStopExecution({ + readExecutionId: () => getSessionTurnId(sessionId), + isRunActive: () => streamingHereRef.current, + signal: controller.signal, + }) + if (stopResolutionRef.current === controller) stopResolutionRef.current = null + if (resolution.status !== "resolved") return {resolution} as const + resolvedExecutionId = resolution.executionId + } + expectedStopExecutionIdRef.current = resolvedExecutionId + const outcome = await cancelSessionExecution({ + sessionId, + projectId, + expectedExecutionId: resolvedExecutionId, + }) + return {outcome} as const + } + void cancel() + .then((result) => { + if ("resolution" in result && result.resolution) { + if (result.resolution.status === "settled") { + setStoppingHere(false) + } else if (result.resolution.status === "timed_out") { + setStoppingHere(false) + message.warning("Could not identify the run to stop. Please try again.") + } + return + } + const {outcome} = result + if (stopSessionIdRef.current !== sessionId) return + if (outcome?.accepted) { + const action = cancelledStopAction({ + parkedAtRequest: wasParked, + parkedAtResponse: !streamingHereRef.current && hitlPendingRef.current, + streaming: streamingHereRef.current, + retry: isRetry, + executionState: outcome.execution.state, + }) + if (action === "settle-parked") { + settleParkedStop() + return + } + if (action === "settle-idle") { + setStoppingHere(false) + expectedStopExecutionIdRef.current = undefined + return + } + if (action === "abort-settled" || action === "abort-retry") { + stop() + setStoppingHere(false) + expectedStopExecutionIdRef.current = undefined + return + } + stopWatchdogTimerRef.current = setTimeout(() => { + retryStopRef.current = true + stopWatchdogTimerRef.current = null + setStoppingHere(false) + }, 30_000) + return + } + setStoppingHere(false) + if (outcome && !outcome.conflict && outcome.execution.state === "idle") { + retryStopRef.current = false + expectedStopExecutionIdRef.current = undefined + return + } + if (outcome?.conflict) { + retryStopRef.current = false + expectedStopExecutionIdRef.current = undefined + } else if (isRetry) { + retryStopRef.current = true + } + message.warning( + outcome?.conflict + ? "That run had already finished. The session is running something else now." + : "Could not stop the run. It may still be running.", + ) + }) + .catch((error: unknown) => { + if (stopResolutionRef.current === resolutionController) { + stopResolutionRef.current = null + } + if (stopSessionIdRef.current !== sessionId) return + if (isRetry) retryStopRef.current = true + setStoppingHere(false) + message.warning( + error instanceof Error + ? error.message + : "Could not stop the run. It may still be running.", + ) + }) + }, [ + projectId, + sessionId, + stop, + stopping, + conversation.hitlPending, + settleParkedStop, + voidPendingResume, + ]) + + const interactionAvailability = getInteractionAvailability({ + stopped: conversation.stopped, + stopping, + streaming: streamingHere, + }) const pendingApprovals = useMemo( - () => getPendingApprovals(conversation.messages), - [conversation.messages], + () => + getLivePendingApprovals(conversation.messages, { + stopped: !interactionAvailability.approvals, + }), + [conversation.messages, interactionAvailability.approvals], ) // Steer keeps the detached resume dispatcher; plain approve/deny go through the engine. const steerActions = useApprovalActions({ @@ -208,30 +483,35 @@ export const LiveConversation = ({ ) const autoScroll = useTranscriptAutoScroll(visibleTurns) - const streamingHere = conversation.status === "submitted" || conversation.status === "streaming" // Parked connect interactions → the dock above the composer owns their actions, so a paused // run can't scroll out of reach. Gated the same way desktop gates it. // Parked question forms → the docked card owns the questions and the answers; the transcript // rows are passive markers. const elicits = useElicitationDock({ messages: conversation.messages, - enabled: !streamingHere && !conversation.stopped, + enabled: interactionAvailability.parkedDocks, approvalsPending: pendingApprovals.length > 0, onOutput: conversation.sendToolOutput, }) const connects = useConnectionDock({ messages: conversation.messages, - enabled: !streamingHere && !conversation.stopped, + enabled: interactionAvailability.parkedDocks, approvalsPending: pendingApprovals.length > 0, elicitationPending: elicits.open, }) + // Any blocking dock on screen. The queue card yields to all of them rather than stacking, + // mid-edit included — the composer keeps the edit, so Enter still rewrites the held row. + const secretDockOpen = !streamingHere && !conversation.stopped && Boolean(pendingSecret) + const gateDockOpen = + pendingApprovals.length > 0 || elicits.open || connects.open || secretDockOpen // A docked gate holds the jump pill back — same rule, same reasons, as the desktop. This - // surface has no question-form dock yet, so only approvals and connect cards can gate it. - const gateOpen = jumpGateOpen({ - approvals: pendingApprovals.length, - elicitationOpen: false, - connectionOpen: connects.open, - }) + // surface has no question-form dock yet, so approvals, connect, and secret cards gate it. + const gateOpen = + jumpGateOpen({ + approvals: pendingApprovals.length, + elicitationOpen: false, + connectionOpen: connects.open, + }) || secretDockOpen // Rewind: re-run the conversation from a turn. The hook only SCANS (it never opens dialogs), // so the warning about tools that already ran, and putting a rewound user message back into @@ -270,6 +550,30 @@ export const LiveConversation = ({ } else { body = ( + {/* A task typed before any provider key exists is held in `pendingTasksAtom` + (not yet sent — the gate is up). Render it as a user bubble so the person + can see what they wrote, matching desktop parity: the desktop shows the + held seed above the connect-model banner. Cleared the moment the gate + drops and the send effect fires (`takePendingTaskAtom` removes the entry). */} + {heldTaskText ? ( +
+ } />} + className="min-w-0 max-w-[85%]" + classNames={{ + content: "min-w-0 max-w-full overflow-hidden text-xs", + body: "min-w-0 max-w-full overflow-hidden", + }} + content={ + + {heldTaskText} + + } + /> +
+ ) : null} {conversation.isEmpty ? ( // The SAME card the desktop shows a conversation with no messages: who you are // about to talk to. A blank session is not an error state — /m rendered nothing @@ -300,7 +604,7 @@ export const LiveConversation = ({ far below the turn it described. It falls back to here for the one case that turn cannot cover: the request is submitted and no assistant turn exists yet. */}
@@ -334,11 +638,29 @@ export const LiveConversation = ({ className={`pointer-events-none absolute inset-x-0 bottom-full ${BOTTOM_FADE_HOVER_HIDE}`} style={BOTTOM_FADE_OVERLAY_STYLE} /> + {/* What you have lined up. Yields to the gate docks entirely: those are + blocked runs wanting an answer, and stacking a second card above one + buries the composer. It comes back when the gate clears. */} + {conversation.queued.length > 0 && !gateDockOpen ? ( +
+ + + +
+ ) : null} {/* A run this device is not driving. Docked with the other strips above the composer, as on the desktop — it used to be a top bar that also appeared for THIS device's own turns, duplicating the composer's Stop and shifting the transcript twice per run. */} - {running && !streamingHere ? ( + {remoteTurn.showStrip && !streamingHere ? ( ) : null} + {conversation.connectionWarning ? ( + + + + ) : null} {pendingApprovals.length > 0 ? ( + + + ) : null} {elicits.open ? (
@@ -400,11 +739,31 @@ export const LiveConversation = ({ ) : null} conversation.send({text, parts})} + onSend={({text, parts}) => { + setStoppingHere(false) + // An open edit rewrites its held message instead of sending. The + // input clears on submit, so the displaced draft goes back after. + if (!conversation.editingId) { + conversation.send({text, parts}) + return + } + const draft = conversation.commitEdit({text, fileParts: parts}) + if (draft) + requestAnimationFrame(() => + composerRef.current?.setMarkdown(draft), + ) + }} disabled={conversation.isHydrating || modelBlocked} + placeholder={ + modelBlocked ? "Connect a model to start chatting…" : undefined + } waitingOnUser={conversation.hitlPending} - streaming={streamingHere} - onStop={conversation.stop} + streaming={shouldShowStopControl({ + busy: streamingHere, + hitlPending: conversation.hitlPending, + })} + stopping={stopping} + onStop={stopHere} inputRef={composerRef} />
diff --git a/web/mobile/src/features/chat/SessionTopBar.tsx b/web/mobile/src/features/chat/SessionTopBar.tsx index bad234755c9..4826fd2cdcd 100644 --- a/web/mobile/src/features/chat/SessionTopBar.tsx +++ b/web/mobile/src/features/chat/SessionTopBar.tsx @@ -5,12 +5,10 @@ import { AgentPageHeader, AgentRevisionStatus, } from "@agenta/playground-ui/agent-page-header" -import {useAtomValue, useSetAtom} from "jotai" +import {useAtomValue} from "jotai" import {NavDrawer} from "../nav/NavDrawer" -import {selectedRevisionAtomFamily} from "./selectedRevision" - /** * The session workspace's top bar — the desktop playground's header on this surface: which agent * you are working on, which revision, and whether it is saved. @@ -25,22 +23,17 @@ import {selectedRevisionAtomFamily} from "./selectedRevision" export const SessionTopBar = ({ entityId, agentId, - sessionId, workspaceId, projectId, }: { /** The revision under edit. Absent = a session with no turns yet (nothing committed to show). */ entityId: string | null agentId?: string | null - sessionId: string workspaceId: string projectId: string }) => { // artifactName resolves from a revision id or a workflow id, so either handle names the agent. const name = useAtomValue(workflowMolecule.selectors.artifactName(entityId ?? agentId ?? "")) - // Picking a revision pins the whole workspace to it (config AND the conversation's target), - // as on the desktop; the pin lives per session and clears on commit. - const pinRevision = useSetAtom(selectedRevisionAtomFamily(sessionId)) // Only override the bar's chip once this agent has an icon; uncustomised, the shared bar draws // its own, so /m has no reason to carry a second robot. const chrome = useAgentIconChrome(agentId, {size: 15, fallbackGlyph: null}) @@ -62,11 +55,7 @@ export const SessionTopBar = ({ } revision={ entityId ? ( - + ) : undefined } /> diff --git a/web/mobile/src/features/chat/SessionWorkspace.tsx b/web/mobile/src/features/chat/SessionWorkspace.tsx index 7343fde7169..c3e18c7a7e7 100644 --- a/web/mobile/src/features/chat/SessionWorkspace.tsx +++ b/web/mobile/src/features/chat/SessionWorkspace.tsx @@ -113,7 +113,7 @@ export const SessionWorkspace = ({ // the shared panels render flat — identical components, missing surface ladder. const pane = showConfig && entityId ? ( - + ) : ( ) @@ -130,7 +130,6 @@ export const SessionWorkspace = ({ diff --git a/web/mobile/src/features/chat/StopButton.tsx b/web/mobile/src/features/chat/StopButton.tsx index f1e654b0e3c..6ae7ee26a16 100644 --- a/web/mobile/src/features/chat/StopButton.tsx +++ b/web/mobile/src/features/chat/StopButton.tsx @@ -1,25 +1,29 @@ import {useState} from "react" -import {commandSessionStream} from "@agenta/entities/session" +import {cancelSessionExecution} from "@agenta/entities/session" import {Button} from "@agenta/ui/ui" -/** - * Cooperative Stop for a running turn: the no-inputs/no-force stream command drops the - * running locks and the runner aborts on its next heartbeat (≤30s). The liveness poll - * confirms — the button unmounts when the session stops reading as running. Until - * feat/agent-cancel-steer lands the turn settles as an error record, not a clean - * "cancelled"; the copy says so. - */ +/** Cooperative Stop stays pending until shared liveness removes the control. */ export const StopButton = ({sessionId, projectId}: {sessionId: string; projectId: string}) => { const [state, setState] = useState<"idle" | "stopping" | "failed">("idle") + const [staleMessage, setStaleMessage] = useState(null) const onStop = async () => { setState("stopping") + setStaleMessage(null) try { - const result = await commandSessionStream({sessionId, projectId}) - if (!result) setState("failed") + // Cross-device Stop has no locally observed execution id to guard with. + const outcome = await cancelSessionExecution({sessionId, projectId}) + if (!outcome) setState("failed") + if (outcome && !outcome.conflict && outcome.execution.state === "idle") setState("idle") + // A conflict means another execution replaced the offered turn. + if (outcome?.conflict) { + setState("idle") + setStaleMessage( + "That run had already finished. The session is running something else now.", + ) + } } catch { - // A rejection (offline, 5xx) must land on "failed" like a null result. Without this - // the button sits on "Stopping…" forever and the user has no way to retry. + // Network rejection must leave Stop retryable. setState("failed") } } @@ -42,6 +46,9 @@ export const StopButton = ({sessionId, projectId}: {sessionId: string; projectId {state === "failed" ? ( Stop failed — try again. ) : null} + {staleMessage ? ( + {staleMessage} + ) : null} ) } diff --git a/web/mobile/src/features/chat/TurnRow.tsx b/web/mobile/src/features/chat/TurnRow.tsx index 1623e0a9f9a..5029b066803 100644 --- a/web/mobile/src/features/chat/TurnRow.tsx +++ b/web/mobile/src/features/chat/TurnRow.tsx @@ -2,7 +2,13 @@ import {useMemo, useState} from "react" import {getMessageTraceId, getMessageUsage} from "@agenta/chat/assets" import {ClientToolPart, type ClientToolOutputHandler} from "@agenta/chat/clientTools" -import {CollapsibleMessageBody, StartupActivity, TurnFooter} from "@agenta/chat/components" +import { + AttachmentCard, + AttachmentCardGrid, + CollapsibleMessageBody, + StartupActivity, + TurnFooter, +} from "@agenta/chat/components" import {useTypewriter} from "@agenta/chat/hooks" import {partSentence, partToolName, rowSummary, type TurnViewModel} from "@agenta/chat/model" import {resolveToolDisplay} from "@agenta/chat/skin" @@ -33,8 +39,9 @@ import { XCircle, } from "lucide-react" +import {Button} from "@/components/ui/button" + import {AssistantMarkdown} from "./AssistantMarkdown" -import {AttachmentPart} from "./AttachmentPart" import {isLiveTextItem} from "./markdownStream" type ToolsItem = Extract @@ -153,8 +160,14 @@ const ToolLines = ({item}: {item: ToolsItem}) => (
) -/** Desktop RunErrorBody's callout: the red card with a title and the reason inline. */ -const RunErrorCallout = ({text}: {text: string}) => { +/** + * Desktop RunErrorBody's callout: the red card with a title and the reason inline. + * + * The retry is here rather than in the turn's hover toolbar because the toolbar hides rewind on + * the LAST turn (rewinding it just re-runs what is already current) — and a failed run is always + * the last turn, so the one turn that most needs re-running was the one turn with no way to do it. + */ +const RunErrorCallout = ({text, onRetry}: {text: string; onRetry?: () => void}) => { const [expanded, setExpanded] = useState(false) const big = text.length > 240 || text.split("\n").length > 4 return ( @@ -178,6 +191,11 @@ const RunErrorCallout = ({text}: {text: string}) => { {expanded ? "Show less" : "Show more"} ) : null} + {onRetry ? ( + + ) : null}
) @@ -219,6 +237,17 @@ const PendingTurn = ({sessionId, workflowId}: {sessionId: string; workflowId?: s ) } +/** The content endpoint carries the session cookie, so a same-origin anchor saves it directly. */ +const downloadAttachment = (url: string, name: string) => { + const link = document.createElement("a") + link.href = url + link.download = name + link.hidden = true + document.body.append(link) + link.click() + link.remove() +} + /** * One transcript turn on the shared bubble chrome — the mobile face of the desktop * AgentMessage: user turns as filled bubbles hugging the right, assistant turns flush on the @@ -273,8 +302,10 @@ export const TurnRow = ({ const body = (
{turn.items.map((item, position) => { + if (item.kind === "files") return null if (item.kind === "part") { if (item.part.type === "text") { + if (!(item.part.text ?? "").trim()) return null // What the user typed renders literally — markdown in your own words // is surprising (desktop parity). if (turn.isUser) { @@ -296,9 +327,6 @@ export const TurnRow = ({ /> ) } - if (item.part.type === "file") { - return - } if (item.part.type === "reasoning") { return ( + onRewind(turn) : undefined} + /> ) : null}
) + // Attachments hang above the bubble rather than inside its fill, so a message reads as its + // files first and its words second. + const fileItems = turn.items.filter((item) => item.kind === "files") + const attachments = fileItems.length ? ( +
+ {fileItems.map((item) => ( + + {item.parts.map((file, n) => ( + + downloadAttachment( + file.url, + file.filename || file.mediaType || "attachment", + ) + } + /> + ))} + + ))} +
+ ) : null + // Attachments with no words: there is no bubble to paint, only the cards. An empty text part + // counts as no words — a turn carrying only files still arrives with one. + const hasBubbleContent = + turn.items.some( + (item) => + item.kind !== "files" && + !( + item.kind === "part" && + item.part.type === "text" && + !(item.part.text ?? "").trim() + ), + ) || turn.status.showError + // Desktop parity: a long pasted message clamps behind "Show more" rather than burying its reply. const content = turn.isUser ? ( @@ -346,7 +418,7 @@ export const TurnRow = ({
} className="min-w-0 max-w-[85%]" classNames={{ @@ -355,7 +427,8 @@ export const TurnRow = ({ : "min-w-0 max-w-full overflow-hidden text-xs", body: "min-w-0 max-w-full overflow-hidden", }} - content={content} + content={hasBubbleContent ? content : null} + header={attachments} /> {/* The turn's information and actions, revealed on hover or keyboard focus — the same lane the desktop transcript reserves, so a settled turn reads quietly until you diff --git a/web/mobile/src/features/chat/stopHereState.ts b/web/mobile/src/features/chat/stopHereState.ts new file mode 100644 index 00000000000..78d9706a3db --- /dev/null +++ b/web/mobile/src/features/chat/stopHereState.ts @@ -0,0 +1,27 @@ +export type CancelledStopAction = + | "settle-parked" + | "settle-idle" + | "abort-settled" + | "abort-retry" + | "await-terminal" + +/** Choose the local follow-up after the server confirms a turn cancellation. */ +export const cancelledStopAction = ({ + parkedAtRequest, + parkedAtResponse, + streaming, + retry, + executionState, +}: { + parkedAtRequest: boolean + parkedAtResponse: boolean + streaming: boolean + retry: boolean + executionState: "stopping" | "idle" +}): CancelledStopAction => { + if (parkedAtRequest || parkedAtResponse) return "settle-parked" + if (!streaming) return "settle-idle" + if (executionState === "idle") return "abort-settled" + if (retry) return "abort-retry" + return "await-terminal" +} diff --git a/web/mobile/src/features/chat/transcriptAdoption.ts b/web/mobile/src/features/chat/transcriptAdoption.ts index 038cd2515e5..610a8e72c1a 100644 --- a/web/mobile/src/features/chat/transcriptAdoption.ts +++ b/web/mobile/src/features/chat/transcriptAdoption.ts @@ -1,11 +1,13 @@ -import type {SessionTranscript} from "@agenta/chat/assets" +import {isSessionTranscript, type SessionTranscript} from "@agenta/chat/assets" import {shouldAdoptServerTranscript} from "@agenta/entities/session" /** What the screen renders right now — the local half of the shared adoption rule. */ export interface RenderedTranscript { messageCount: number - /** Records the rendered transcript was built from; `undefined` before the first adoption. */ - watermark: number | undefined + /** Rows the rendered transcript was built from; used only for legacy, unsequenced logs. */ + recordCount: number | undefined + /** Highest durable sequence the rendered transcript covers. */ + sequenceCursor: number | undefined } /** @@ -20,15 +22,28 @@ export interface RenderedTranscript { * - the watermark is the hook's in-memory one, not desktop's persisted * `agenta:agent-chat:record-counts` — mobile caches no transcript, so it re-syncs on open. */ -export const shouldAdoptTranscript = ( - transcript: SessionTranscript | null, - rendered: RenderedTranscript, -): boolean => - transcript !== null && +export const shouldAdoptTranscript = (transcript: unknown, rendered: RenderedTranscript): boolean => + isSessionTranscript(transcript) && shouldAdoptServerTranscript({ - serverRecordCount: transcript.recordCount, + serverRecordCount: transcript.sequenceCursor ?? transcript.recordCount, serverMessageCount: transcript.messages.length, localMessageCount: rendered.messageCount, - watermark: rendered.watermark, + watermark: + transcript.sequenceCursor === undefined + ? rendered.recordCount + : rendered.sequenceCursor, busy: false, }) + +/** Resolve one watch-triggered read without letting transport failure reach React. */ +export const adoptTranscriptRead = async ( + read: () => Promise, + adopt: (transcript: SessionTranscript) => boolean, +): Promise => { + try { + const transcript = await read() + return isSessionTranscript(transcript) ? adopt(transcript) : false + } catch { + return false + } +} diff --git a/web/mobile/src/features/chat/turnStatus.ts b/web/mobile/src/features/chat/turnStatus.ts index 0046fe0a1a8..018be97c168 100644 --- a/web/mobile/src/features/chat/turnStatus.ts +++ b/web/mobile/src/features/chat/turnStatus.ts @@ -1,3 +1,8 @@ +import {deriveRemoteTurnPresentation} from "@agenta/chat/model" + +/** Mobile presentation for a remote/shared-path run. */ +export const deriveMobileRemoteTurnPresentation = deriveRemoteTurnPresentation + /** * Should the trailing status line show the working pulse? * diff --git a/web/mobile/src/features/chat/useSessionTranscript.ts b/web/mobile/src/features/chat/useSessionTranscript.ts index 5e9ddb867e5..48b8d3a334b 100644 --- a/web/mobile/src/features/chat/useSessionTranscript.ts +++ b/web/mobile/src/features/chat/useSessionTranscript.ts @@ -5,7 +5,7 @@ import {revalidateSessionRecordsAtom} from "@agenta/entities/session" import type {UIMessage} from "ai" import {getDefaultStore} from "jotai" -import {shouldAdoptTranscript} from "./transcriptAdoption" +import {adoptTranscriptRead, shouldAdoptTranscript} from "./transcriptAdoption" /** * Read-only transcript for one session: server record replay via `loadSessionMessages` @@ -35,7 +35,8 @@ export const useSessionTranscript = (sessionId: string, pollMs = 0) => { const messagesRef = useRef([]) // Records the rendered transcript was built from; `undefined` until the first adoption. This // is in-memory only — mobile persists no transcript, so there is nothing to file it against. - const watermarkRef = useRef(undefined) + const recordCountRef = useRef(undefined) + const sequenceCursorRef = useRef(undefined) /** * Apply one delivery behind the shared adoption rule (`shouldAdoptTranscript`). Returns @@ -47,10 +48,13 @@ export const useSessionTranscript = (sessionId: string, pollMs = 0) => { if (sessionRef.current !== sessionId) return false const shouldAdopt = shouldAdoptTranscript(transcript, { messageCount: messagesRef.current.length, - watermark: watermarkRef.current, + recordCount: recordCountRef.current, + sequenceCursor: sequenceCursorRef.current, }) if (!shouldAdopt || !transcript) return false - watermarkRef.current = transcript.recordCount + recordCountRef.current = transcript.recordCount + if (transcript.sequenceCursor !== undefined) + sequenceCursorRef.current = transcript.sequenceCursor messagesRef.current = transcript.messages setMessages(transcript.messages) setState("ready") @@ -72,20 +76,25 @@ export const useSessionTranscript = (sessionId: string, pollMs = 0) => { setState("loading") setMessages([]) messagesRef.current = [] - watermarkRef.current = undefined + recordCountRef.current = undefined + sequenceCursorRef.current = undefined void loadSessionMessages(sessionId, (fresh) => { // Disk-restore revalidation re-delivery — fresh is non-empty by contract. if (cancelled) return if (adoptRef.current(fresh)) adopted = true - }).then((transcript) => { - if (cancelled) return - if (adoptRef.current(transcript)) { - adopted = true - return - } - // Nothing adopted from either delivery → no durable history for this session. - if (!adopted) setState("empty") }) + .then((transcript) => { + if (cancelled) return + if (adoptRef.current(transcript)) { + adopted = true + return + } + // Nothing adopted from either delivery → no durable history for this session. + if (!adopted) setState("empty") + }) + .catch(() => { + if (!cancelled && !adopted) setState("empty") + }) return () => { cancelled = true } @@ -100,20 +109,16 @@ export const useSessionTranscript = (sessionId: string, pollMs = 0) => { inFlightRef.current = true // Invalidate first so the shared-cache read refetches instead of serving staleTime. getDefaultStore().set(revalidateSessionRecordsAtom, sessionId) - void loadSessionMessages(sessionId) - .then((transcript) => { - adoptRef.current(transcript) - }) - // A failed poll keeps what is on screen and waits for the next tick; swallowing it - // here keeps a transient 5xx from surfacing as an unhandled rejection every 3s. - .catch(() => undefined) - .finally(() => { - inFlightRef.current = false - if (pendingRef.current) { - pendingRef.current = false - if (sessionRef.current === sessionId) refresh() - } - }) + void adoptTranscriptRead( + () => loadSessionMessages(sessionId), + (transcript) => adoptRef.current(transcript), + ).finally(() => { + inFlightRef.current = false + if (pendingRef.current) { + pendingRef.current = false + if (sessionRef.current === sessionId) refresh() + } + }) }, [sessionId]) useEffect(() => { diff --git a/web/mobile/src/features/chat/useSessionWatch.ts b/web/mobile/src/features/chat/useSessionWatch.ts index b578ef9df79..d95cdde7e48 100644 --- a/web/mobile/src/features/chat/useSessionWatch.ts +++ b/web/mobile/src/features/chat/useSessionWatch.ts @@ -1,5 +1,6 @@ import {useEffect, useRef, useState} from "react" +import {shouldRefreshLegacyObserverLiveness} from "@agenta/chat/model" import {useQueryClient} from "@tanstack/react-query" import {tryRefreshSession} from "@/lib/auth" @@ -33,15 +34,21 @@ export const useSessionWatch = ({ sessionId, projectId, onRecordsChanged, + onInteractionChanged, + sharedReaderAdvertised = true, }: { sessionId: string projectId: string onRecordsChanged: () => void + onInteractionChanged?: () => void + sharedReaderAdvertised?: boolean }): {connected: boolean} => { const [connected, setConnected] = useState(false) const queryClient = useQueryClient() const onRecordsChangedRef = useRef(onRecordsChanged) onRecordsChangedRef.current = onRecordsChanged + const onInteractionChangedRef = useRef(onInteractionChanged) + onInteractionChangedRef.current = onInteractionChanged useEffect(() => { if (!sessionId || !projectId) return @@ -52,6 +59,7 @@ export const useSessionWatch = ({ let disposed = false let attempt = 0 let lastNotifiedAt = 0 + let lastLivenessRefreshAt = 0 /** Reconnect coverage only — real `records-changed` events are never throttled. */ const notifyOnConnect = () => { @@ -61,8 +69,13 @@ export const useSessionWatch = ({ onRecordsChangedRef.current() } - const invalidateBadges = () => { + const invalidateLiveness = (trackLegacyRefresh = false) => { + if (trackLegacyRefresh) lastLivenessRefreshAt = Date.now() void queryClient.invalidateQueries({queryKey: livenessQueryKey(projectId)}) + } + + const invalidateBadges = (trackLegacyRefresh = false) => { + invalidateLiveness(trackLegacyRefresh) void queryClient.invalidateQueries({ queryKey: actionableInteractionsQueryKey(projectId), }) @@ -110,9 +123,24 @@ export const useSessionWatch = ({ notifyOnConnect() invalidateBadges() }) - es.addEventListener("records-changed", () => onRecordsChangedRef.current()) - es.addEventListener("lifecycle", invalidateBadges) - es.addEventListener("interaction", invalidateBadges) + es.addEventListener("records-changed", () => { + onRecordsChangedRef.current() + const now = Date.now() + if ( + shouldRefreshLegacyObserverLiveness({ + sharedReaderAdvertised, + lastRefreshAt: lastLivenessRefreshAt, + now, + }) + ) { + invalidateLiveness(true) + } + }) + es.addEventListener("lifecycle", () => invalidateBadges(true)) + es.addEventListener("interaction", () => { + invalidateBadges() + onInteractionChangedRef.current?.() + }) es.onerror = () => { setConnected(false) // CONNECTING = built-in auto-reconnect; only a fatal CLOSED needs us. @@ -136,7 +164,7 @@ export const useSessionWatch = ({ if (retryHandle !== undefined) window.clearTimeout(retryHandle) close() } - }, [sessionId, projectId, queryClient]) + }, [sessionId, projectId, queryClient, sharedReaderAdvertised]) return {connected} } diff --git a/web/mobile/src/features/context/useProjectPermission.ts b/web/mobile/src/features/context/useProjectPermission.ts new file mode 100644 index 00000000000..04f392d7f95 --- /dev/null +++ b/web/mobile/src/features/context/useProjectPermission.ts @@ -0,0 +1,32 @@ +import {getAccessClient} from "@agenta/sdk/resources" +import {useQuery} from "@tanstack/react-query" + +export const fetchProjectPermission = async ( + projectId: string, + action: string, +): Promise => { + try { + await getAccessClient().checkPermissions({ + action, + scope_type: "project", + scope_id: projectId, + resource_type: "service", + }) + return true + } catch { + return false + } +} + +/** Read one effective project permission from the authenticated backend. */ +export const useProjectPermission = (projectId: string, action: string): boolean => { + const query = useQuery({ + queryKey: ["mobile", "project-permission", projectId, action], + queryFn: () => fetchProjectPermission(projectId, action), + enabled: Boolean(projectId), + staleTime: 30_000, + retry: false, + }) + + return query.data === true +} diff --git a/web/mobile/src/features/nav/useMobileNavItems.tsx b/web/mobile/src/features/nav/useMobileNavItems.tsx index b5404e4a46e..b66ea871fc3 100644 --- a/web/mobile/src/features/nav/useMobileNavItems.tsx +++ b/web/mobile/src/features/nav/useMobileNavItems.tsx @@ -6,6 +6,7 @@ import { AGENTS_SIDEBAR_KEY, buildHelpDocsNavItem, defineSidebarEntity, + SIDEBAR_AGENT_ORDER_ZONE, resolveChildren, SESSIONS_SIDEBAR_KEY, sidebarAgentRanksAtomFamily, @@ -115,6 +116,8 @@ const mobileAgentsEntity = defineSidebarEntity(MOBILE_NAV_SCOPE_ID, AGENTS_SIDEB listAtom: agentWorkflowsListQueryStateAtom, getLabel: (workflow) => workflow.name || workflow.slug || "Untitled agent", childPath: (workflow) => `/agents/${workflow.id}`, + // The same zone the desktop rail writes: one arrangement, both hosts. + dragZone: SIDEBAR_AGENT_ORDER_ZONE, emptyLabel: "No agents", showAllPath: "/agents", }) diff --git a/web/mobile/src/features/sessions/useActionableInteractions.ts b/web/mobile/src/features/sessions/useActionableInteractions.ts index 879e983c728..de988c41dd8 100644 --- a/web/mobile/src/features/sessions/useActionableInteractions.ts +++ b/web/mobile/src/features/sessions/useActionableInteractions.ts @@ -1,23 +1,14 @@ -import { - queryInteractions, - type SessionInteraction, - type SessionStream, -} from "@agenta/entities/session" -import {useQuery, useQueryClient} from "@tanstack/react-query" +import {queryInteractions, type SessionInteraction} from "@agenta/entities/session" +import {useQuery} from "@tanstack/react-query" -import {livenessQueryKey} from "./useLivenessPoll" +import {useLivenessPoll} from "./useLivenessPoll" export const actionableInteractionsQueryKey = (projectId: string) => ["mobile", "actionable-interactions", projectId] as const -/** - * Every pending HITL request across the project in ONE query (`session_id` omitted, - * `actionable_only: true`) — the list-badge primitive. Same cadence rules as the liveness poll: - * 15s while anything is pending OR alive (a running turn is what mints new gates), stops when - * idle, re-checks on focus. - */ +/** Poll pending project HITL requests while a gate exists or a turn can create one. */ export const useActionableInteractions = (projectId: string) => { - const queryClient = useQueryClient() + const liveness = useLivenessPoll(projectId) return useQuery({ queryKey: actionableInteractionsQueryKey(projectId), queryFn: ({signal}) => @@ -26,10 +17,8 @@ export const useActionableInteractions = (projectId: string) => { staleTime: 10_000, refetchInterval: (query) => { if ((query.state.data?.length ?? 0) > 0) return 15_000 - const alive = queryClient.getQueryData( - livenessQueryKey(projectId), - ) - return (alive?.length ?? 0) > 0 ? 15_000 : false + // Only running turns can mint new gates. + return (liveness.data ?? []).some((stream) => stream.flags?.is_running) ? 15_000 : false }, refetchOnWindowFocus: true, }) diff --git a/web/mobile/src/features/sessions/useLivenessPoll.ts b/web/mobile/src/features/sessions/useLivenessPoll.ts index bfa5a6b0238..00e9781ae7b 100644 --- a/web/mobile/src/features/sessions/useLivenessPoll.ts +++ b/web/mobile/src/features/sessions/useLivenessPoll.ts @@ -1,15 +1,16 @@ -import {deriveStreamNest, querySessionStreams, type SessionStream} from "@agenta/entities/session" +import { + deriveStreamNest, + livenessPollInterval, + querySessionStreams, + type SessionStream, +} from "@agenta/entities/session" import {useQuery} from "@tanstack/react-query" -/** Shared key so other polls (interactions) can read the alive set from the cache. */ +/** Shared key for the project liveness subscription. */ export const livenessQueryKey = (projectId: string) => ["mobile", "session-liveness", projectId] as const -/** - * Backend liveness for the project's sessions — mirrors the desktop pattern - * (oss AgentChatSlice state/liveness.ts): ONE project-scoped `is_alive=true` query backs every - * badge, low-priority, 15s while anything is alive, stops when idle, re-checks on focus. - */ +/** Poll quickly while work runs, slowly while a session remains warm, and stop when idle. */ export const useLivenessPoll = (projectId: string) => useQuery({ queryKey: livenessQueryKey(projectId), @@ -17,7 +18,7 @@ export const useLivenessPoll = (projectId: string) => querySessionStreams({projectId, isAlive: true, abortSignal: signal, lowPriority: true}), enabled: Boolean(projectId), staleTime: 10_000, - refetchInterval: (query) => ((query.state.data?.length ?? 0) > 0 ? 15_000 : false), + refetchInterval: (query) => livenessPollInterval(query.state.data), refetchOnWindowFocus: true, }) diff --git a/web/mobile/src/features/sessions/useSessionRowMenu.ts b/web/mobile/src/features/sessions/useSessionRowMenu.ts index 3208e34c5b2..74e79871815 100644 --- a/web/mobile/src/features/sessions/useSessionRowMenu.ts +++ b/web/mobile/src/features/sessions/useSessionRowMenu.ts @@ -1,8 +1,9 @@ import {useCallback} from "react" import type {SessionStream} from "@agenta/entities/session" +import {sessionRoutePath} from "@agenta/sessions/link" import {sessionOpenTarget, type SessionRowVm} from "@agenta/sessions/row" -import {useSessionActions} from "@agenta/sessions-ui" +import {useSessionActions, type SessionActionTarget} from "@agenta/sessions-ui" import {useRouter} from "next/router" const targetFor = (vm: SessionRowVm) => ({ @@ -29,10 +30,15 @@ const targetForStream = (session: SessionStream) => ({ */ export const useSessionRowMenu = (base: string) => { const router = useRouter() - const actions = useSessionActions() + // Every session has its own page here, so no agent is needed for a link. + const sharePathFor = useCallback( + ({sessionId}: SessionActionTarget) => sessionRoutePath(base, sessionId), + [base], + ) + const actions = useSessionActions({sharePathFor}) const open = useCallback( - (vm: SessionRowVm) => void router.push(`${base}/sessions/${vm.id}`), + (vm: SessionRowVm) => void router.push(sessionRoutePath(base, vm.id)), [base, router], ) diff --git a/web/mobile/tests/unit/projectPermission.test.ts b/web/mobile/tests/unit/projectPermission.test.ts new file mode 100644 index 00000000000..4af9ee29324 --- /dev/null +++ b/web/mobile/tests/unit/projectPermission.test.ts @@ -0,0 +1,25 @@ +import {beforeEach, describe, expect, it, vi} from "vitest" + +const checkPermissions = vi.fn() + +vi.mock("@agenta/sdk/resources", () => ({ + getAccessClient: () => ({checkPermissions}), +})) + +import {fetchProjectPermission} from "../../src/features/context/useProjectPermission" + +describe("mobile project permissions", () => { + beforeEach(() => checkPermissions.mockReset()) + + it("asks the backend for the effective project permission", async () => { + checkPermissions.mockResolvedValue({effect: "allow"}) + + await expect(fetchProjectPermission("project-1", "edit_secret")).resolves.toBe(true) + expect(checkPermissions).toHaveBeenCalledWith({ + action: "edit_secret", + scope_type: "project", + scope_id: "project-1", + resource_type: "service", + }) + }) +}) diff --git a/web/mobile/tests/unit/stopHereState.test.ts b/web/mobile/tests/unit/stopHereState.test.ts new file mode 100644 index 00000000000..f7eb37dbdd5 --- /dev/null +++ b/web/mobile/tests/unit/stopHereState.test.ts @@ -0,0 +1,65 @@ +import {describe, expect, it} from "vitest" + +import {cancelledStopAction} from "../../src/features/chat/stopHereState" + +describe("mobile local Stop state", () => { + it("settles a parked approval as soon as the server confirms cancellation", () => { + expect( + cancelledStopAction({ + parkedAtRequest: true, + parkedAtResponse: true, + streaming: false, + retry: false, + executionState: "stopping", + }), + ).toBe("settle-parked") + }) + + it("settles when a streaming run parks before cancellation returns", () => { + expect( + cancelledStopAction({ + parkedAtRequest: false, + parkedAtResponse: true, + streaming: false, + retry: false, + executionState: "stopping", + }), + ).toBe("settle-parked") + }) + + it("waits for terminal stream evidence after cancelling an active stream", () => { + expect( + cancelledStopAction({ + parkedAtRequest: false, + parkedAtResponse: false, + streaming: true, + retry: false, + executionState: "stopping", + }), + ).toBe("await-terminal") + }) + + it("hard-aborts an active stream after the watchdog retry is accepted", () => { + expect( + cancelledStopAction({ + parkedAtRequest: false, + parkedAtResponse: false, + streaming: true, + retry: true, + executionState: "stopping", + }), + ).toBe("abort-retry") + }) + + it("settles an acknowledged legacy Stop without waiting for the client deadline", () => { + expect( + cancelledStopAction({ + parkedAtRequest: false, + parkedAtResponse: false, + streaming: true, + retry: false, + executionState: "idle", + }), + ).toBe("abort-settled") + }) +}) diff --git a/web/mobile/tests/unit/transcriptAdoption.test.ts b/web/mobile/tests/unit/transcriptAdoption.test.ts index 30de438592a..a25f9562f53 100644 --- a/web/mobile/tests/unit/transcriptAdoption.test.ts +++ b/web/mobile/tests/unit/transcriptAdoption.test.ts @@ -1,56 +1,75 @@ import type {SessionTranscript} from "@agenta/chat/assets" import type {UIMessage} from "ai" -import {describe, expect, it} from "vitest" +import {describe, expect, it, vi} from "vitest" -import {shouldAdoptTranscript} from "../../src/features/chat/transcriptAdoption" +import { + adoptTranscriptRead, + shouldAdoptTranscript, +} from "../../src/features/chat/transcriptAdoption" -const transcript = (messageCount: number, recordCount: number): SessionTranscript => ({ +const transcript = ( + messageCount: number, + recordCount: number, + sequenceCursor?: number, +): SessionTranscript => ({ messages: Array.from( {length: messageCount}, (_, i) => ({id: `m${i}`, role: "assistant", parts: []}) as UIMessage, ), recordCount, + sequenceCursor, +}) + +const rendered = (messageCount: number, recordCount?: number, sequenceCursor?: number) => ({ + messageCount, + recordCount, + sequenceCursor, }) describe("shouldAdoptTranscript", () => { it("adopts the first delivery for a freshly opened session", () => { - expect( - shouldAdoptTranscript(transcript(3, 12), {messageCount: 0, watermark: undefined}), - ).toBe(true) + expect(shouldAdoptTranscript(transcript(3, 12), rendered(0))).toBe(true) }) it("ignores a failed / history-less load", () => { - expect(shouldAdoptTranscript(null, {messageCount: 0, watermark: undefined})).toBe(false) - expect( - shouldAdoptTranscript(transcript(0, 0), {messageCount: 0, watermark: undefined}), - ).toBe(false) + expect(shouldAdoptTranscript(null, rendered(0))).toBe(false) + expect(shouldAdoptTranscript(undefined, rendered(0))).toBe(false) + expect(shouldAdoptTranscript(transcript(0, 0), rendered(0))).toBe(false) }) it("ignores a re-read that brought no new records", () => { - expect(shouldAdoptTranscript(transcript(3, 12), {messageCount: 3, watermark: 12})).toBe( - false, - ) + expect(shouldAdoptTranscript(transcript(3, 12), rendered(3, 12))).toBe(false) }) // Issue #5530: a turn that grows in place (tool results landing, an approval round-trip // completing) keeps its message count, so only the record watermark sees the growth. it("adopts a grown log even when the message count is unchanged", () => { - expect(shouldAdoptTranscript(transcript(3, 40), {messageCount: 3, watermark: 12})).toBe( - true, - ) + expect(shouldAdoptTranscript(transcript(3, 40), rendered(3, 12))).toBe(true) }) it("never trades down to a snapshot shorter than what is on screen", () => { - expect(shouldAdoptTranscript(transcript(2, 40), {messageCount: 3, watermark: 12})).toBe( - false, - ) + expect(shouldAdoptTranscript(transcript(2, 40), rendered(3, 12))).toBe(false) }) // Mobile keeps no persisted transcript, so a session it has rendered before still opens with // an absent watermark — which reads as 0 and re-syncs from the durable log once. it("re-syncs when the watermark is absent", () => { - expect( - shouldAdoptTranscript(transcript(3, 12), {messageCount: 3, watermark: undefined}), - ).toBe(true) + expect(shouldAdoptTranscript(transcript(3, 12), rendered(3))).toBe(true) + }) + + it("adopts sequence growth when retention keeps the row count flat", () => { + expect(shouldAdoptTranscript(transcript(3, 20, 101), rendered(3, 20, 100))).toBe(true) + }) +}) + +describe("adoptTranscriptRead", () => { + it.each([ + ["rejected", () => Promise.reject(new Error("network changed"))], + ["undefined", () => Promise.resolve(undefined)], + ])("keeps the mobile transcript when a watch-triggered read is %s", async (_failure, read) => { + const adopt = vi.fn() + + await expect(adoptTranscriptRead(read, adopt)).resolves.toBe(false) + expect(adopt).not.toHaveBeenCalled() }) }) diff --git a/web/mobile/tests/unit/turnStatus.test.ts b/web/mobile/tests/unit/turnStatus.test.ts index b432656eb23..6b8ab043281 100644 --- a/web/mobile/tests/unit/turnStatus.test.ts +++ b/web/mobile/tests/unit/turnStatus.test.ts @@ -1,6 +1,9 @@ import {describe, expect, it} from "vitest" -import {showTrailingWorkingPulse} from "@/features/chat/turnStatus" +import { + deriveMobileRemoteTurnPresentation, + showTrailingWorkingPulse, +} from "@/features/chat/turnStatus" const userTurn = {isUser: true, isStreamingTurn: false} const streamingAssistant = {isUser: false, isStreamingTurn: true} @@ -25,3 +28,60 @@ describe("showTrailingWorkingPulse", () => { expect(showTrailingWorkingPulse(false, [])).toBe(false) }) }) + +describe("deriveMobileRemoteTurnPresentation", () => { + it.each([ + { + name: "renders activity and no strip for a ready reader", + input: {livenessRunning: true, sharedReaderAdvertised: true, readerReady: true}, + expected: {showActivity: true, showStrip: false}, + }, + { + name: "renders the strip while the reader is not ready", + input: {livenessRunning: true, sharedReaderAdvertised: true, readerReady: false}, + expected: {showActivity: false, showStrip: true}, + }, + { + name: "renders the strip when the feature is off", + input: {livenessRunning: true, sharedReaderAdvertised: false, readerReady: false}, + expected: {showActivity: false, showStrip: true}, + }, + { + name: "does not render the strip in the tab that owns a continuation", + input: { + livenessRunning: true, + sharedReaderAdvertised: true, + readerReady: false, + ownedContinuation: true, + }, + expected: {showActivity: false, showStrip: false}, + }, + ])("$name", ({input, expected}) => { + expect(deriveMobileRemoteTurnPresentation(input)).toEqual(expected) + }) + + it("shows the flag-off observer banner only while session-stream liveness is running", () => { + const input = { + snapshotRunning: true, + sharedReaderAdvertised: false, + readerReady: false, + } + + expect( + deriveMobileRemoteTurnPresentation({...input, livenessRunning: true}).showStrip, + ).toBe(true) + expect( + deriveMobileRemoteTurnPresentation({...input, livenessRunning: false}).showStrip, + ).toBe(false) + }) + + it("hides the banner when the advertised reader is ready", () => { + expect( + deriveMobileRemoteTurnPresentation({ + livenessRunning: true, + sharedReaderAdvertised: true, + readerReady: true, + }).showStrip, + ).toBe(false) + }) +}) diff --git a/web/oss/package.json b/web/oss/package.json index a47e3048188..593d9beb363 100644 --- a/web/oss/package.json +++ b/web/oss/package.json @@ -1,6 +1,6 @@ { "name": "@agenta/oss", - "version": "0.114.4", + "version": "0.115.1", "private": true, "engines": { "node": "24.x" diff --git a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx index 0327cdad555..e3ba7db16c0 100644 --- a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx @@ -19,6 +19,7 @@ import {commandSessionStream} from "@agenta/entities/session" import {workflowMolecule} from "@agenta/entities/workflow" import {DriveSessionProvider} from "@agenta/entity-ui/drive" import {workflowRevisionDrawerOpenAtom} from "@agenta/playground-ui/workflow-revision-drawer" +import {currentSessionParamForScope, writeSessionParamForScope} from "@agenta/sessions/link" import { pendingSessionOpensAtom, removePendingSessionOpensAtom, @@ -208,6 +209,21 @@ const AgentChatPanel = ({entityId}: {entityId: string}) => { removePendingOpens(fresh) }, [pendingOpensForScope, adoptSession, addSession, removePendingOpens]) + // `?session_id=` names the session on screen, so a pasted link and a reload both land on it. + // Read once per scope — every in-app open goes through the queue above, which rewrites the + // param itself. Adopting is the same verb: the session hydrates from records either way. + const [linked, setLinked] = useState(() => ({ + scope, + id: currentSessionParamForScope(scope), + })) + if (linked.scope !== scope) setLinked({scope, id: currentSessionParamForScope(scope)}) + const linkedSessionId = linked.scope === scope ? linked.id : "" + useEffect(() => { + if (!linkedSessionId) return + adoptSession({id: linkedSessionId}) + setLinked({scope, id: ""}) + }, [linkedSessionId, scope, adoptSession]) + // Always keep at least one tab. Re-arms when the list drains without double-firing // under StrictMode. Held while a deep-linked session is pending: adopting it satisfies the // at-least-one-tab rule, and seeding first would leave a stray blank tab beside it. @@ -218,13 +234,13 @@ const AgentChatPanel = ({entityId}: {entityId: string}) => { const seeded = useRef(false) useEffect(() => { if (!projectId) return - if (pendingOpensForScope.length > 0) return + if (pendingOpensForScope.length > 0 || linkedSessionId) return if (sessions.length === 0 && !seeded.current) { seeded.current = true addSession() } if (sessions.length > 0) seeded.current = false - }, [projectId, sessions.length, addSession, pendingOpensForScope]) + }, [projectId, sessions.length, addSession, pendingOpensForScope, linkedSessionId]) // Sweep husks (never-run, untitled, empty sessions) that accumulated in history — from before // the close-time cleanup, or orphaned by a reload. Open tabs are untouched, so this never drops @@ -236,6 +252,14 @@ const AgentChatPanel = ({entityId}: {entityId: string}) => { // Tolerate a stale active id (its tab was closed) by falling back to the first tab. const activeId = sessions.some((s) => s.id === rawActiveId) ? rawActiveId : sessions[0]?.id + // Keep the address bar on the session you're looking at, so it stays copyable as you switch + // tabs. Held until a pending link is adopted, or this would overwrite the link with whatever + // tab the last visit left open. + useEffect(() => { + if (linkedSessionId || !activeId) return + writeSessionParamForScope(scope, activeId) + }, [activeId, scope, linkedSessionId]) + // Keyboard shortcuts. Switch and rename happen inside per-session components, so they travel as // requests on the shared atoms. The drawer mounts a second panel over this one, so exactly one // of the two listens; onboarding hides the bar entirely and allows a single session. diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index 2b3e63b3f98..b0066f5d9f8 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -8,11 +8,13 @@ import { sideEffectingToolsInRange, } from "@agenta/chat/assets" import {getMessageTraceId} from "@agenta/chat/assets" -import {ConnectionFocusProvider} from "@agenta/chat/components" +import {getPendingSecretInteractions} from "@agenta/chat/clientTools" +import {AttachmentDropOverlay, ConnectionFocusProvider} from "@agenta/chat/components" import { stagedFilesToParts, useComposerAttachments, useAgentChatQueue, + useSessionLivePreview, type QueuedMessage, } from "@agenta/chat/hooks" import { @@ -22,9 +24,15 @@ import { useVoiceComposer, } from "@agenta/chat/hooks" import {type SessionRunStatus} from "@agenta/chat/model" -import {ignoreStreamRejection, isEmptyAssistantTurn, isVisiblePart} from "@agenta/chat/model" -import {getPendingApprovals} from "@agenta/chat/model" -import {sessionMessagesAtom, setSessionStatusAtom} from "@agenta/chat/state" +import { + ignoreStreamRejection, + isEmptyAssistantTurn, + isSessionBusyRefusal, + isVisiblePart, +} from "@agenta/chat/model" +import {getInteractionAvailability, getLivePendingApprovals} from "@agenta/chat/model" +import {withoutSharedSenderAcceptanceMessages} from "@agenta/chat/model" +import {hasSessionChat, sessionMessagesAtom, setSessionStatusAtom} from "@agenta/chat/state" import {clearSessionFresh} from "@agenta/chat/state" import { contextWindowForModel, @@ -32,6 +40,7 @@ import { modalitiesForModel, workflowMolecule, } from "@agenta/entities/workflow" +import {SecretRequestDock} from "@agenta/entity-ui/clientTools" import {ContextRail} from "@agenta/entity-ui/drive" import {DriveSessionProvider} from "@agenta/entity-ui/drive" import {filesDrawerStagedAtomFamily} from "@agenta/entity-ui/drive" @@ -41,22 +50,24 @@ import {isOverlayOpen} from "@agenta/shared/utils" import {modal} from "@agenta/ui/app-message" import {type RichChatInputHandle} from "@agenta/ui/rich-chat-input" import {isAltChord} from "@agenta/ui/shortcuts" -import {UploadSimple} from "@phosphor-icons/react" import {type FileUIPart, type UIMessage} from "ai" import {useAtomValue, useSetAtom, useStore} from "jotai" import {DriveFileLinkProvider} from "@/oss/components/Drives/DriveFileLinkProvider" import {useSessionFilesPane} from "@/oss/components/Drives/SessionFilesPane" import {TEMPLATE_STRIP_MODE} from "@/oss/components/pages/agent-home/assets/constants" +import {useProjectPermissions} from "@/oss/hooks/useProjectPermissions" import {isAgentFileUploadsEnabled} from "./assets/constants" import {CONTENT_VISIBILITY_ENABLED} from "./assets/conversationLayout" import {runWithInFlightSubmit} from "./assets/inFlightSubmit" +import {restoreHeldRefusedSend} from "./assets/refusedMessageRecovery" import AgentComposerDock from "./components/AgentComposerDock" import AgentTranscript from "./components/AgentTranscript" import AgentTurn from "./components/AgentTurn" import AttachmentViewerDrawer from "./components/AttachmentViewerDrawer" import {Inspector} from "./components/Inspector/Inspector" +import MessageAttachmentViewer from "./components/MessageAttachmentViewer" import RightPanelSplit from "./components/RightPanel/RightPanelSplit" import TranscriptPlaceholder from "./components/TranscriptPlaceholder" import {useAgentChatSession} from "./hooks/useAgentChatSession" @@ -67,6 +78,7 @@ import {useScrollIntent} from "./hooks/useScrollIntent" import {useTranscriptScroll} from "./hooks/useTranscriptScroll" import {useTurnInspector} from "./hooks/useTurnInspector" import {useVirtuosoTranscript} from "./hooks/useVirtuosoTranscript" +import {deriveSessionRemoteTurnPresentation} from "./state/liveness" import {useChatScopeKey} from "./state/scope" import { activeSessionIdAtomFamily, @@ -83,8 +95,8 @@ import {focusComposerRequestAtom, matchesSessionRequest} from "./state/uiRequest * Messages persist to localStorage (seeded on mount, written when the stream settles) so the * tab survives a reload / revision swap. * - * Design decisions baked in (docs/design/agent-workflows/playground-agent-generation.md): - * - D9 teardown: abort the in-flight stream on unmount (tab close / revision swap). + * Design decisions baked in (docs/design/agent-workflows/projects/session-chat-registry/decisions.md): + * - D9 teardown: release the chat on unmount; it is preserved while its session tab is open. * - DT3 cancelled state: a stopped stream tags its partial bubble "Stopped" + offers Resend. * - DT4 autoscroll: stick to bottom while streaming; pause when scrolled up; "jump to latest". * - DT5 a11y: the message log is an aria-live region; controls are keyboard-operable. @@ -100,13 +112,16 @@ const AgentConversation = ({ /** Shared across the panel's session panes: the composer entrance plays only once. */ revealPlayedRef: MutableRefObject }) => { + const {hasPermission} = useProjectPermissions() const store = useStore() // Workflow artifact id for this conversation — the key for the agent's durable `agent-files` // mount, folded into the session drive by the Drive surfaces below (via the drive context). const artifactId = useAtomValue(workflowMolecule.selectors.workflowId(entityId)) const setSessionStatus = useSetAtom(setSessionStatusAtom) // Seed once from the persisted store (read imperatively so our own writes don't feed back). - const [initialMessages] = useState(() => store.get(sessionMessagesAtom)[sessionId] ?? []) + const [initialMessages] = useState(() => + withoutSharedSenderAcceptanceMessages(store.get(sessionMessagesAtom)[sessionId] ?? []), + ) const richInputRef = useRef(null) const composer = useComposerDraft({sessionId, richInputRef, revealPlayedRef}) @@ -124,6 +139,10 @@ const AgentConversation = ({ status, busy, error, + connectionWarning, + acceptedRunPending, + turnDeliverySource, + settleSharedTurn, sendMessage, regenerate, setMessages, @@ -132,15 +151,49 @@ const AgentConversation = ({ isHydrating, hydratedEmpty, stopped, + stopping, setStopped, handleStop, handleClientToolOutput, + adoptRevision, markLiveGate, answerApproval, resumeOrphaned, isSeen, - runningElsewhere, + runningElsewhere: livenessRunningElsewhere, + sharedReaderAdvertised, + refreshFromRecords, + setSharedSenderReady, } = useAgentChatSession({entityId, sessionId, initialMessages, intent: scrollIntent}) + const { + messages: previewMessages, + runningFromSnapshot, + readerReady, + } = useSessionLivePreview({ + sessionId, + sharedReaderAdvertised, + runningElsewhere: livenessRunningElsewhere, + sender: true, + onReadyChange: setSharedSenderReady, + onExecutionSettled: settleSharedTurn, + onDisconnect: refreshFromRecords, + }) + const remoteTurn = deriveSessionRemoteTurnPresentation({ + livenessRunning: livenessRunningElsewhere, + snapshotRunning: runningFromSnapshot || acceptedRunPending, + sharedReaderAdvertised, + readerReady, + ownedContinuation: acceptedRunPending, + }) + const transcriptMessages = useMemo(() => { + const durableMessages = withoutSharedSenderAcceptanceMessages(messages) + if (turnDeliverySource === "legacy" || previewMessages.length === 0) return durableMessages + return [...durableMessages, ...previewMessages] + }, [messages, previewMessages, turnDeliverySource]) + const transcriptBusy = + busy || + remoteTurn.showActivity || + (turnDeliverySource !== "legacy" && previewMessages.length > 0) // Turn Inspector: open state, the focused turn, and the assistant → turn-number mapping. const { @@ -236,6 +289,7 @@ const AgentConversation = ({ attachmentsSettled, isDragging, addFiles, + restoreAttachments, } = attachments // Playground-native onboarding: the hero, Create-agent / Continue-in-IDE, the template strip @@ -330,9 +384,20 @@ const AgentConversation = ({ // one-by-one once the turn truly settles (never mid-approval). A user stop is the exception — // it voids the pending gate, so `stopped` lets a fresh send go immediately (not queue). An // orphaned restored resume shape (reload mid-approval-resume) voids it the same way. - const {queued, submit, removeQueued, clearQueue, hitlPending} = useAgentChatQueue({ + const { + queued, + submit, + removeQueued, + hitlPending, + editingId, + beginEdit, + cancelEdit, + commitEdit, + takeLastSent, + } = useAgentChatQueue({ status, messages, + acceptedRunPending, stopped, resumeOrphaned, sendQueued, @@ -365,9 +430,12 @@ const AgentConversation = ({ [answerApproval, markLiveGate, submit], ) - // Pending HITL gates for the paused turn, surfaced in the persistent ApprovalDock above the - // composer (not inline in the transcript, so a paused run can't scroll out of reach). - const pendingApprovals = useMemo(() => getPendingApprovals(messages), [messages]) + const interactionAvailability = getInteractionAvailability({stopped, stopping, streaming: busy}) + const pendingSecret = useMemo(() => getPendingSecretInteractions(messages)[0], [messages]) + const pendingApprovals = useMemo( + () => getLivePendingApprovals(messages, {stopped: !interactionAvailability.approvals}), + [messages, interactionAvailability.approvals], + ) // Parked connect interactions on the paused turn → the connect dock owns their actions (the // inline rows are passive markers). Gated off while busy (`input-streaming` isn't parked yet) // and after a user stop (the run is dead, nothing to settle — matches the queue's stop void). @@ -376,13 +444,13 @@ const AgentConversation = ({ // is already false by the time the dock should open. const elicits = useElicitationDock({ messages, - enabled: !busy && !stopped, + enabled: interactionAvailability.parkedDocks, approvalsPending: pendingApprovals.length > 0, onOutput: handleClientToolOutput, }) const connects = useConnectionDock({ messages, - enabled: !busy && !stopped, + enabled: interactionAvailability.parkedDocks, approvalsPending: pendingApprovals.length > 0, elicitationPending: elicits.open, }) @@ -390,13 +458,12 @@ const AgentConversation = ({ // arriving below to jump to. const gateOpen = jumpGateOpen({ approvals: pendingApprovals.length, - elicitationOpen: false, + elicitationOpen: Boolean(pendingSecret), connectionOpen: connects.open, }) // Publish this session's run state (single source of truth: drives the tab bar's status dot // AND the Session inspector's live-watcher signal, which derives "streaming" from `running`). - // Precedence error > awaiting approval > running > idle. Reset to idle on unmount so a closed - // tab keeps no stale dot and stops claiming it's the live watcher. + // Precedence error > awaiting approval > running > idle. // `hitlPending` reads only the LAST assistant message, so the moment a new turn starts // streaming (or hydration reshapes the transcript) a still-pending interaction in an // EARLIER message stops counting — status collapses to idle, the settle stamp lands, and @@ -413,6 +480,28 @@ const AgentConversation = ({ }), [messages], ) + const refusedSendRef = useRef(undefined) + const restoreRefusedSend = useCallback( + () => restoreHeldRefusedSend(refusedSendRef, richInputRef.current, restoreAttachments), + [restoreAttachments], + ) + // Restore a refused send after the editor's synchronous submit clear. + useEffect(() => { + if (!error || !isSessionBusyRefusal(error)) return + if (!refusedSendRef.current) refusedSendRef.current = takeLastSent() + requestAnimationFrame(() => { + restoreRefusedSend() + }) + }, [error, restoreRefusedSend, takeLastSent]) + + const handleComposerChange = useCallback( + (text: string) => { + composer.handleComposerChange(text) + if (!text.trim()) restoreRefusedSend() + }, + [composer.handleComposerChange, restoreRefusedSend], + ) + useEffect(() => { const status: SessionRunStatus = error ? "error" @@ -423,8 +512,14 @@ const AgentConversation = ({ : "idle" setSessionStatus({id: sessionId, status}) }, [error, hitlPending, anyPendingInteraction, busy, sessionId, setSessionStatus]) + // On unmount, retire the dot ONLY if the run went with us. A chat preserved past this mount + // (route change with the tab still open) is still this browser's run to report, so it keeps its + // status until it settles — `useAgentChatSession`'s `onFinish` retires it then. The session hook + // releases the chat in an earlier cleanup, so the registry is already authoritative here. useEffect( - () => () => setSessionStatus({id: sessionId, status: "idle"}), + () => () => { + if (!hasSessionChat(sessionId)) setSessionStatus({id: sessionId, status: "idle"}) + }, [sessionId, setSessionStatus], ) @@ -488,11 +583,16 @@ const AgentConversation = ({ // Exactly one scroll engine owns the transcript: Virtuoso when it's enabled in the playground // settings, the SC-1..4 DOM engine otherwise (each bails on the other's flag). Both act on the // shared `scrollIntent`, so producers never care which is live. - const virt = useVirtuosoTranscript({intent: scrollIntent, sessionId, messages, status}) + const virt = useVirtuosoTranscript({ + intent: scrollIntent, + sessionId, + messages: transcriptMessages, + status, + }) const useVirtuoso = virt.enabled const scroll = useTranscriptScroll({ intent: scrollIntent, - messages, + messages: transcriptMessages, status, useVirtuoso, }) @@ -501,14 +601,22 @@ const AgentConversation = ({ trimmed: string, fileParts: FileUIPart[] | undefined, consumedUids: string[], + stagedFiles: typeof files, ) => { - // Glide to the bottom; the min-h-full active turn makes that show the new question at the top - // with the answer streaming below. Park during the glide, follow again on settle. Clear any - // prior "stopped" marker — it's resolved by asking again. - scrollIntent.armGlide() - setStopped(false) - // One path: `submit` sends now or queues behind held messages via the shared release gate. - submit({text: trimmed, fileParts}) + if (editingId) { + // A rewrite of a held message: nothing is sent, so the transcript must not move. + // The input clears itself on submit, so the displaced draft goes back after that. + const draft = commitEdit({text: trimmed, fileParts, stagedFiles}) + if (draft) requestAnimationFrame(() => richInputRef.current?.setMarkdown(draft)) + } else { + // Glide to the bottom; the min-h-full active turn makes that show the new question at the + // top with the answer streaming below. Park during the glide, follow again on settle. + // Clear any prior "stopped" marker — it's resolved by asking again. + scrollIntent.armGlide() + setStopped(false) + // One path: `submit` sends now or queues behind held messages via the shared release gate. + submit({text: trimmed, fileParts, stagedFiles}) + } // The message left the composer — drop its persisted draft (and any pending capture). composer.clearDraft() onboardingChat.consumeTemplateProvenance() @@ -544,12 +652,11 @@ const AgentConversation = ({ reason: "couldn't be read — remove it and attach it again", })), ) - attachments.setAttachmentsOpen(true) return } fileParts = parts } - finishSubmit(trimmed, fileParts, stagedUids) + finishSubmit(trimmed, fileParts, stagedUids, files) return } @@ -562,7 +669,7 @@ const AgentConversation = ({ const fileParts = outboundFiles.length ? stagedFilesToParts(outboundFiles, sessionId) : undefined - finishSubmit(trimmed, fileParts, stagedUids) + finishSubmit(trimmed, fileParts, stagedUids, outboundFiles) }) handleSubmitRef.current = handleSubmit @@ -607,10 +714,11 @@ const AgentConversation = ({ // fill. Keeping the fill on a STABLE element — not hopping it from the user bubble to the assistant // bubble when the answer arrives — avoids the mid-stream layout jump. const lastUserIndex = (() => { - for (let i = messages.length - 1; i >= 0; i--) if (messages[i].role === "user") return i + for (let i = transcriptMessages.length - 1; i >= 0; i--) + if (transcriptMessages[i].role === "user") return i return -1 })() - const activeStart = lastUserIndex >= 0 ? lastUserIndex : messages.length + const activeStart = lastUserIndex >= 0 ? lastUserIndex : transcriptMessages.length // The fill = min-h-full on the active turn whenever there's PRIOR conversation above it (so the // question can sit at the top). Derived from layout, NOT from `busy` — so it persists when the turn // settles instead of being yanked away (which clamped the scroll and jumped the view). @@ -623,6 +731,7 @@ const AgentConversation = ({ ) const handleResend = useCallback( (messageId: string) => { + if (busyRef.current) return const msgs = messagesRef.current const idx = msgs.findIndex((m) => m.id === messageId) // Same hazard as rewind (#6362 review): regenerating drops the failed assistant @@ -651,7 +760,7 @@ const AgentConversation = ({ ) const renderMessage = (message: UIMessage, index: number) => { - const isLast = index === messages.length - 1 + const isLast = index === transcriptMessages.length - 1 const isAssistantTurn = message.role === "assistant" const turn = turnNumbers.get(message.id) const isInspected = isAssistantTurn && inspectedTurn != null && turn === inspectedTurn @@ -664,13 +773,14 @@ const AgentConversation = ({ // never during render (unsafe under StrictMode's double invoke). enter={!isSeen(message.id)} isLast={isLast} - isStreaming={busy && isLast} - precededByEmptyAssistant={index > 0 && isEmptyAssistantTurn(messages[index - 1])} - // A user turn has no trace of its own; borrow the paired (next) assistant turn's - // trace so its timestamp dates from the run, not this browser's first-seen stamp. + isStreaming={transcriptBusy && isLast} + precededByEmptyAssistant={ + index > 0 && isEmptyAssistantTurn(transcriptMessages[index - 1]) + } + // A user turn borrows its paired assistant trace so the timestamp reflects the run. turnTraceId={ - message.role === "user" && messages[index + 1] - ? getMessageTraceId(messages[index + 1]) + message.role === "user" && transcriptMessages[index + 1] + ? getMessageTraceId(transcriptMessages[index + 1]) : undefined } inspected={isInspected} @@ -681,13 +791,15 @@ const AgentConversation = ({ turn={turn} onInspectTurn={handleInspectTurn} showWorking={ - isLast && busy && (!isAssistantTurn || message.parts.some(isVisiblePart)) + isLast && + transcriptBusy && + (!isAssistantTurn || message.parts.some(isVisiblePart)) } // Paused on the user (never concurrently with showWorking — hitlPending implies not // busy): keeps the turn from reading as finished while the queue holds sends. showWaiting={isLast && isAssistantTurn && !busy && hitlPending} showStopped={stopped && isLast && isAssistantTurn} - resendDisabled={busy} + resendDisabled={busy || acceptedRunPending} onResend={handleResend} onRewind={handleRewind} onClientToolOutput={handleClientToolOutput} @@ -704,12 +816,16 @@ const AgentConversation = ({ {/* Wraps transcript AND dock: a parked "Connect to X below" row links to X's card. */} + {/* The whole conversation ACCEPTS a drop; only the composer shows it (below). + Aiming at a 100px dock to attach a file is a needless demand. */}
{/* Themed confirm dialogs (rewind-past-a-tool) mount through this holder. */} {quickLookHost} + {/* Previews a SENT attachment; the tray's own drawer is below. */} + {uploadsEnabled ? ( - {/* At the limit the overlay says so rather than inviting a drop it is - about to reject wholesale. */} - {isDragging && ( -
- - - {atMax ? "Attachment limit reached" : "Drop files here"} - - - {atMax - ? `Remove one to add another (${limits.maxCount} max)` - : `${describeAccepted(limits)} · up to ${limits.maxCount} files`} - -
- )} {/* Stream errors are surfaced inline on the failing turn (red error bubble with the real reason), stamped in the effect above — no separate top-level banner. */} - + {/* The highlight is the composer alone: lighting the whole + transcript to accept a file the composer will hold read as the + page itself being the target. */} +
+ + + ) : null + } + onClientToolOutput={handleClientToolOutput} + onSubmit={handleSubmit} + onStop={handleStop} + stopping={stopping} + richInputRef={richInputRef} + composer={{...composer, handleComposerChange}} + attachments={attachments} + onboardingChat={onboardingChat} + voice={voice} + audioPerceivable={audioPerceivable} + composerDisabled={composerDisabled} + attachmentsBlocked={attachmentsBlocked} + /> +
{/* Chat-mode context rail (spec E1): docked right of the transcript, Files pinned on top. Always mounted so hide/show SLIDES (width transition) — diff --git a/web/oss/src/components/AgentChatSlice/assets/attachmentMedia.ts b/web/oss/src/components/AgentChatSlice/assets/attachmentMedia.ts index e3ed13d6106..c1151e9d59f 100644 --- a/web/oss/src/components/AgentChatSlice/assets/attachmentMedia.ts +++ b/web/oss/src/components/AgentChatSlice/assets/attachmentMedia.ts @@ -2,7 +2,7 @@ import {useEffect, useMemo, useState} from "react" import {attachmentContentUrl} from "@agenta/chat/assets" import {useAtomValue} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import {atomWithQuery} from "jotai-tanstack-query" import axios from "@/oss/lib/api/assets/axiosConfig" diff --git a/web/oss/src/components/AgentChatSlice/assets/refusedMessageRecovery.test.ts b/web/oss/src/components/AgentChatSlice/assets/refusedMessageRecovery.test.ts new file mode 100644 index 00000000000..9c4b51f9e63 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/assets/refusedMessageRecovery.test.ts @@ -0,0 +1,86 @@ +import {describe, expect, it, vi} from "vitest" + +import { + canRestoreRefusedSend, + restoreRefusedDraft, + restoreHeldRefusedSend, + restoreRefusedSend, +} from "./refusedMessageRecovery" + +describe("restoreRefusedDraft", () => { + it("restores a refused message only into an empty composer", () => { + const setMarkdown = vi.fn() + const editor = {getMarkdown: () => "", setMarkdown} as never + + expect(restoreRefusedDraft(editor, "try again")).toBe(true) + expect(setMarkdown).toHaveBeenCalledWith("try again") + }) + + it("does not overwrite a newer draft", () => { + const setMarkdown = vi.fn() + const editor = {getMarkdown: () => "new draft", setMarkdown} as never + + expect(restoreRefusedDraft(editor, "old refused message")).toBe(false) + expect(setMarkdown).not.toHaveBeenCalled() + }) + + it("allows attachment recovery only while the composer is still empty", () => { + expect(canRestoreRefusedSend({getMarkdown: () => "", setMarkdown: vi.fn()} as never)).toBe( + true, + ) + expect( + canRestoreRefusedSend({getMarkdown: () => "new draft", setMarkdown: vi.fn()} as never), + ).toBe(false) + }) + + it("leaves a refused send with staged attachments untouched behind a newer draft", () => { + const setMarkdown = vi.fn() + const restoreAttachments = vi.fn() + const stagedFiles = [{uid: "file-1", name: "brief.pdf"}] + const editor = {getMarkdown: () => "newer draft", setMarkdown} as never + + expect( + restoreRefusedSend(editor, {text: "refused message", stagedFiles}, restoreAttachments), + ).toBe(false) + expect(setMarkdown).not.toHaveBeenCalled() + expect(restoreAttachments).not.toHaveBeenCalled() + }) + + it("captures a refusal before deferred placement and restores it once", () => { + let markdown = "newer draft" + const setMarkdown = vi.fn((next: string) => { + markdown = next + }) + const restoreAttachments = vi.fn() + const stagedFiles = [{uid: "file-1", name: "brief.pdf"}] + const refused = {text: "refused message", stagedFiles} + const newer = {text: "newer draft", stagedFiles: []} + let lastSent: typeof refused | undefined = refused + const takeLastSent = () => { + const sent = lastSent + lastSent = undefined + return sent + } + const slot: {current: typeof refused | undefined} = {current: undefined} + const editor = {getMarkdown: () => markdown, setMarkdown} as never + const frames: (() => boolean)[] = [] + + expect(slot.current).toBeUndefined() + if (!slot.current) slot.current = takeLastSent() + frames.push(() => restoreHeldRefusedSend(slot, editor, restoreAttachments)) + + lastSent = newer + markdown = "" + expect(frames.shift()?.()).toBe(true) + + expect(setMarkdown).toHaveBeenCalledTimes(1) + expect(setMarkdown).toHaveBeenCalledWith("refused message") + expect(restoreAttachments).toHaveBeenCalledTimes(1) + expect(restoreAttachments).toHaveBeenCalledWith(stagedFiles) + expect(lastSent).toBe(newer) + + expect(restoreHeldRefusedSend(slot, editor, restoreAttachments)).toBe(false) + expect(setMarkdown).toHaveBeenCalledTimes(1) + expect(restoreAttachments).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/oss/src/components/AgentChatSlice/assets/refusedMessageRecovery.ts b/web/oss/src/components/AgentChatSlice/assets/refusedMessageRecovery.ts new file mode 100644 index 00000000000..3219d1fca6b --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/assets/refusedMessageRecovery.ts @@ -0,0 +1,43 @@ +import type {RichChatInputHandle} from "@agenta/ui/rich-chat-input" + +export const canRestoreRefusedSend = (editor: RichChatInputHandle | null): boolean => + Boolean(editor && editor.getMarkdown() === "") + +export const restoreRefusedDraft = (editor: RichChatInputHandle | null, text: string): boolean => { + if (!editor || !text || !canRestoreRefusedSend(editor)) return false + editor.setMarkdown(text) + return true +} + +interface RefusedSend { + text: string + stagedFiles?: TAttachment[] +} + +interface RefusedSendSlot { + current: RefusedSend | undefined +} + +export const restoreRefusedSend = ( + editor: RichChatInputHandle | null, + sent: RefusedSend, + restoreAttachments: (files: TAttachment[]) => void, +): boolean => { + if (!canRestoreRefusedSend(editor)) return false + if (sent.text && !restoreRefusedDraft(editor, sent.text)) return false + if (sent.stagedFiles?.length) restoreAttachments(sent.stagedFiles) + return true +} + +export const restoreHeldRefusedSend = ( + slot: RefusedSendSlot, + editor: RichChatInputHandle | null, + restoreAttachments: (files: TAttachment[]) => void, +): boolean => { + const sent = slot.current + if (!sent) return false + slot.current = undefined + if (restoreRefusedSend(editor, sent, restoreAttachments)) return true + slot.current = sent + return false +} diff --git a/web/oss/src/components/AgentChatSlice/assets/stopState.test.ts b/web/oss/src/components/AgentChatSlice/assets/stopState.test.ts new file mode 100644 index 00000000000..31d6e18ea48 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/assets/stopState.test.ts @@ -0,0 +1,62 @@ +import {describe, expect, it} from "vitest" + +import {isStoppingPhase, reduceStopPhase, type StopPhase} from "./stopState" + +const transition = (events: Parameters[1][]): StopPhase => + events.reduce(reduceStopPhase, "idle" as StopPhase) + +describe("stop state", () => { + it("enters stopping while the request is pending", () => { + const phase = transition([{type: "request"}]) + + expect(phase).toBe("requesting") + expect(isStoppingPhase(phase)).toBe(true) + }) + + it("stays stopping after acceptance until the stream terminates", () => { + const phase = transition([{type: "request"}, {type: "accepted"}]) + + expect(phase).toBe("accepted") + expect(isStoppingPhase(phase)).toBe(true) + expect(reduceStopPhase(phase, {type: "terminal"})).toBe("stopped") + }) + + it("settles immediately when the server cancels a parked turn", () => { + const phase = transition([{type: "request"}, {type: "cancelled", parked: true}]) + + expect(phase).toBe("stopped") + expect(isStoppingPhase(phase)).toBe(false) + }) + + it("keeps waiting for a streaming turn after the server accepts cancellation", () => { + const phase = transition([{type: "request"}, {type: "cancelled", parked: false}]) + + expect(phase).toBe("accepted") + expect(isStoppingPhase(phase)).toBe(true) + }) + + it("remembers a terminal event that beats the response", () => { + expect(transition([{type: "request"}, {type: "terminal"}, {type: "accepted"}])).toBe( + "stopped", + ) + }) + + it("keeps an ordinary terminal event idle", () => { + const phase = transition([{type: "terminal"}]) + + expect(phase).toBe("idle") + expect(isStoppingPhase(phase)).toBe(false) + }) + + it("makes an accepted stop retryable after the watchdog timeout", () => { + const phase = transition([{type: "request"}, {type: "accepted"}, {type: "timeout"}]) + + expect(phase).toBe("retryable") + expect(isStoppingPhase(phase)).toBe(false) + expect(reduceStopPhase(phase, {type: "terminal"})).toBe("stopped") + }) + + it.each(["failed", "already_idle"] as const)("returns to idle on %s", (type) => { + expect(transition([{type: "request"}, {type}])).toBe("idle") + }) +}) diff --git a/web/oss/src/components/AgentChatSlice/assets/stopState.ts b/web/oss/src/components/AgentChatSlice/assets/stopState.ts new file mode 100644 index 00000000000..6c824afd47b --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/assets/stopState.ts @@ -0,0 +1,34 @@ +export type StopPhase = "idle" | "requesting" | "accepted" | "retryable" | "terminal" | "stopped" + +export type StopEvent = + | {type: "request"} + | {type: "accepted"} + | {type: "cancelled"; parked: boolean} + | {type: "terminal"} + | {type: "timeout"} + | {type: "failed" | "already_idle" | "reset"} + +export const reduceStopPhase = (phase: StopPhase, event: StopEvent): StopPhase => { + switch (event.type) { + case "request": + return phase === "terminal" ? "terminal" : "requesting" + case "accepted": + return phase === "terminal" ? "stopped" : "accepted" + case "cancelled": + if (event.parked || phase === "terminal") return "stopped" + return "accepted" + case "timeout": + return phase === "accepted" ? "retryable" : phase + case "terminal": + if (phase === "requesting") return "terminal" + if (phase === "accepted" || phase === "retryable") return "stopped" + return phase + case "failed": + case "already_idle": + case "reset": + return "idle" + } +} + +export const isStoppingPhase = (phase: StopPhase): boolean => + phase === "requesting" || phase === "accepted" || phase === "terminal" diff --git a/web/oss/src/components/AgentChatSlice/assets/stopWhileResolvingExecution.test.ts b/web/oss/src/components/AgentChatSlice/assets/stopWhileResolvingExecution.test.ts new file mode 100644 index 00000000000..95c80de9592 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/assets/stopWhileResolvingExecution.test.ts @@ -0,0 +1,146 @@ +import {act, createElement, useCallback} from "react" + +import {latestTurnId} from "@agenta/chat/assets" +import { + clearSessionEphemera, + clearSessionTurnId, + getSessionTurnId, + setSessionTurnId, +} from "@agenta/chat/state" +import type {UIMessage} from "ai" +import {createRoot} from "react-dom/client" +import {afterAll, afterEach, beforeAll, describe, expect, it, vi} from "vitest" + +import {stopPinnedExecution} from "./stopWhileResolvingExecution" + +const sessionId = "session-1" + +const deferred = () => { + let resolve!: () => void + const promise = new Promise((done) => { + resolve = done + }) + return {promise, resolve} +} + +beforeAll(() => vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true)) +afterAll(() => vi.unstubAllGlobals()) +afterEach(() => clearSessionEphemera(sessionId)) + +describe("stopPinnedExecution", () => { + it("starts the local abort while cancellation is still pending", async () => { + const held = deferred() + const events: string[] = [] + const stop = vi.fn(() => events.push("stop")) + const cancelExecution = vi.fn(async (executionId: string | undefined) => { + events.push(`cancel:${executionId}`) + await held.promise + }) + + const stopping = stopPinnedExecution({ + stop, + expectedExecutionId: "turn-A", + cancelExecution, + }) + + expect(events).toEqual(["stop", "cancel:turn-A"]) + + held.resolve() + await stopping + expect(cancelExecution).toHaveBeenCalledWith("turn-A") + }) + + it("stops turn B before metadata without restoring turn A's id", async () => { + const stop = vi.fn() + const cancelExecution = vi.fn(async (_executionId: string | undefined) => {}) + setSessionTurnId(sessionId, "turn-A") + + clearSessionTurnId(sessionId) + const messages = [ + {id: "a1", role: "assistant", parts: [], metadata: {turnId: "turn-A"}}, + {id: "u2", role: "user", parts: []}, + ] as UIMessage[] + const turnId = latestTurnId(messages) + if (turnId) setSessionTurnId(sessionId, turnId) + + await stopPinnedExecution({ + stop, + expectedExecutionId: getSessionTurnId(sessionId), + cancelExecution, + }) + + expect(stop).toHaveBeenCalledOnce() + expect(cancelExecution).toHaveBeenCalledWith(undefined) + expect(cancelExecution).not.toHaveBeenCalledWith("turn-A") + }) + + it("keeps turn A pinned when turn B is admitted while cancellation is held", async () => { + const held = deferred() + const cancelled: (string | undefined)[] = [] + setSessionTurnId(sessionId, "turn-A") + + const stopping = stopPinnedExecution({ + stop: vi.fn(), + expectedExecutionId: getSessionTurnId(sessionId), + cancelExecution: async (executionId) => { + await held.promise + cancelled.push(executionId) + }, + }) + + clearSessionTurnId(sessionId) + setSessionTurnId(sessionId, "turn-B") + expect(getSessionTurnId(sessionId)).toBe("turn-B") + + held.resolve() + await stopping + expect(cancelled).toEqual(["turn-A"]) + }) + + it("keeps turn A pinned after the hook remounts and admits turn B", async () => { + const held = deferred() + const cancelled: (string | undefined)[] = [] + const cancelExecution = async (executionId: string | undefined) => { + await held.promise + cancelled.push(executionId) + } + let stopFromMount!: () => Promise + const Harness = () => { + stopFromMount = useCallback( + () => + stopPinnedExecution({ + stop: vi.fn(), + expectedExecutionId: getSessionTurnId(sessionId), + cancelExecution, + }), + [], + ) + return null + } + + const mount = () => { + const host = document.createElement("div") + const root = createRoot(host) + act(() => root.render(createElement(Harness))) + return root + } + + setSessionTurnId(sessionId, "turn-A") + const firstMount = mount() + let stopping!: Promise + act(() => { + stopping = stopFromMount() + }) + act(() => firstMount.unmount()) + + clearSessionTurnId(sessionId) + setSessionTurnId(sessionId, "turn-B") + const secondMount = mount() + expect(getSessionTurnId(sessionId)).toBe("turn-B") + + held.resolve() + await stopping + expect(cancelled).toEqual(["turn-A"]) + act(() => secondMount.unmount()) + }) +}) diff --git a/web/oss/src/components/AgentChatSlice/assets/stopWhileResolvingExecution.ts b/web/oss/src/components/AgentChatSlice/assets/stopWhileResolvingExecution.ts new file mode 100644 index 00000000000..18d5b29e915 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/assets/stopWhileResolvingExecution.ts @@ -0,0 +1,14 @@ +export interface StopPinnedExecutionParams { + stop: () => void + expectedExecutionId: string | undefined + cancelExecution: (executionId: string | undefined) => Promise +} + +export async function stopPinnedExecution({ + stop, + expectedExecutionId, + cancelExecution, +}: StopPinnedExecutionParams): Promise { + stop() + await cancelExecution(expectedExecutionId) +} diff --git a/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx b/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx index 3417e01f1ad..04313e43818 100644 --- a/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx +++ b/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx @@ -1,12 +1,12 @@ import {useCallback, useEffect, useRef, type RefObject} from "react" -import {CHAT_COLUMN} from "@agenta/chat/assets" +import {CHAT_COLUMN, shouldShowStopControl} from "@agenta/chat/assets" import type {ClientToolOutputHandler} from "@agenta/chat/clientTools" import { ChatComposer, + ConnectionWarningStrip, MicPermissionNotice, RecordingBar, - RevealCollapse, RunningElsewhereStrip, VoiceInputButton, } from "@agenta/chat/components" @@ -46,7 +46,7 @@ import ConnectionDock from "./ConnectionDock" import ConnectModelBanner from "./ConnectModelBanner" import ContextBudgetIndicator from "./ContextBudgetIndicator" import ElicitationDock from "./ElicitationDock" -import QueuedMessages from "./QueuedMessages" +import QueuedMessagesDock from "./QueuedMessagesDock" import PermissionsPickerPanel from "./SlashCommand/PermissionsPickerPanel" /** @@ -59,7 +59,8 @@ const AgentComposerDock = ({ entityId, messages, busy, - runningElsewhere, + showRunningElsewhere, + connectionWarning, hitlPending, queue, modelKey, @@ -71,9 +72,11 @@ const AgentComposerDock = ({ onApprovalResponse, connects, elicits, + secretDock, onClientToolOutput, onSubmit, onStop, + stopping, richInputRef, composer, attachments, @@ -86,13 +89,17 @@ const AgentComposerDock = ({ entityId: string messages: UIMessage[] busy: boolean - /** The backend reports a live run for this session that this browser is not driving. */ - runningElsewhere: boolean + /** Show the disconnected/flag-off fallback for a run this browser is not driving. */ + showRunningElsewhere: boolean + /** The sender request disconnected after the session accepted the turn. */ + connectionWarning?: string hitlPending: boolean queue: { queued: QueuedMessage[] removeQueued: (id: string) => void - clearQueue: () => void + editingId: string | null + beginEdit: (id: string, draft?: string) => void + cancelEdit: () => string } modelKey: React.ComponentProps modelBlocked: boolean @@ -104,10 +111,12 @@ const AgentComposerDock = ({ onApprovalResponse: (args: {id: string; approved: boolean; message?: string}) => void connects: ConnectionDockState /** Parked question forms the run is blocked on (from `useElicitationDock`). */ + secretDock?: React.ReactNode elicits: ElicitationDockState onClientToolOutput: ClientToolOutputHandler onSubmit: (text: string) => void | Promise onStop: () => void + stopping: boolean richInputRef: RefObject composer: ReturnType attachments: ReturnType @@ -137,6 +146,8 @@ const AgentComposerDock = ({ voiceRecorder, voiceWillSend, startVoiceMessage, + dictationStopRef, + endDictation, dictating, setDictating, setDictationError, @@ -182,6 +193,17 @@ const AgentComposerDock = ({ [onSubmit, richInputRef], ) + // Onboarding: submit = commit the ephemeral — Enter creates the agent (matching the + // composer's "↵ Send" hint). Either way the message is written, so anything the mic is still + // hearing belongs to no draft. + const handleComposerSubmit = useCallback( + (text: string) => { + endDictation() + return onboardingActive ? handleCreateAgent() : submitMessage(text) + }, + [endDictation, handleCreateAgent, onboardingActive, submitMessage], + ) + // Restoring focus can only happen AFTER the picker unmounts: a focus() call in the handler is // undone when the still-focused panel (or Radix popover) leaves the DOM. const hadPickerRef = useRef(slash.picker) @@ -227,22 +249,30 @@ const AgentComposerDock = ({ // Permission rules live in the Advanced accordion's Permissions group. const openPermissionsConfig = useCallback(() => openConfigFor("advanced"), [openConfigFor]) + // Any blocking dock on screen. The queue card yields to all of them rather than stacking, + // mid-edit included — the composer keeps the edit, so Enter still rewrites the held row. + const gateDockOpen = + pendingApprovals.length > 0 || elicits.open || connects.open || Boolean(secretDock) + + // Editing borrows the composer: the row's text goes in, the draft it displaces is stashed. + const {beginEdit, cancelEdit} = queue + const editQueued = useCallback( + (message: QueuedMessage) => { + const input = richInputRef.current + beginEdit(message.id, input?.getMarkdown() ?? "") + input?.setMarkdown(message.text) + input?.focus() + }, + [beginEdit, richInputRef], + ) + const cancelQueuedEdit = useCallback(() => { + const input = richInputRef.current + input?.setMarkdown(cancelEdit()) + input?.focus() + }, [cancelEdit, richInputRef]) + return ( <> - {/* Queue sits BETWEEN the messages and the composer, so showing it never shifts the - composer (and the editor) upward. Streaming itself is signalled by the composer's - send button (it becomes a spinning Stop button), so there's no "Streaming…" row. */} - 0} className={CHAT_COLUMN}> -
- -
-
- {/* Rich markdown composer (Lexical). Enter sends; attachments via header/prefix slots. Wrapper `px-3` keeps the session-bar gutter; the input centers on CHAT_COLUMN so it aligns with the (also centered) message column when the panel is wide. The persistent @@ -252,7 +282,9 @@ const AgentComposerDock = ({ {/* The whole composer fades + rises in ONCE on mount (Reveal), so the input joins the empty-state/hero entrance instead of popping. Mount-only: it never remounts across the onboarding→chat transitions, so this never reintroduces layout shift on state changes. */} - + {/* `relative z-10`: Reveal's transform traps the `/` panels' own z-index, so without a + stacking order here the transcript's `z-[5]` bottom fade washes the docked chrome. */} + {/* Agent empty-chat strip (S6): docked above the composer. Visibility is decided by AgentConversation, which hands the same flag to the empty state so exactly one of the strip and the starter pills renders. */} @@ -266,6 +298,18 @@ const AgentComposerDock = ({ />
) : null} + {/* Above the gate docks, and hidden entirely while one is up: those are blocked + runs wanting an answer, and a second card stacked above one buries the composer. + Inside the `Reveal` so it shares the composer's `px-3` gutter and column. */} + {/* Always mounted so it animates in/out (RevealCollapse) instead of popping. Pre-commit onboarding SUPPRESSES it — the provider-key check is deferred until the agent is committed (Create-agent then runs the connect→unlock→auto-send flow on the real agent). */} @@ -274,9 +318,12 @@ const AgentComposerDock = ({
{/* Sits with the other docked strips so a session running in another browser reads as busy instead of frozen (#5530). */} - {runningElsewhere && !chromeHidden ? ( + {showRunningElsewhere && !chromeHidden ? ( ) : null} + {connectionWarning && !chromeHidden ? ( + + ) : null} {secretDock}
: null} } - // Onboarding: submit = commit the ephemeral — Enter creates the agent - // (matching the composer's "↵ Send" hint). - onSubmit={onboardingActive ? () => handleCreateAgent() : submitMessage} + onSubmit={handleComposerSubmit} disabled={onboardingActive ? ideHandoffActive : modelBlocked} hideSendButton={onboardingActive} placeholder={ @@ -413,7 +459,8 @@ const AgentComposerDock = ({ initialMarkdown={composer.initialDraft} slashCommands={slash.sections} onChange={composer.handleComposerChange} - streaming={busy} + streaming={shouldShowStopControl({busy, hitlPending})} + stopping={stopping} onStop={onStop} attachments={attachments} attachmentsBlocked={attachmentsBlocked} @@ -434,6 +481,7 @@ const AgentComposerDock = ({ attachmentsFull={atMax} onDictationError={setDictationError} onDictatingChange={setDictating} + stopRef={dictationStopRef} disabled={onboardingActive ? ideHandoffActive : modelBlocked} /> {/* Context-budget meter temporarily hidden from the UI. diff --git a/web/oss/src/components/AgentChatSlice/components/AgentMessage.runError.test.tsx b/web/oss/src/components/AgentChatSlice/components/AgentMessage.runError.test.tsx index 8bc76946ff3..aab41dfd803 100644 --- a/web/oss/src/components/AgentChatSlice/components/AgentMessage.runError.test.tsx +++ b/web/oss/src/components/AgentChatSlice/components/AgentMessage.runError.test.tsx @@ -45,6 +45,21 @@ describe("RunErrorBody", () => { expect(rendered).not.toContain("Add your key") }) + it("offers Try again for an offline send that failed before acceptance", () => { + const rendered = text( + undefined} + />, + ) + + expect(rendered).toContain("The agent run failed") + expect(rendered).toContain("Could not reach Agenta") + expect(rendered).toContain("Try again") + }) + it("hides Try again when no retry handler is wired (not the last turn, or busy)", () => { const rendered = text( text.length > 240 || text.split("\n").length > 4 @@ -169,13 +178,16 @@ export const RunErrorBody = ({ text, stateKey, code, + transport, onRetry, }: { text: string stateKey: string /** The runner's failure class, when the turn carried one (`data-agent-error`'s `code`). */ code?: string - /** Re-run the failed turn; offered only for the transient classes in RETRYABLE_CODES. */ + /** The request never reached Agenta — retryable, and it has no code to match on. */ + transport?: boolean + /** Re-run the failed turn; offered for transport failures and the classes in RETRYABLE_CODES. */ onRetry?: () => void }) => { const stored = useAtomValue(expandedValueAtomFamily(stateKey)) @@ -184,13 +196,17 @@ export const RunErrorBody = ({ const expanded = stored ?? false const big = isBigError(text) const offerOwnKey = code ? STARTER_CREDIT_CODES.has(code) : false - const offerRetry = !!onRetry && !!code && RETRYABLE_CODES.has(code) + const notSent = !!code && NOT_SENT_CODES.has(code) + const offerRetry = + !notSent && !!onRetry && (!!transport || (!!code && RETRYABLE_CODES.has(code))) return (
- The agent run failed + + {notSent ? "Message not sent" : "The agent run failed"} + {big && expanded ? (
                         {text}
@@ -272,8 +288,8 @@ const triggerDownload = (href: string, name: string) => {
 }
 
 const AttachmentFilePart = ({file, sessionId}: {file: FileUIPart; sessionId: string}) => {
+    const setViewing = useSetAtom(viewingMessageAttachmentAtom)
     const attachmentId = attachmentIdForPart(file)
-    const kind = fileKind(file.mediaType)
     const source = useAttachmentMediaSrc(attachmentId ? sessionId : null, attachmentId)
     const src = attachmentId ? source.src : file.url
     const name = filePartName(file)
@@ -289,9 +305,13 @@ const AttachmentFilePart = ({file, sessionId}: {file: FileUIPart; sessionId: str
         }
     }, [fallbackDownloadPending, name, source.failed, source.src])
 
-    const handleDownload = async (event: React.MouseEvent) => {
-        if (!attachmentId || !src || src.startsWith("blob:")) return
-        event.preventDefault()
+    const handleDownload = async () => {
+        if (!src) return
+        // Already a local blob (the axios fallback resolved it) — save it straight off.
+        if (src.startsWith("blob:") || !attachmentId) {
+            triggerDownload(src, name)
+            return
+        }
         try {
             const response = await fetch(src, {credentials: "include"})
             if (!response.ok) throw new Error("Direct attachment download failed")
@@ -305,43 +325,23 @@ const AttachmentFilePart = ({file, sessionId}: {file: FileUIPart; sessionId: str
         }
     }
 
-    if (kind === "audio") {
-        return (
-            
-        )
-    }
-
     return (
-        
-                            {file.mediaType}
-                        
-                    ) : (
-                        
-                            {source.failed ? "Download unavailable" : file.mediaType}
-                        
-                    )
-                ) : undefined
+            action={src && !source.failed ? "download" : "none"}
+            onDownload={() => void handleDownload()}
+            onView={
+                src && isViewable(file.mediaType ?? "")
+                    ? () =>
+                          setViewing({
+                              name,
+                              mediaType: file.mediaType ?? "",
+                              url: src,
+                          })
+                    : undefined
             }
         />
     )
@@ -381,6 +381,7 @@ const AgentMessage = ({
     // we know whether the turn produced an answer.
     const runError = getMessageRunError(message)
     const runErrorCode = getMessageRunErrorCode(message)
+    const runErrorTransport = isMessageRunErrorTransport(message)
     const fullText = message.parts
         .filter((p) => p.type === "text")
         .map((p) => (p as {text: string}).text)
@@ -476,6 +477,7 @@ const AgentMessage = ({
         | {kind: "part"; part: UIMessage["parts"][number]; index: number}
         | {kind: "tools"; parts: ToolUIPart[]; index: number}
         | {kind: "clientTool"; part: ToolUIPart; index: number}
+        | {kind: "files"; parts: FileUIPart[]; index: number}
     // A HITL-approved tool's part LINGERS in `approval-responded` (a perpetual spinner, no output):
     // the cold-replay runner re-issues the approved call under a FRESH id, so its execution output
     // lands on a SEPARATE sibling part. Drop the answered gate once its executed sibling exists (same
@@ -507,6 +509,14 @@ const AgentMessage = ({
             else renderItems.push({kind: "tools", parts: [part as ToolUIPart], index: i})
             return
         }
+        // Consecutive attachments share one grid, so a message's files lay out as a block
+        // instead of one full-width card per part.
+        if (part.type === "file") {
+            const last = renderItems[renderItems.length - 1]
+            if (last && last.kind === "files") last.parts.push(part as FileUIPart)
+            else renderItems.push({kind: "files", parts: [part as FileUIPart], index: i})
+            return
+        }
         renderItems.push({kind: "part", part, index: i})
     })
     const renderLeafPart = (part: UIMessage["parts"][number], i: number) => {
@@ -546,20 +556,13 @@ const AgentMessage = ({
                 />
             )
         }
-        // Multi-modality: render attachments (sent by the user or returned by the
-        // agent) as X `FileCard`s — images preview inline, other kinds show a typed
-        // file chip with a download link.
-        if (part.type === "file") {
-            return (
-                
-            )
-        }
         return null
     }
 
     const defaultBody = (
         
{renderItems.map((item) => { + if (item.kind === "files") return null if (item.kind === "tools") { return ( onRetry(message.id) : undefined} /> ) @@ -646,6 +650,39 @@ const AgentMessage = ({ contentBody ) + // Attachments hang above the bubble rather than inside its fill, so a message reads as its + // files first and its words second. + const fileItems = renderItems.filter((item) => item.kind === "files") + const attachments = fileItems.length ? ( +
+ {fileItems.map((item) => ( + + {item.parts.map((file, n) => ( + + ))} + + ))} +
+ ) : null + // Attachments with no words: there is no bubble to paint, only the cards. An empty text part + // counts as no words — a turn carrying only files still arrives with one. + const hasBubbleContent = + renderItems.some( + (item) => + item.kind !== "files" && + !( + item.kind === "part" && + item.part.type === "text" && + !((item.part as {text?: string}).text ?? "").trim() + ), + ) || + showError || + isError + // The turn's meta line, in a reserved lane BELOW the bubble (the `pb-8` on the row), so it // never overlays the last content line and never reaches the next turn. The lane is always // present (stable height), so revealing it only fades opacity — no layout shift either way (the @@ -666,7 +703,7 @@ const AgentMessage = ({ placement={isUser ? "end" : "start"} // Borderless assistant turns: content sits on the panel bg with just the avatar and // spacing, so tool cards aren't wrapped in an extra outline. User stays filled. - variant={isUser ? "filled" : "borderless"} + variant={isUser && hasBubbleContent ? "filled" : "borderless"} avatar={} className="min-w-0 max-w-[85%]" classNames={{ @@ -679,7 +716,8 @@ const AgentMessage = ({ : "min-w-0 max-w-full overflow-hidden", body: "min-w-0 max-w-full overflow-hidden", }} - content={body} + content={hasBubbleContent ? body : null} + header={attachments} />
(null) + +/** + * The selected attachment as a `File`. Keyed on the URL, so the blob can only ever belong to the + * current selection — switching attachments cannot leave the previous one on screen. + */ +const attachmentFileAtom = atomWithQuery((get) => { + const viewing = get(viewingMessageAttachmentAtom) + return { + queryKey: ["message-attachment", viewing?.url ?? null], + queryFn: async (): Promise => { + if (!viewing) return null + const response = await fetch(viewing.url, {credentials: "include"}) + if (!response.ok) throw new Error("Attachment fetch failed") + const blob = await response.blob() + return new File([blob], viewing.name, {type: viewing.mediaType || blob.type}) + }, + enabled: !!viewing, + staleTime: 5 * 60_000, + } +}) + +/** + * Previews a SENT attachment in the same Files drawer the composer tray uses. + * + * The drawer reads local blobs and a replayed attachment is a URL, so the bytes are fetched once + * and handed over as a synthesized staged row. Mounted once by the conversation; cards set the + * atom rather than each owning a drawer. + */ +const MessageAttachmentViewer = () => { + const [viewing, setViewing] = useAtom(viewingMessageAttachmentAtom) + const {data: file, isError} = useAtomValue(attachmentFileAtom) + + // Nothing to preview — close rather than leave an empty drawer open. + useEffect(() => { + if (isError) setViewing(null) + }, [isError, setViewing]) + + const uploads = useMemo( + () => + viewing && file ? [{uid: viewing.url, name: viewing.name, originFileObj: file}] : [], + [viewing, file], + ) + + return ( + setViewing(null)} + /> + ) +} + +export default MessageAttachmentViewer diff --git a/web/oss/src/components/AgentChatSlice/components/QueuedMessages.tsx b/web/oss/src/components/AgentChatSlice/components/QueuedMessages.tsx deleted file mode 100644 index 8241712cd9f..00000000000 --- a/web/oss/src/components/AgentChatSlice/components/QueuedMessages.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import {memo, useState} from "react" - -import {fileKind, filePartName} from "@agenta/chat/assets" -import type {QueuedMessage} from "@agenta/chat/hooks" -import {Popover, PopoverContent, PopoverTrigger} from "@agenta/ui/ui" -import { - CaretDown, - CaretUp, - File as FileIcon, - SpeakerHigh, - Stack, - VideoCamera, - X, -} from "@phosphor-icons/react" -import type {FileUIPart} from "ai" - -/** One attachment tile: image thumbnail, else a type icon. */ -const Attachment = ({part}: {part: FileUIPart}) => { - const name = filePartName(part) - const kind = fileKind(part.mediaType) - if (kind === "image") { - return ( - // eslint-disable-next-line @next/next/no-img-element -- data: URL thumbnail, no optimization - {name} - ) - } - const Icon = kind === "audio" ? SpeakerHigh : kind === "video" ? VideoCamera : FileIcon - return ( - - - - ) -} - -const QueuedList = ({ - queued, - held, - onRemove, - onClear, -}: { - queued: QueuedMessage[] - held: boolean - onRemove: (id: string) => void - onClear: () => void -}) => ( -
-
- - {held ? "Held until you answer the agent" : "Queued — sent one by one"} - - -
-
- {queued.map((message, index) => { - const text = message.text.trim() - const files = message.fileParts ?? [] - return ( -
- - {index + 1} - -
- {text ? ( - // Clamp to 2 lines; full text on hover. - - {text} - - ) : files.length === 0 ? ( - - (empty message) - - ) : null} - {files.length > 0 && ( -
- {files.map((part, i) => ( - - ))} -
- )} -
- -
- ) - })} -
-
-) - -/** - * Collapsed queue control: a count pill in the composer footer that opens a popover to read, - * reorder-by-removal, and clear messages typed while a turn was streaming. Fixed footprint — - * the composer height never grows with the queue. - */ -const QueuedMessages = ({ - queued, - held = false, - onRemove, - onClear, -}: { - queued: QueuedMessage[] - /** The run is paused on the user (HITL / parked interaction) — say WHY the queue is held. */ - held?: boolean - onRemove: (id: string) => void - onClear: () => void -}) => { - const [open, setOpen] = useState(false) - if (queued.length === 0) return null - return ( - - - - - - - - - ) -} - -export default memo(QueuedMessages) diff --git a/web/oss/src/components/AgentChatSlice/components/QueuedMessagesDock.tsx b/web/oss/src/components/AgentChatSlice/components/QueuedMessagesDock.tsx new file mode 100644 index 00000000000..8e1c0cc9a0e --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/components/QueuedMessagesDock.tsx @@ -0,0 +1,57 @@ +/** + * Desktop shell around the shared `QueuedMessagesDock` — the "what you have lined up" band for + * messages typed while a turn was in flight. Sibling of `ApprovalDock`/`ConnectionDock` in form, + * and it keeps its place ABOVE them: those are gates the run is blocked on, so they stay nearest + * the composer, while this is a list the run drains by itself. + * + * The card is shared with /m; this file keeps only the desktop chrome — the open/close collapse, + * the column width, and the latch that holds the last non-empty queue so the list is still there + * to look at while the dock animates shut. + */ +import {memo, useRef} from "react" + +import {QueuedMessagesDock, RevealCollapse} from "@agenta/chat/components" +import type {QueuedMessage} from "@agenta/chat/hooks" + +interface AgentQueuedMessagesDockProps { + queued: QueuedMessage[] + /** The run is parked on the user, so the queue is held rather than merely waiting. */ + held: boolean + onRemove: (id: string) => void + onEdit: (message: QueuedMessage) => void + onCancelEdit: () => void + editingId: string | null + className?: string +} + +const AgentQueuedMessagesDock = ({ + queued, + held, + onRemove, + onEdit, + onCancelEdit, + editingId, + className, +}: AgentQueuedMessagesDockProps) => { + const open = queued.length > 0 + // Latch the last non-empty queue: emptying it starts the collapse, and without this the rows + // would vanish first and leave an empty box folding shut. + const shownRef = useRef(queued) + if (open) shownRef.current = queued + + return ( + + + + ) +} + +export default memo(AgentQueuedMessagesDock) diff --git a/web/oss/src/components/AgentChatSlice/components/SessionHistoryMenu.tsx b/web/oss/src/components/AgentChatSlice/components/SessionHistoryMenu.tsx index 0a908d6fe48..2387ecb48a6 100644 --- a/web/oss/src/components/AgentChatSlice/components/SessionHistoryMenu.tsx +++ b/web/oss/src/components/AgentChatSlice/components/SessionHistoryMenu.tsx @@ -255,7 +255,10 @@ const SessionHistoryList = ({onPicked}: {onPicked: () => void}) => { session={session} label={labelOf(session)} archived - onOpen={() => undefined} + onOpen={() => { + openSession(session.id) + onPicked() + }} onDelete={() => deleteSession(session.id)} onArchive={() => archiveSession(session.id)} onUnarchive={() => unarchiveSession(session.id)} diff --git a/web/oss/src/components/AgentChatSlice/components/SessionRail.tsx b/web/oss/src/components/AgentChatSlice/components/SessionRail.tsx index 69352572517..c153d7800c5 100644 --- a/web/oss/src/components/AgentChatSlice/components/SessionRail.tsx +++ b/web/oss/src/components/AgentChatSlice/components/SessionRail.tsx @@ -52,10 +52,6 @@ const TRASH_ICON = const ARCHIVE_ICON = const RESTORE_ICON = -// Stable no-op for an archived row's `onSelect` (archived rows aren't openable) — keeps the -// id-taking setter identity stable so the memoized row doesn't re-render. -const NOOP = () => {} - interface SessionRailRowProps { session: AgentChatSession label: string @@ -468,9 +464,9 @@ const SessionRail = ({activeId, addDisabled = false, className}: SessionRailProp key={session.id} session={session} label={label} - active={false} + active={session.id === currentActiveId} archived - onSelect={NOOP} + onSelect={openSession} onDelete={deleteSession} onRename={handleRename} onArchive={archiveSession} diff --git a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.test.ts b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.test.ts new file mode 100644 index 00000000000..2e0785b534b --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.test.ts @@ -0,0 +1,643 @@ +import {act, createElement} from "react" + +import type {UIMessage} from "ai" +import {createRoot} from "react-dom/client" +import {beforeEach, describe, expect, it, vi} from "vitest" +;(globalThis as typeof globalThis & {IS_REACT_ACT_ENVIRONMENT: boolean}).IS_REACT_ACT_ENVIRONMENT = + true + +const state = vi.hoisted(() => ({ + acceptedRunBySession: new Map(), + turnDeliverySourceBySession: new Map(), + capturedHooks: undefined as + | { + prepareRequest: (args: {messages: UIMessage[]; id?: string}) => Promise + onData: (part: {type: string; data?: unknown}) => void + onError: () => void + sendAutomaticallyWhen: (args: {messages: UIMessage[]}) => boolean + } + | undefined, + messages: [] as UIMessage[], + projectId: "project-id" as string | null, + latestTurnId: undefined as string | undefined, + hitlPending: false, + sessionTurnId: null as string | null, + stoppingTurnId: null as string | null, + stopStateLoading: false, + cancelSessionExecution: vi.fn(), + resolveStopExecution: vi.fn(), + regenerate: vi.fn(() => Promise.resolve()), + sendMessage: vi.fn(() => Promise.resolve()), + turnIds: new Map(), + busy: false, + stop: vi.fn(), +})) + +vi.mock("@agenta/chat/assets", () => ({ + buildRequestWithinDeadline: (build: () => Promise) => build(), + getMessageTraceId: () => undefined, + latestTurnId: () => state.latestTurnId, + resolveStopExecution: state.resolveStopExecution, + startupLabelFromDataPart: () => undefined, +})) + +vi.mock("@agenta/chat/hooks", () => ({ + useSessionChat: (args: {hooks: NonNullable}) => { + state.capturedHooks = args.hooks + return {} + }, +})) + +vi.mock("@agenta/chat/model", () => ({ + createUserStoppedState: () => ({stopped: false, turnIdentity: null}), + ignoreStreamRejection: () => undefined, + isSessionTurnStopping: ({ + currentTurnId, + stoppingTurnId, + }: { + currentTurnId?: string | null + stoppingTurnId?: string | null + }) => Boolean(currentTurnId && stoppingTurnId === currentTurnId), + parseAgentRunError: () => ({message: "error"}), + reduceUserStoppedState: ( + current: {stopped: boolean; turnIdentity: null}, + event: {type: string}, + ) => { + if (event.type === "user-stop" && !current.stopped) return {...current, stopped: true} + if (event.type === "reset" && current.stopped) return {...current, stopped: false} + return current + }, + withoutSharedSenderAcceptanceMessages: (messages: UIMessage[]) => messages, +})) + +vi.mock("@agenta/chat/state", () => ({ + acceptedRunBySession: state.acceptedRunBySession, + clearSessionTurnId: (sessionId: string) => state.turnIds.delete(sessionId), + clearTurnClockAtom: "clear-turn-clock", + expandedKeysForMessages: () => [], + getSessionTurnId: (sessionId: string) => state.turnIds.get(sessionId), + isChatBusy: () => state.busy, + persistSessionMessagesAtom: "persist-messages", + pruneExpandedAtom: "prune-expanded", + sessionMessagesAtom: "session-messages", + sessionRecordCountsReadAtom: "record-counts", + setSessionStatusAtom: "set-session-status", + setSessionTurnId: (sessionId: string, turnId: string) => state.turnIds.set(sessionId, turnId), + setAcceptedSessionTurnId: (sessionId: string, turnId: string) => + state.turnIds.set(sessionId, turnId), + stampMessagesCreatedAtAtom: "stamp-created-at", + startTurnClockAtom: "start-turn-clock", + turnDeliverySourceBySession: state.turnDeliverySourceBySession, +})) + +vi.mock("@agenta/entities/session", () => ({ + cancelSessionExecution: state.cancelSessionExecution, + invalidateSessionListQueries: vi.fn(), + killSession: vi.fn(), + recordInteractionAnswerAtom: "record-interaction-answer", + revalidateSessionMountsAtom: "revalidate-mounts", + revalidateSessionRecordsAtom: "revalidate-records", +})) + +vi.mock("@agenta/entities/trace", () => ({markTraceAsFresh: vi.fn()})) +vi.mock("@agenta/entities/workflow", () => ({ + invalidateAgentCommittedRevisionCache: vi.fn(), + workflowMolecule: { + selectors: {configuration: () => "workflow-configuration"}, + }, +})) + +vi.mock("@agenta/playground", () => ({ + agentShouldResumeAfterApproval: ({liveInteraction}: {liveInteraction?: unknown}) => + liveInteraction !== null, + approvalResolution: vi.fn(), + buildAgentRequest: vi.fn(async () => ({ + invocationUrl: "https://agent.test/invoke", + headers: {}, + requestBody: {}, + })), + buildTurnCapture: vi.fn(), + isHitlPending: () => state.hitlPending, + isResumeSend: () => false, + playgroundController: {actions: {switchEntity: "switch-entity"}}, + recordAnswerThenRelease: vi.fn(), +})) + +vi.mock("@agenta/shared/state", () => ({ + agentSelfCommitSignalAtom: "commit-signal", +})) +vi.mock("@agenta/shared/utils", () => ({generateId: () => "generated-id"})) +vi.mock("@agenta/ui/app-message", () => ({message: {warning: vi.fn()}})) +vi.mock("@ai-sdk/react", () => ({ + useChat: () => ({ + addToolApprovalResponse: vi.fn(), + addToolOutput: vi.fn(), + error: undefined, + messages: state.messages, + regenerate: state.regenerate, + sendMessage: state.sendMessage, + setMessages: vi.fn(), + status: "ready", + stop: state.stop, + }), +})) +vi.mock("@tanstack/react-query", () => ({ + useQueryClient: () => ({invalidateQueries: vi.fn()}), +})) + +vi.mock("jotai", () => ({ + useAtomValue: () => state.projectId, + useSetAtom: () => vi.fn(), + useStore: () => ({ + get: (atom: string) => { + if (atom === "record-counts" || atom === "session-messages") return {} + if (atom === "open-sessions") return new Set() + return undefined + }, + }), +})) + +vi.mock("@/oss/state/project", () => ({projectIdAtom: "project-id"})) +vi.mock("../assets/constants", () => ({ + doesAgentChatStopKillSession: () => false, +})) +vi.mock("../components/Inspector/invalidate", () => ({ + invalidateSessionInspector: vi.fn(), +})) +vi.mock("../state/scope", () => ({useChatScopeKey: () => "scope"})) +vi.mock("../state/sessions", () => ({ + openSessionIdsAtomFamily: () => "open-sessions", +})) +vi.mock("../state/turnCaptures", () => ({ + captureTurnRequestAtom: "capture-request", +})) +vi.mock("./useFileActivityDetector", () => ({ + useFileActivityDetector: vi.fn(), +})) +vi.mock("./useSessionHydration", () => ({ + useSessionHydration: () => ({ + hydratedEmpty: false, + isHydrating: false, + runningElsewhere: false, + sessionTurnId: state.sessionTurnId, + stoppingTurnId: state.stoppingTurnId, + stopStateLoading: state.stopStateLoading, + }), +})) +vi.mock("./useToolCacheInvalidation", () => ({ + useToolCacheInvalidation: vi.fn(), +})) + +import {useAgentChatSession} from "./useAgentChatSession" + +describe("useAgentChatSession execution guard", () => { + beforeEach(() => { + state.acceptedRunBySession.clear() + state.turnDeliverySourceBySession.clear() + state.turnIds.clear() + state.sendMessage.mockClear() + state.regenerate.mockClear() + state.cancelSessionExecution.mockReset() + state.resolveStopExecution.mockReset() + state.stop.mockReset() + state.resolveStopExecution.mockImplementation(async ({readExecutionId}) => { + const executionId = readExecutionId() + return executionId ? {status: "resolved", executionId} : {status: "settled"} + }) + state.projectId = "project-id" + state.latestTurnId = undefined + state.hitlPending = false + state.sessionTurnId = null + state.stoppingTurnId = null + state.stopStateLoading = false + state.busy = false + }) + + it("clears the previous turn before sends, regeneration, and SDK automatic requests", async () => { + const sessionId = "session-1" + let result: ReturnType | undefined + const container = document.createElement("div") + const root = createRoot(container) + const Probe = () => { + result = useAgentChatSession({ + entityId: "revision-1", + sessionId, + initialMessages: [], + intent: {} as never, + }) + return null + } + act(() => root.render(createElement(Probe))) + + state.turnIds.set(sessionId, "turn-before-send") + act(() => void result!.sendMessage({text: "next"})) + expect(state.turnIds.get(sessionId)).toBeUndefined() + + state.turnIds.set(sessionId, "turn-before-regenerate") + act(() => void result!.regenerate()) + expect(state.turnIds.get(sessionId)).toBeUndefined() + + state.turnIds.set(sessionId, "turn-before-auto-resume") + await act(() => state.capturedHooks!.prepareRequest({messages: [], id: sessionId})) + expect(state.turnIds.get(sessionId)).toBeUndefined() + + act(() => root.unmount()) + }) + + it("voids an approval resume before cancellation settles or its stream errors", async () => { + const sessionId = "session-1" + let resolveCancel: ((value: unknown) => void) | undefined + state.cancelSessionExecution.mockReturnValue( + new Promise((resolve) => { + resolveCancel = resolve + }), + ) + + let result: ReturnType | undefined + const container = document.createElement("div") + const root = createRoot(container) + const Probe = () => { + result = useAgentChatSession({ + entityId: "revision-1", + sessionId, + initialMessages: [], + intent: {} as never, + }) + return null + } + act(() => root.render(createElement(Probe))) + + act(() => result!.markLiveGate({kind: "approval", id: "approval-1"})) + act(() => result!.handleStop()) + act(() => state.capturedHooks!.onError()) + + expect(state.capturedHooks!.sendAutomaticallyWhen({messages: []})).toBe(false) + + await act(async () => { + resolveCancel?.({ + accepted: true, + conflict: false, + execution: {id: "turn-1", state: "stopping"}, + }) + await Promise.resolve() + }) + act(() => root.unmount()) + }) + + it("stops an accepted shared turn before transcript metadata arrives", async () => { + state.busy = true + state.cancelSessionExecution.mockResolvedValue({ + accepted: true, + conflict: false, + execution: {id: "accepted-turn", state: "stopping"}, + }) + let result: ReturnType | undefined + const container = document.createElement("div") + const root = createRoot(container) + const Probe = () => { + result = useAgentChatSession({ + entityId: "revision-1", + sessionId: "session-1", + initialMessages: [], + intent: {} as never, + }) + return null + } + act(() => root.render(createElement(Probe))) + await act(() => state.capturedHooks!.prepareRequest({messages: [], id: "session-1"})) + act(() => + state.capturedHooks!.onData({ + type: "data-session-accepted", + data: {executionId: "accepted-turn"}, + }), + ) + await act(async () => result!.handleStop()) + expect(state.cancelSessionExecution).toHaveBeenCalledWith({ + sessionId: "session-1", + projectId: "project-id", + expectedExecutionId: "accepted-turn", + }) + expect(state.resolveStopExecution).not.toHaveBeenCalled() + act(() => root.unmount()) + }) + + it("keeps the approval resume void when Stop cannot load the project", () => { + state.projectId = null + const sessionId = "session-1" + let result: ReturnType | undefined + const container = document.createElement("div") + const root = createRoot(container) + const Probe = () => { + result = useAgentChatSession({ + entityId: "revision-1", + sessionId, + initialMessages: [], + intent: {} as never, + }) + return null + } + act(() => root.render(createElement(Probe))) + + act(() => result!.markLiveGate({kind: "approval", id: "approval-1"})) + act(() => result!.handleStop()) + act(() => state.capturedHooks!.onError()) + + expect(state.cancelSessionExecution).not.toHaveBeenCalled() + expect(state.capturedHooks!.sendAutomaticallyWhen({messages: []})).toBe(false) + + act(() => root.unmount()) + }) + + it("keeps remounted interaction actions closed until an accepted paused Stop settles", async () => { + const sessionId = "session-1" + state.latestTurnId = "turn-1" + state.hitlPending = true + state.cancelSessionExecution.mockResolvedValue({ + accepted: true, + conflict: false, + execution: {id: "turn-1", state: "stopping"}, + }) + + let result: ReturnType | undefined + const Probe = () => { + result = useAgentChatSession({ + entityId: "revision-1", + sessionId, + initialMessages: [], + intent: {} as never, + }) + return null + } + + const firstContainer = document.createElement("div") + const firstRoot = createRoot(firstContainer) + act(() => firstRoot.render(createElement(Probe))) + await act(async () => { + result!.handleStop() + await Promise.resolve() + }) + expect(state.cancelSessionExecution).toHaveBeenCalledWith({ + sessionId, + projectId: "project-id", + expectedExecutionId: "turn-1", + }) + act(() => firstRoot.unmount()) + + state.sessionTurnId = "turn-1" + state.stoppingTurnId = "turn-1" + const remountContainer = document.createElement("div") + const remountRoot = createRoot(remountContainer) + act(() => remountRoot.render(createElement(Probe))) + expect(result!.stopping).toBe(true) + + state.stoppingTurnId = null + act(() => remountRoot.render(createElement(Probe))) + expect(result!.stopping).toBe(false) + + act(() => remountRoot.unmount()) + }) + + it("settles an acknowledged legacy Stop without entering the retry deadline", async () => { + vi.useFakeTimers() + const sessionId = "session-1" + state.busy = true + state.turnIds.set(sessionId, "turn-1") + state.cancelSessionExecution.mockResolvedValue({ + accepted: true, + conflict: false, + execution: {id: "turn-1", state: "idle"}, + }) + + let result: ReturnType | undefined + const container = document.createElement("div") + const root = createRoot(container) + const Probe = () => { + result = useAgentChatSession({ + entityId: "revision-1", + sessionId, + initialMessages: [], + intent: {} as never, + }) + return null + } + act(() => root.render(createElement(Probe))) + + await act(async () => { + result!.handleStop() + await Promise.resolve() + }) + act(() => vi.advanceTimersByTime(30_000)) + + expect(state.stop).toHaveBeenCalledOnce() + expect(state.cancelSessionExecution).toHaveBeenCalledOnce() + expect(result!.stopping).toBe(false) + + act(() => root.unmount()) + vi.useRealTimers() + }) + + it("drops a stale retry fence after conflict so the next Stop targets the observed run", async () => { + vi.useFakeTimers() + const sessionId = "session-1" + state.busy = true + state.turnIds.set(sessionId, "turn-original") + state.cancelSessionExecution + .mockResolvedValueOnce({ + accepted: true, + conflict: false, + execution: {id: "turn-original", state: "stopping"}, + }) + .mockResolvedValueOnce({ + accepted: false, + conflict: true, + execution: {id: null, state: "idle"}, + }) + .mockResolvedValueOnce({ + accepted: true, + conflict: false, + execution: {id: "turn-replacement", state: "stopping"}, + }) + + let result: ReturnType | undefined + const container = document.createElement("div") + const root = createRoot(container) + const Probe = () => { + result = useAgentChatSession({ + entityId: "revision-1", + sessionId, + initialMessages: [], + intent: {} as never, + }) + return null + } + act(() => root.render(createElement(Probe))) + + await act(async () => { + result!.handleStop() + await Promise.resolve() + }) + act(() => vi.advanceTimersByTime(30_000)) + + state.turnIds.set(sessionId, "turn-replacement") + await act(async () => { + result!.handleStop() + await Promise.resolve() + }) + await act(async () => { + result!.handleStop() + await Promise.resolve() + }) + + expect(state.cancelSessionExecution).toHaveBeenNthCalledWith(2, { + sessionId, + projectId: "project-id", + expectedExecutionId: "turn-original", + }) + expect(state.cancelSessionExecution).toHaveBeenNthCalledWith(3, { + sessionId, + projectId: "project-id", + expectedExecutionId: "turn-replacement", + }) + + act(() => root.unmount()) + vi.useRealTimers() + }) + + it("resets a pending execution lookup when the mounted session changes", async () => { + let release!: (value: {status: "aborted"}) => void + state.busy = true + state.resolveStopExecution.mockImplementation( + () => + new Promise((resolve) => { + release = resolve + }), + ) + + let sessionId = "session-1" + let result: ReturnType | undefined + const container = document.createElement("div") + const root = createRoot(container) + const Probe = () => { + result = useAgentChatSession({ + entityId: "revision-1", + sessionId, + initialMessages: [], + intent: {} as never, + }) + return null + } + act(() => root.render(createElement(Probe))) + + act(() => result!.handleStop()) + expect(result!.stopping).toBe(true) + + sessionId = "session-2" + act(() => root.render(createElement(Probe))) + expect(result!.stopping).toBe(false) + + await act(async () => { + release({status: "aborted"}) + await Promise.resolve() + }) + expect(result!.stopping).toBe(false) + + act(() => root.unmount()) + }) + + it("ignores a cancellation response from the previously mounted session", async () => { + let release!: (value: { + accepted: true + conflict: false + execution: {id: string; state: "stopping"} + }) => void + state.busy = true + state.turnIds.set("session-1", "turn-1") + state.cancelSessionExecution.mockImplementation( + () => + new Promise((resolve) => { + release = resolve + }), + ) + + let sessionId = "session-1" + let result: ReturnType | undefined + const container = document.createElement("div") + const root = createRoot(container) + const Probe = () => { + result = useAgentChatSession({ + entityId: "revision-1", + sessionId, + initialMessages: [], + intent: {} as never, + }) + return null + } + act(() => root.render(createElement(Probe))) + + act(() => result!.handleStop()) + expect(result!.stopping).toBe(true) + + sessionId = "session-2" + act(() => root.render(createElement(Probe))) + expect(result!.stopping).toBe(false) + + await act(async () => { + release({ + accepted: true, + conflict: false, + execution: {id: "turn-1", state: "stopping"}, + }) + await Promise.resolve() + }) + expect(result!.stopping).toBe(false) + + act(() => root.unmount()) + }) + + it("waits for a resumed execution id before sending Stop", async () => { + const sessionId = "session-1" + let release!: (value: {status: "resolved"; executionId: string}) => void + state.busy = true + state.resolveStopExecution.mockImplementation( + () => + new Promise((resolve) => { + release = resolve + }), + ) + state.cancelSessionExecution.mockResolvedValue({ + accepted: true, + conflict: false, + execution: {id: "turn-resumed", state: "stopping"}, + }) + + let result: ReturnType | undefined + const container = document.createElement("div") + const root = createRoot(container) + const Probe = () => { + result = useAgentChatSession({ + entityId: "revision-1", + sessionId, + initialMessages: [], + intent: {} as never, + }) + return null + } + act(() => root.render(createElement(Probe))) + + act(() => result!.handleStop()) + expect(state.resolveStopExecution).toHaveBeenCalledOnce() + expect(state.cancelSessionExecution).not.toHaveBeenCalled() + + await act(async () => { + release({status: "resolved", executionId: "turn-resumed"}) + await Promise.resolve() + }) + expect(state.cancelSessionExecution).toHaveBeenCalledWith({ + sessionId, + projectId: "project-id", + expectedExecutionId: "turn-resumed", + }) + + act(() => root.unmount()) + }) +}) diff --git a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts index e8f30a90bcb..440aaaa6397 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts @@ -1,26 +1,46 @@ -import {useCallback, useEffect, useMemo, useRef, useState} from "react" +import {useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState} from "react" import { buildRequestWithinDeadline, getMessageTraceId, + latestTurnId, + resolveStopExecution, startupLabelFromDataPart, } from "@agenta/chat/assets" import type {ClientToolOutputHandler} from "@agenta/chat/clientTools" -import {ignoreStreamRejection, parseAgentRunError} from "@agenta/chat/model" +import {useSessionChat} from "@agenta/chat/hooks" import { + classifyAgentRunError, + ignoreStreamRejection, + createUserStoppedState, + isSessionTurnStopping, + reduceUserStoppedState, + type RunErrorMetadata, + withoutSharedSenderAcceptanceMessages, +} from "@agenta/chat/model" +import { + acceptedRunBySession, clearTurnClockAtom, stampMessagesCreatedAtAtom, startTurnClockAtom, + turnDeliverySourceBySession, + type TurnDeliverySource, } from "@agenta/chat/state" import {expandedKeysForMessages, pruneExpandedAtom} from "@agenta/chat/state" import { + clearSessionTurnId, + getSessionTurnId, + isChatBusy, persistSessionMessagesAtom, sessionMessagesAtom, sessionRecordCountsReadAtom, + setSessionStatusAtom, + setSessionTurnId, + setAcceptedSessionTurnId, + type SessionChatHooks, } from "@agenta/chat/state" -import {AgentChatTransport} from "@agenta/chat/transport" import { - commandSessionStream, + cancelSessionExecution, invalidateSessionListQueries, killSession, recordInteractionAnswerAtom, @@ -34,6 +54,7 @@ import { approvalResolution, buildAgentRequest, buildTurnCapture, + isHitlPending, isResumeSend, playgroundController, recordAnswerThenRelease, @@ -41,6 +62,7 @@ import { } from "@agenta/playground" import {agentSelfCommitSignalAtom} from "@agenta/shared/state" import {generateId} from "@agenta/shared/utils" +import {message} from "@agenta/ui/app-message" import {useChat} from "@ai-sdk/react" import {useQueryClient} from "@tanstack/react-query" import {type UIMessage} from "ai" @@ -49,7 +71,10 @@ import {useAtomValue, useSetAtom, useStore} from "jotai" import {projectIdAtom} from "@/oss/state/project" import {doesAgentChatStopKillSession} from "../assets/constants" +import {isStoppingPhase, reduceStopPhase} from "../assets/stopState" import {invalidateSessionInspector} from "../components/Inspector/invalidate" +import {useChatScopeKey} from "../state/scope" +import {openSessionIdsAtomFamily} from "../state/sessions" import {captureTurnRequestAtom} from "../state/turnCaptures" import {useFileActivityDetector} from "./useFileActivityDetector" @@ -63,8 +88,9 @@ import {useToolCacheInvalidation} from "./useToolCacheInvalidation" * stop/kill, and teardown. Everything the UI layers on top (queue, approvals, onboarding, the * composer) consumes this hook's return rather than reaching for `useChat` directly. * - * Design decisions baked in (docs/design/agent-workflows/playground-agent-generation.md): - * - D9 teardown: abort the in-flight stream on unmount (tab close / revision swap). + * Design decisions baked in (docs/design/agent-workflows/projects/session-chat-registry/decisions.md): + * - D9 teardown: release the chat on unmount; `@agenta/chat`'s session-chat registry owns + * the instance and decides whether to preserve it (#5724). * - DT3 cancelled state: a stopped stream tags its partial bubble "Stopped" + offers Resend. */ export const useAgentChatSession = ({ @@ -98,75 +124,107 @@ export const useAgentChatSession = ({ const recordWatermarkRef = useRef( store.get(sessionRecordCountsReadAtom)[sessionId], ) + // Durable sequence coverage is connection-local and must never be stored as a row count. + const sequenceWatermarkRef = useRef(undefined) // Whether the LAST assistant turn was user-stopped. You can only cancel the in-flight (last) turn, // so this is a single boolean gated on position at render time — independent of message ids (which // can be missing/duplicated in restore/error paths and would otherwise smear the tag onto every // turn). Cleared on the next send/resend. - const [stopped, setStopped] = useState(false) - - // `useChat` pins its `Chat` (and thus this transport) for the life of the session `id`; it is - // NOT recreated when `entityId` changes (only on an `id` change). So the request builder must - // read the CURRENT entity through a ref — capturing `entityId` by value would send every turn - // with the revision that was displayed when the session first mounted, even after a switch or a - // self-commit. Reading `entityIdRef.current` at send time keeps runs on the live revision. - const entityIdRef = useRef(entityId) - entityIdRef.current = entityId - - // Turn Inspector capture write, read via ref so the transport `useMemo` doesn't depend on it. - const captureTurnRequest = useSetAtom(captureTurnRequestAtom) - const captureRef = useRef(captureTurnRequest) - captureRef.current = captureTurnRequest - - // Transport feeds the v6 stream request from the playground pipeline. `api` here is a - // placeholder that `prepareSendMessagesRequest` overrides per request. - const transport = useMemo( - () => - new AgentChatTransport({ - api: "", - prepareSendMessagesRequest: async ({messages, id}) => { - // Bounded: retries while the invocation URL is still loading and rejects if - // the build hangs, so a failed send surfaces as an error bubble instead of an - // eternal spinner (#6042). - const req = await buildRequestWithinDeadline(() => - buildAgentRequest(entityIdRef.current, messages, { - sessionId: id ?? sessionId, - }), - ) - captureRef.current(buildTurnCapture(req, generateId(), Date.now())) - return {api: req.invocationUrl, headers: req.headers, body: req.requestBody} - }, - }), - [sessionId], + const [userStoppedState, dispatchStopped] = useReducer( + reduceUserStoppedState, + initialMessages, + createUserStoppedState, ) + const stopped = userStoppedState.stopped + const setStopped = useCallback( + (next: boolean) => dispatchStopped({type: next ? "user-stop" : "reset"}), + [], + ) + const [stopPhase, dispatchStop] = useReducer(reduceStopPhase, "idle") + const captureTurnRequest = useSetAtom(captureTurnRequestAtom) const revalidateSessionMounts = useSetAtom(revalidateSessionMountsAtom) const revalidateSessionRecords = useSetAtom(revalidateSessionRecordsAtom) + const setSessionStatus = useSetAtom(setSessionStatusAtom) const recordInteractionAnswer = useSetAtom(recordInteractionAnswerAtom) const queryClient = useQueryClient() // Only a gate settled in this mount may trigger an automatic resume; hydrated answers stay inert. // `null` means "no live gate" — voided by a stop, or spent once a resume really went out; // `undefined` means "no live marker", which falls back to the predicate's tail heuristics. const liveGateInteractionRef = useRef(null) + // Whether this mount is still on screen. The chat outlives it, so its callbacks need to tell + // "still mine to report" from "running on in the background". + const mountedRef = useRef(false) + const messagesRef = useRef(initialMessages) const setTurnStartupLabel = useSetAtom(startTurnClockAtom) + // Did the runner acknowledge THIS turn? Its acceptance frame is transient, so it reaches + // `onData` and never the transcript — this is the only place the answer survives. A stream that + // dies after it is a lost connection, not a lost turn; one that dies before it may be a send + // that never started, and that failure has to stay on screen and in the cache. + const turnAcceptedRef = useRef(acceptedRunBySession.has(sessionId)) + const acceptedExecutionIdRef = useRef( + acceptedRunBySession.get(sessionId) ?? null, + ) + const [acceptedRunPending, setAcceptedRunPending] = useState(() => + acceptedRunBySession.has(sessionId), + ) + const [turnDeliverySource, setTurnDeliverySource] = useState( + () => turnDeliverySourceBySession.get(sessionId) ?? null, + ) + const entityIdRef = useRef(entityId) + // Synced after commit, never during render: an interrupted render must not leak an + // uncommitted revision into the request builder. + useLayoutEffect(() => { + entityIdRef.current = entityId + }, [entityId]) + const adoptRevision = useCallback((next: string) => { + entityIdRef.current = next + }, []) + const sharedSenderReadyRef = useRef(false) + const setSharedSenderReady = useCallback((ready: boolean) => { + sharedSenderReadyRef.current = ready + }, []) - const { - messages, - sendMessage, - status, - stop, - regenerate, - setMessages, - addToolApprovalResponse, - addToolOutput, - error, - } = useChat({ - id: sessionId, - messages: initialMessages, - transport, - // Coalesce stream deltas to ~1 UI commit / 50ms so a fast token stream doesn't drive a - // render per token; caps commit frequency independently of the per-commit memo win. - experimental_throttle: 50, + // Rebuilt every render and bound to the chat on every commit (below), so they always see the live + // values — `entityId` included, which is why a run follows a revision switch or a self-commit + // instead of sticking to the revision this session first mounted on. + const hooks: SessionChatHooks = { + prepareRequest: async ({messages, id}) => { + clearSessionTurnId(sessionId) + turnAcceptedRef.current = false + acceptedExecutionIdRef.current = null + acceptedRunBySession.delete(sessionId) + setAcceptedRunPending(false) + const sharedResponse = sharedSenderReadyRef.current + const deliverySource: TurnDeliverySource = sharedResponse ? "shared" : "legacy" + turnDeliverySourceBySession.set(sessionId, deliverySource) + setTurnDeliverySource(deliverySource) + // Bounded: retries while the invocation URL is still loading and rejects if the build + // hangs, so a failed send surfaces as an error bubble instead of an eternal spinner + // (#6042). The helper owns the not-ready / timed-out errors. + const req = await buildRequestWithinDeadline(() => + buildAgentRequest(entityIdRef.current, messages, { + sessionId: id ?? sessionId, + sharedResponse, + secretSetup: true, + }), + ) + captureTurnRequest(buildTurnCapture(req, generateId(), Date.now())) + return {api: req.invocationUrl, headers: req.headers, body: req.requestBody} + }, + // ── #6047 startup states: capture the runner's observed startup boundary as it streams ── onData: (part) => { + if (part.type === "data-session-accepted") { + turnAcceptedRef.current = true + const data = part.data as {executionId?: unknown} | undefined + acceptedExecutionIdRef.current = + typeof data?.executionId === "string" ? data.executionId : null + if (acceptedExecutionIdRef.current) { + setAcceptedSessionTurnId(sessionId, acceptedExecutionIdRef.current) + } + acceptedRunBySession.set(sessionId, acceptedExecutionIdRef.current) + setAcceptedRunPending(true) + } const label = startupLabelFromDataPart(part) if (label) setTurnStartupLabel(sessionId, label) }, @@ -190,7 +248,12 @@ export const useAgentChatSession = ({ // `is_running: true` outlived the answer by up to 15s (#5844). Safe to refetch immediately — // the runner awaits its `is_running: false` heartbeat BEFORE closing this stream // (services/runner/src/server.ts `aliveWatchdog.release()`), so the flag is already cleared. - onFinish: ({message}) => { + onFinish: ({message, messages: finishedMessages, finishReason}) => { + dispatchStopped({ + type: "stream-terminal", + messages: finishedMessages, + finishReason, + }) markTraceAsFresh(getMessageTraceId(message)) revalidateSessionMounts(sessionId) revalidateSessionRecords(sessionId) @@ -199,28 +262,86 @@ export const useAgentChatSession = ({ // title/preview/activity. Nothing else tells the session lists, so they discovered a // brand-new session only on their next poll or window refocus. invalidateSessionListQueries() + // A preserved run settling with nobody mounted: this callback outlives the mount, so it + // is what retires the session's run-state dot. A LIVE mount publishes its own status + // (with error/awaiting precedence) from `busy`, so writing here would only flicker it. + if (!mountedRef.current) setSessionStatus({id: sessionId, status: "idle"}) }, - onError: (err) => { - // Clear the marker but do NOT void the resume. A gateway approval is answered while the - // stream is still open, so the SDK skips its own dispatch and only re-evaluates when the - // stream ends — often by erroring, right here. `null` made that last evaluation return - // false and stranded the answer; `undefined` lets the tail heuristics decide. - // Adoption is unaffected: the hydration guard reads this ref as a boolean. - liveGateInteractionRef.current = undefined - // Render the error in-chat (the `error` alert below); swallow it here so an - // aborted/errored stream doesn't bubble unhandled to the Next.js dev overlay (F-033). - console.warn("[AgentChatPanel] useChat error (rendered in-chat):", err) + onError: () => { + // Preserve null after resume/Stop; only a live marker may fall back to tail detection. + if (liveGateInteractionRef.current !== null) { + liveGateInteractionRef.current = undefined + } }, + } + + // The registry owns the `Chat`, so re-entering the route re-binds to the SAME instance mid-turn + // instead of aborting the run (#5724). The desktop preserves a chat for as long as its TAB is + // open: a route change unmounts this conversation but leaves the tab, so the run follows the + // user; the close/delete/archive/reset writers all commit before React runs the cleanup, so the + // open-tab set is the authoritative answer by then. + const scopeKey = useChatScopeKey() + const chat = useSessionChat({ + sessionId, + initialMessages, + hooks, + shouldPreserve: () => store.get(openSessionIdsAtomFamily(scopeKey)).has(sessionId), + }) + + const { + messages, + sendMessage: sendChatMessage, + status, + stop, + regenerate: regenerateChatMessage, + setMessages, + addToolApprovalResponse, + addToolOutput, + error, + clearError, + } = useChat({ + chat, + // Coalesce stream deltas to ~1 UI commit / 50ms so a fast token stream doesn't drive a + // render per token; caps commit frequency independently of the per-commit memo win. + experimental_throttle: 50, }) - const busy = status === "submitted" || status === "streaming" + const sendMessageWithFreshGuard: typeof sendChatMessage = useCallback( + (...args: Parameters) => { + clearSessionTurnId(sessionId) + return sendChatMessage(...args) + }, + [sendChatMessage, sessionId], + ) + const regenerateWithFreshGuard: typeof regenerateChatMessage = useCallback( + (...args: Parameters) => { + clearSessionTurnId(sessionId) + return regenerateChatMessage(...args) + }, + [regenerateChatMessage, sessionId], + ) + const lastMessage = messages[messages.length - 1] + const serverErrorProvenance = + lastMessage?.role === "assistant" && + lastMessage.parts.some((part) => part.type === "data-agent-error") + const errorBoundary = useMemo( + () => + error + ? classifyAgentRunError(error, turnAcceptedRef.current, serverErrorProvenance) + : {}, + [error, serverErrorProvenance], + ) + const busy = isChatBusy(status) // `messages`/`busy` change every token; consumers that must stay referentially stable // (`handleRewind`, the hydration/SWR adoption guards) read them through refs instead. - const messagesRef = useRef(messages) messagesRef.current = messages - const busyRef = useRef(busy) - busyRef.current = busy + const busyRef = useRef(busy || acceptedRunPending) + busyRef.current = busy || acceptedRunPending + + useEffect(() => { + dispatchStopped({type: "transcript", messages}) + }, [messages]) // Mid-stream drive signals: settled write-ish tool calls append file-activity entries (and // throttle-revalidate the drives) as the turn streams, not just at onFinish. @@ -229,7 +350,16 @@ export const useAgentChatSession = ({ // Server-side platform ops (create_schedule, …) stale the client cache with no other signal. useToolCacheInvalidation({sessionId, messages}) - const {isHydrating, hydratedEmpty, runningElsewhere} = useSessionHydration({ + const { + isHydrating, + hydratedEmpty, + runningElsewhere, + stopStateLoading, + sessionTurnId, + stoppingTurnId, + sharedReaderAdvertised, + refreshFromRecords, + } = useSessionHydration({ sessionId, initialMessages, messagesRef, @@ -237,12 +367,21 @@ export const useAgentChatSession = ({ seenIdsRef, restoredIdsRef, recordWatermarkRef, + sequenceWatermarkRef, busy, setMessages, persistMessages, + clearRunError: clearError, intent, pendingResumeRef: liveGateInteractionRef, }) + const stopping = + isStoppingPhase(stopPhase) || + isSessionTurnStopping({ + currentTurnId: sessionTurnId ?? latestTurnId(messages), + stoppingTurnId, + }) || + (stopStateLoading && isHitlPending(messages)) // A decision made in THIS mount marks the resume as live — a restored approval-requested tail // the user answers after a reload genuinely auto-resumes, so the queue's pre-resume hold applies. @@ -331,30 +470,32 @@ export const useAgentChatSession = ({ // response, tool output, stream finish) — never on mount — so this resume can't fire and // must not hold the queue. Short-circuits cheap on the streaming hot path: any live send // makes the tail non-restored. - const lastMessage = messages[messages.length - 1] const resumeOrphaned = !liveGateInteractionRef.current && !!lastMessage && restoredIdsRef.current.has(lastMessage.id) && agentShouldResumeAfterApproval({messages}) - // Surface a stream failure inline: stamp the parsed error onto the failing assistant turn so - // it renders as a red error bubble with the real reason (and persists with the session via the - // effect below), instead of a transient top banner + a generic "no response". FE-only — it - // uses the error useChat already has; the backend doesn't need to attach it to the trace. + // Cache only the newest turn id observed by this page for guarded Stop. + useEffect(() => { + const turnId = latestTurnId(messages) + if (turnId) setSessionTurnId(sessionId, turnId) + }, [messages, sessionId]) + + // Run failures become conversation content; an accepted transport loss stays connection state. useEffect(() => { - if (!error) return - const parsed = parseAgentRunError(error) + const parsed = errorBoundary.runError + if (!parsed) return + const stamp: RunErrorMetadata = {runError: parsed} setMessages((prev) => { const last = prev.length > 0 ? prev[prev.length - 1] : undefined - const existing = (last?.metadata as {runError?: {message?: string}} | undefined) - ?.runError + const existing = (last?.metadata as RunErrorMetadata | undefined)?.runError if (last?.role === "assistant") { if (existing?.message === parsed.message) return prev // already stamped const next = [...prev] next[next.length - 1] = { ...last, - metadata: {...(last.metadata as object | undefined), runError: parsed}, + metadata: {...(last.metadata as object | undefined), ...stamp}, } return next } @@ -365,11 +506,11 @@ export const useAgentChatSession = ({ id: `run-error-${generateId()}`, role: "assistant", parts: [], - metadata: {runError: parsed}, + metadata: stamp, } as (typeof prev)[number], ] }) - }, [error, setMessages]) + }, [errorBoundary.runError, setMessages]) // A live turn makes the transcript no longer a copy of the server's, and we can't know how many // records the runner logged for it — so drop the watermark and let the next open re-sync from @@ -377,13 +518,20 @@ export const useAgentChatSession = ({ // flips to "submitted", effects run in declaration order, so clearing here is what stops the // persist below from filing a locally-extended transcript under a server watermark. useEffect(() => { - if (status === "submitted" || status === "streaming") recordWatermarkRef.current = undefined + if (isChatBusy(status)) { + recordWatermarkRef.current = undefined + sequenceWatermarkRef.current = undefined + } }, [status]) // Persist the conversation whenever its stream settles (skip mid-stream). useEffect(() => { if (status === "streaming") return - persistMessages({id: sessionId, messages, recordCount: recordWatermarkRef.current}) + persistMessages({ + id: sessionId, + messages: withoutSharedSenderAcceptanceMessages(messages), + recordCount: recordWatermarkRef.current, + }) }, [messages, status, sessionId, persistMessages]) // ── #6047 startup states: one label per in-flight turn ── @@ -461,53 +609,200 @@ export const useAgentChatSession = ({ } }, [messages, entityId, switchEntity, store, setAgentCommitSignal]) - // ── DT3 cancelled state: wrap stop() to mark the in-flight assistant turn ── - const markStopped = useCallback(() => { - const last = messages[messages.length - 1] - if (last && last.role === "assistant") setStopped(true) - }, [messages]) - const projectId = useAtomValue(projectIdAtom) + const expectedStopExecutionIdRef = useRef(undefined) + const retryStopRef = useRef(false) + const abortAfterAcceptedRef = useRef(false) + const stopResolutionRef = useRef(null) + const stopAttemptRef = useRef(0) + + useEffect(() => { + stopAttemptRef.current += 1 + dispatchStop({type: "reset"}) + retryStopRef.current = false + abortAfterAcceptedRef.current = false + expectedStopExecutionIdRef.current = undefined + return () => { + stopResolutionRef.current?.abort() + } + }, [sessionId]) const handleStop = useCallback(() => { - markStopped() - // A stop voids the pending gate (same rule the queue applies), so the marker must go too — - // otherwise it outlives the abandoned resume and blocks this mount's records adoption. + if (stopping) return + // Fence delayed approval release even when cancellation cannot be requested yet. liveGateInteractionRef.current = null - stop() // abort the client stream immediately - if (!projectId || !sessionId) return + const wasParked = !busyRef.current && isHitlPending(messagesRef.current) + const stopAttempt = ++stopAttemptRef.current + dispatchStop({type: "request"}) + if (!projectId || !sessionId) { + dispatchStop({type: "failed"}) + message.warning("Could not stop the run. It may still be running.") + return + } // Opt-in hard kill (NEXT_PUBLIC_AGENT_CHAT_STOP_KILLS_SESSION): tear the whole session down. if (doesAgentChatStopKillSession()) { killSession({sessionId, projectId}) .then((ok) => { + if (stopAttemptRef.current !== stopAttempt) return if (ok) { + dispatchStop( + wasParked ? {type: "cancelled", parked: true} : {type: "accepted"}, + ) queryClient.invalidateQueries({queryKey: ["session-liveness"]}) - // Refresh an open Inspector's Runtime lens so its Lifecycle/State reflect the - // kill immediately (mirrors the panel's own Kill button). + // Refresh an open Inspector so it reflects the kill immediately. void invalidateSessionInspector(queryClient, sessionId) + } else { + dispatchStop({type: "failed"}) + message.warning("Could not stop the run. It may still be running.") } }) - .catch(() => {}) + .catch((error: unknown) => { + if (stopAttemptRef.current !== stopAttempt) return + dispatchStop({type: "failed"}) + message.warning( + error instanceof Error + ? error.message + : "Could not stop the run. It may still be running.", + ) + }) return } - // Default Stop: cooperatively cancel the CURRENT TURN. The control-plane `cancel` command - // (no inputs, no force) drops the alive lock; the runner closes the turn as interrupted and - // the session STAYS OPEN so a follow-up prompt resumes it — instead of the old behaviour where - // the client stream aborted but the runner kept running and billing. - commandSessionStream({sessionId, projectId}).catch(() => {}) - }, [markStopped, stop, projectId, sessionId, queryClient]) - - // ── D9 teardown: abort the in-flight stream on unmount (tab close / revision swap) ── - // Keyed on sessionId: closing a tab or swapping the revision unmounts this conversation - // and should tear down its stream. - // The clock goes with it: a turn torn down mid-flight leaves an entry no one clears, and a - // later remount would then read a start time from a turn that is long gone. + // Keep the stream attached until a terminal event confirms accepted cancellation. + const isRetry = retryStopRef.current + const expectedExecutionId = isRetry + ? expectedStopExecutionIdRef.current + : getSessionTurnId(sessionId) + retryStopRef.current = false + abortAfterAcceptedRef.current = isRetry + const cancel = async () => { + let resolvedExecutionId = expectedExecutionId + if (!isRetry && !resolvedExecutionId && busyRef.current) { + const controller = new AbortController() + stopResolutionRef.current?.abort() + stopResolutionRef.current = controller + const resolution = await resolveStopExecution({ + readExecutionId: () => getSessionTurnId(sessionId), + isRunActive: () => busyRef.current, + signal: controller.signal, + }) + if (stopResolutionRef.current === controller) stopResolutionRef.current = null + if (resolution.status !== "resolved") return {resolution} as const + resolvedExecutionId = resolution.executionId + } + expectedStopExecutionIdRef.current = resolvedExecutionId + const outcome = await cancelSessionExecution({ + sessionId, + projectId, + expectedExecutionId: resolvedExecutionId, + }) + return {outcome} as const + } + void cancel() + .then((result) => { + if (stopAttemptRef.current !== stopAttempt) return + if ("resolution" in result && result.resolution) { + if (result.resolution.status === "settled") { + dispatchStop({type: "terminal"}) + } else if (result.resolution.status === "timed_out") { + dispatchStop({type: "failed"}) + message.warning("Could not identify the run to stop. Please try again.") + } + return + } + const {outcome} = result + void invalidateSessionInspector(queryClient, sessionId) + if (outcome?.accepted) { + dispatchStop({type: "cancelled", parked: wasParked}) + const legacyStopSettled = outcome.execution.state === "idle" + if (legacyStopSettled || abortAfterAcceptedRef.current) { + stop() + dispatchStop({type: "terminal"}) + } + queryClient.invalidateQueries({queryKey: ["session-liveness"]}) + return + } + if (outcome && !outcome.conflict && outcome.execution.state === "idle") { + abortAfterAcceptedRef.current = false + expectedStopExecutionIdRef.current = undefined + dispatchStop({type: "already_idle"}) + queryClient.invalidateQueries({queryKey: ["session-liveness"]}) + return + } + if (outcome?.conflict) { + retryStopRef.current = false + expectedStopExecutionIdRef.current = undefined + } else if (abortAfterAcceptedRef.current) { + retryStopRef.current = true + } + abortAfterAcceptedRef.current = false + dispatchStop({type: "failed"}) + message.warning( + outcome?.conflict + ? "That run had already finished. The session is running something else now." + : "Could not stop the run. It may still be running.", + ) + queryClient.invalidateQueries({queryKey: ["session-liveness"]}) + }) + .catch((error: unknown) => { + if (stopAttemptRef.current !== stopAttempt) return + stopResolutionRef.current = null + if (abortAfterAcceptedRef.current) retryStopRef.current = true + abortAfterAcceptedRef.current = false + dispatchStop({type: "failed"}) + message.warning( + error instanceof Error + ? error.message + : "Could not stop the run. It may still be running.", + ) + }) + }, [stopping, projectId, sessionId, queryClient, stop]) + + useEffect(() => { + if (stopPhase !== "accepted") return + const timer = setTimeout(() => { + retryStopRef.current = true + abortAfterAcceptedRef.current = false + dispatchStop({type: "timeout"}) + }, 30_000) + return () => clearTimeout(timer) + }, [stopPhase]) + + const previousBusyRef = useRef(busy) + useEffect(() => { + const wasBusy = previousBusyRef.current + previousBusyRef.current = busy + if (wasBusy && !busy) { + retryStopRef.current = false + dispatchStop({type: "terminal"}) + } + if (!wasBusy && busy) dispatchStop({type: "reset"}) + }, [busy]) + + useEffect(() => { + if (stopPhase !== "stopped") return + const last = messagesRef.current[messagesRef.current.length - 1] + if (last?.role === "assistant") setStopped(true) + retryStopRef.current = false + abortAfterAcceptedRef.current = false + expectedStopExecutionIdRef.current = undefined + dispatchStop({type: "reset"}) + }, [stopPhase]) + + // ── D9 teardown: `useSessionChat` releases the claim; this tracks what it does not own ── + // The startup clock only goes with the session when the session itself is gone — clearing it + // unconditionally would blank a still-open tab's label when its stream is merely following the + // user to another route (#5724, #6047). useEffect(() => { + // Set on SETUP, not at declaration: StrictMode's dev cycle tears this effect down and runs + // it again on the same mount, and the flag has to come back with it. + mountedRef.current = true return () => { - stop() - clearTurnClock(sessionId) + mountedRef.current = false + if (!store.get(openSessionIdsAtomFamily(scopeKey)).has(sessionId)) { + clearTurnClock(sessionId) + } } - }, [sessionId, stop, clearTurnClock]) + }, [sessionId, scopeKey, store, clearTurnClock]) // After each commit, mark on-screen messages as seen so they don't re-animate on later renders // (e.g. streaming tokens). Done in an effect, not during render, so StrictMode's double invoke @@ -523,9 +818,21 @@ export const useAgentChatSession = ({ messages, status, busy, - error, - sendMessage, - regenerate, + error: errorBoundary.runError, + connectionWarning: errorBoundary.connectionWarning, + acceptedRunPending, + turnDeliverySource, + settleSharedTurn: (executionId?: string) => { + const acceptedExecutionId = acceptedExecutionIdRef.current + if (executionId && acceptedExecutionId && acceptedExecutionId !== executionId) return + acceptedExecutionIdRef.current = null + acceptedRunBySession.delete(sessionId) + setAcceptedRunPending(false) + turnDeliverySourceBySession.delete(sessionId) + setTurnDeliverySource(null) + }, + sendMessage: sendMessageWithFreshGuard, + regenerate: regenerateWithFreshGuard, setMessages, addToolApprovalResponse, messagesRef, @@ -533,10 +840,15 @@ export const useAgentChatSession = ({ isHydrating, hydratedEmpty, runningElsewhere, + sharedReaderAdvertised, + refreshFromRecords, + setSharedSenderReady, stopped, + stopping, setStopped, handleStop, handleClientToolOutput, + adoptRevision, markLiveGate, answerApproval, resumeOrphaned, diff --git a/web/oss/src/components/AgentChatSlice/hooks/useOpenAgentSession.ts b/web/oss/src/components/AgentChatSlice/hooks/useOpenAgentSession.ts index 2c0fa9c9244..0f86d39ffea 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useOpenAgentSession.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useOpenAgentSession.ts @@ -1,5 +1,6 @@ import {useCallback} from "react" +import {playgroundSessionPath} from "@agenta/sessions/link" import { addPendingSessionOpenAtom, removePendingSessionOpensAtom, @@ -15,6 +16,10 @@ import {urlAtom} from "@/oss/state/url" * navigate. `AgentChatPanel` adopts it once the chat scope resolves, so a session this browser has * never seen still opens — its transcript hydrates from the durable records. * + * The target also rides the URL (`?session_id=`), so the address bar names what you are looking at + * and a reload comes back to it. The stashed target stays: it carries the title the tab shows + * before records hydrate, and a fresh session's id, which the URL only learns once it exists. + * * No revision is pinned: the playground resolves its own default. Continuing under the exact config * the session last ran with is a separate concern (see the sessions UX plan). */ @@ -26,10 +31,12 @@ export function useOpenAgentSession(): (target: PendingSessionOpen) => void { return useCallback( (target: PendingSessionOpen) => { + const path = playgroundSessionPath(baseAppURL, target.appId, target.sessionId) + if (!path) return addPendingOpen(target) // Clear on a failed navigation, or the target would be adopted by whatever agent // playground this browser opens next. Only OUR entry — others may be in flight. - router.push(`${baseAppURL}/${target.appId}/playground`).catch(() => { + router.push(path).catch(() => { removePendingOpens([target]) }) }, diff --git a/web/oss/src/components/AgentChatSlice/hooks/useSessionActions.tsx b/web/oss/src/components/AgentChatSlice/hooks/useSessionActions.tsx index 1d876100b22..2b3519d21db 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useSessionActions.tsx +++ b/web/oss/src/components/AgentChatSlice/hooks/useSessionActions.tsx @@ -1,6 +1,7 @@ -import {useMemo} from "react" +import {useCallback, useMemo} from "react" import type {ReactNode} from "react" +import {playgroundSessionPath} from "@agenta/sessions/link" import { useSessionActions as useSessionActionsCore, type SessionActionTarget, @@ -8,6 +9,8 @@ import { } from "@agenta/sessions-ui" import {useStore} from "jotai" +import {urlAtom} from "@/oss/state/url" + import { archivedSessionHistoryAtomFamily, archiveSessionAtomFamily, @@ -61,7 +64,19 @@ export const useSessionActions = () => { [store], ) - const actions = useSessionActionsCore({localCache}) + // A session's link is its agent's playground, deep-linked. No agent (a session with no turns + // yet) means no link, and the menu entry disables itself. + // + // Read through the store rather than subscribing: this hook runs once per SIDEBAR ROW, and + // `urlAtom` recomputes on every route change, so a subscription re-renders the whole session + // list each time you navigate. Same reason `localCache` above reads that way. + const sharePathFor = useCallback( + ({sessionId, appId}: SessionActionTarget) => + appId ? playgroundSessionPath(store.get(urlAtom).baseAppURL, appId, sessionId) : "", + [store], + ) + + const actions = useSessionActionsCore({localCache, sharePathFor}) return useMemo( () => ({ diff --git a/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.livePreview.test.tsx b/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.livePreview.test.tsx new file mode 100644 index 00000000000..9bd0fc77e4a --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.livePreview.test.tsx @@ -0,0 +1,242 @@ +import {act, createElement} from "react" + +import {useSessionLivePreview} from "@agenta/chat/hooks" +import type {SessionRecord} from "@agenta/entities/session" +import {projectIdAtom} from "@agenta/shared/state" +import type {UIMessage} from "ai" +import {createStore, Provider} from "jotai" +import {createRoot} from "react-dom/client" +import {beforeEach, describe, expect, it, vi} from "vitest" + +import {type ScrollIntent} from "./useScrollIntent" +import {useSessionHydration} from "./useSessionHydration" +;(globalThis as typeof globalThis & {IS_REACT_ACT_ENVIRONMENT: boolean}).IS_REACT_ACT_ENVIRONMENT = + true + +const mocks = vi.hoisted(() => ({ + fetchSessionSnapshot: vi.fn(), + querySessionTranscript: vi.fn(), + loadSessionMessages: vi.fn(), + openedUrls: [] as string[], +})) + +vi.mock("@agenta/chat/assets", async (importOriginal) => ({ + ...(await importOriginal()), + loadSessionMessages: mocks.loadSessionMessages, +})) + +vi.mock("@agenta/chat/state", async (importOriginal) => ({ + ...(await importOriginal()), + hasSessionChat: () => false, + isSessionFresh: () => true, +})) + +vi.mock("@agenta/entities/session", async (importOriginal) => { + const {atom} = await import("jotai") + const actual = await importOriginal() + return { + ...actual, + fetchSessionInteractionStatesAtom: atom(null, () => new Map()), + fetchSessionSnapshot: mocks.fetchSessionSnapshot, + querySessionTranscript: mocks.querySessionTranscript, + } +}) + +vi.mock("../state/liveness", async () => { + const {atom} = await import("jotai") + const liveness = atom({ + isLoading: false, + nest: {isRunning: false}, + sharedReader: true, + stoppingTurnId: null, + turnId: null, + }) + const runningElsewhere = atom(false) + return { + sessionLivenessAtomFamily: () => liveness, + sessionRunningElsewhereAtomFamily: () => runningElsewhere, + } +}) + +vi.mock("../state/scope", () => ({useChatScopeKey: () => "scope-1"})) + +vi.mock("../state/sessions", async () => { + const {atom} = await import("jotai") + const activeSessionId = atom("session-1") + return {activeSessionIdAtomFamily: () => activeSessionId} +}) + +vi.mock("./useSessionRecordsWatch", () => ({useSessionRecordsWatch: () => undefined})) + +const record = (id: string, sequence: number, payload: Record): SessionRecord => ({ + id, + session_id: "session-1", + project_id: "project-1", + sequence, + event_index: null, + sender: "agent", + session_update: String(payload.type), + payload, + created_at: null, +}) + +describe("desktop durable reconnect", () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.openedUrls.length = 0 + mocks.fetchSessionSnapshot.mockResolvedValue({ + session: {flags: {is_running: false}}, + execution: null, + read: {latest_sequence: 10}, + }) + mocks.querySessionTranscript.mockResolvedValue([ + record("record-8", 8, {type: "message", text: "durable reply"}), + record("record-10", 10, {type: "done"}), + ]) + Object.defineProperty(document, "visibilityState", {configurable: true, value: "visible"}) + vi.stubGlobal( + "EventSource", + class { + onmessage = null + onerror = null + + constructor(url: string | URL) { + mocks.openedUrls.push(String(url)) + } + + addEventListener() {} + close() {} + }, + ) + }) + + it("opens SSE after the desktop adapter adopts the bounded snapshot", async () => { + const store = createStore() + store.set(projectIdAtom, "project-1") + const messagesRef = {current: [] as UIMessage[]} + const recordWatermarkRef = {current: undefined as number | undefined} + const sequenceWatermarkRef = {current: undefined as number | undefined} + const setMessages = vi.fn() + const busyRef = {current: false} + const seenIdsRef = {current: new Set()} + const restoredIdsRef = {current: new Set()} + const persistMessages = vi.fn() + const intent = { + armJump: vi.fn(), + stickRef: {current: false}, + } as unknown as ScrollIntent + const pendingResumeRef = {current: null} + const container = document.createElement("div") + const root = createRoot(container) + let hydration: ReturnType | undefined + + const Probe = () => { + hydration = useSessionHydration({ + sessionId: "session-1", + initialMessages: [], + messagesRef, + busyRef, + seenIdsRef, + restoredIdsRef, + recordWatermarkRef, + sequenceWatermarkRef, + busy: false, + setMessages, + persistMessages, + clearRunError: vi.fn(), + intent, + pendingResumeRef, + }) + useSessionLivePreview({ + sessionId: "session-1", + sharedReaderAdvertised: true, + runningElsewhere: true, + onDisconnect: hydration.refreshFromRecords, + }) + return null + } + + await act(async () => { + root.render(createElement(Provider, {store}, createElement(Probe))) + }) + await vi.waitFor(() => expect(mocks.openedUrls).toHaveLength(1)) + expect(recordWatermarkRef.current).toBe(2) + expect(sequenceWatermarkRef.current).toBe(10) + expect(setMessages).toHaveBeenCalledOnce() + expect(mocks.openedUrls[0]).toContain("/sessions/session-1/events?after=10") + + messagesRef.current = setMessages.mock.calls[0][0] + const laterMessages = [ + { + id: "assistant-1", + role: "assistant", + parts: [{type: "text", text: "new retained tail"}], + } as UIMessage, + ] + await expect( + hydration!.refreshFromRecords({ + messages: laterMessages, + recordCount: 2, + sequenceCursor: 11, + }), + ).resolves.toBe(true) + expect(recordWatermarkRef.current).toBe(2) + expect(sequenceWatermarkRef.current).toBe(11) + expect(setMessages).toHaveBeenLastCalledWith(laterMessages) + act(() => root.unmount()) + }) + + it.each(["rejected", "undefined"] as const)( + "keeps the desktop transcript when a watch-triggered read is %s", + async (failure) => { + if (failure === "rejected") { + mocks.loadSessionMessages.mockRejectedValueOnce(new Error("network changed")) + } else { + mocks.loadSessionMessages.mockResolvedValueOnce(undefined) + } + const store = createStore() + store.set(projectIdAtom, "project-1") + const messagesRef = {current: [] as UIMessage[]} + const setMessages = vi.fn() + const container = document.createElement("div") + const root = createRoot(container) + let hydration: ReturnType | undefined + + const Probe = () => { + hydration = useSessionHydration({ + sessionId: "session-1", + initialMessages: [], + messagesRef, + busyRef: {current: false}, + seenIdsRef: {current: new Set()}, + restoredIdsRef: {current: new Set()}, + recordWatermarkRef: {current: undefined}, + sequenceWatermarkRef: {current: undefined}, + busy: false, + setMessages, + persistMessages: vi.fn(), + intent: { + armJump: vi.fn(), + stickRef: {current: false}, + } as unknown as ScrollIntent, + pendingResumeRef: {current: null}, + }) + return null + } + + await act(async () => { + root.render(createElement(Provider, {store}, createElement(Probe))) + }) + let adopted: boolean | undefined + await act(async () => { + adopted = await hydration!.refreshFromRecords( + new MessageEvent("records-changed") as never, + ) + }) + + expect(adopted).toBe(false) + expect(setMessages).not.toHaveBeenCalled() + act(() => root.unmount()) + }, + ) +}) diff --git a/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts b/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts index 57eb963fdf6..4d8de30efda 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts @@ -1,7 +1,8 @@ import {type MutableRefObject, useCallback, useEffect, useRef, useState} from "react" -import {loadSessionMessages, type SessionTranscript} from "@agenta/chat/assets" -import {isSessionFresh} from "@agenta/chat/state" +import {isSessionTranscript, loadSessionMessages, type SessionTranscript} from "@agenta/chat/assets" +import {withoutSharedSenderAcceptanceMessages} from "@agenta/chat/model" +import {hasSessionChat, isSessionFresh} from "@agenta/chat/state" import { fetchSessionRecordsAtom, hasWaitingInteraction, @@ -106,9 +107,11 @@ export const useSessionHydration = ({ seenIdsRef, restoredIdsRef, recordWatermarkRef, + sequenceWatermarkRef, busy, setMessages, persistMessages, + clearRunError, intent, pendingResumeRef, }: { @@ -120,10 +123,14 @@ export const useSessionHydration = ({ restoredIdsRef: MutableRefObject> /** Records the rendered transcript was built from; `undefined` once a live turn supersedes it. */ recordWatermarkRef: MutableRefObject + /** Durable sequence coverage for sequenced reconnect snapshots; never a row count. */ + sequenceWatermarkRef: MutableRefObject /** THIS browser is streaming the turn — reactive, so the catch-up poll can start/stop on it. */ busy: boolean setMessages: (messages: UIMessage[]) => void persistMessages: (args: {id: string; messages: UIMessage[]; recordCount?: number}) => void + /** Drop the stream error `useChat` is holding. Adopting the log supersedes it. */ + clearRunError: () => void intent: ScrollIntent /** * Non-null while a client-tool settle (connect Not-now/Connect, an elicitation answer) has @@ -141,8 +148,14 @@ export const useSessionHydration = ({ // A to-be-hydrated session (empty local cache, not brand-new) shows a transcript skeleton // instead of the "start a chat" hero, so a session WITH server history doesn't flash the empty // state before its records land. Seeded synchronously so the first paint is already the skeleton. + // Did a PREVIOUS mount leave a live chat behind? Read once, during the first render — this mount + // publishes its own chat at commit, so reading it later would always say yes. A run preserved + // across a route change is still streaming into the chat we just re-bound to, and a transcript + // is only persisted on SETTLE, so `initialMessages` is empty mid-stream and hydration would + // otherwise put the skeleton over the run we kept alive (#5724). + const [resumedLiveChat] = useState(() => hasSessionChat(sessionId)) const [isHydrating, setIsHydrating] = useState( - () => initialMessages.length === 0 && !isSessionFresh(sessionId), + () => initialMessages.length === 0 && !isSessionFresh(sessionId) && !resumedLiveChat, ) // Set when server hydration for a KNOWN (non-fresh, uncached) session returns no records — its // durable history was pruned by retention or never persisted. Drives the "history unavailable" @@ -155,14 +168,18 @@ export const useSessionHydration = ({ * record log has grown past what we're rendering. Returns whether it adopted. */ const adoptServerTranscript = useCallback( - (transcript: SessionTranscript | null, {armJump = true} = {}): boolean => { - if (!transcript) return false - const {messages: serverMsgs, recordCount, interactionRows} = transcript + (transcript: unknown, {armJump = true} = {}): boolean => { + if (!isSessionTranscript(transcript)) return false + const {messages: serverMsgs, recordCount, sequenceCursor, interactionRows} = transcript const adopt = shouldAdoptServerTranscript({ - serverRecordCount: recordCount, + serverRecordCount: sequenceCursor ?? recordCount, serverMessageCount: serverMsgs.length, - localMessageCount: messagesRef.current.length, - watermark: recordWatermarkRef.current, + localMessageCount: withoutSharedSenderAcceptanceMessages(messagesRef.current) + .length, + watermark: + sequenceCursor === undefined + ? recordWatermarkRef.current + : sequenceWatermarkRef.current, busy: busyRef.current, // #5942: a card still parked on the user outranks the log — adopting over it // discards whatever they typed into its form. @@ -187,6 +204,11 @@ export const useSessionHydration = ({ // length, that keeps the guard order-independent and stops an older snapshot from // clobbering a newer one. recordWatermarkRef.current = recordCount + if (sequenceCursor !== undefined) sequenceWatermarkRef.current = sequenceCursor + // The log just superseded what this tab was rendering, a failed request of our own + // included. `useChat` holds that error until the next send and the session dot reads + // it, so without this the dot stays red beside a finished turn. + clearRunError() setMessages(serverMsgs) persistMessages({id: sessionId, messages: serverMsgs, recordCount}) return true @@ -202,8 +224,10 @@ export const useSessionHydration = ({ seenIdsRef, restoredIdsRef, recordWatermarkRef, + sequenceWatermarkRef, setMessages, persistMessages, + clearRunError, intent.armJump, intent.stickRef, ], @@ -243,7 +267,7 @@ export const useSessionHydration = ({ useEffect(() => { // A session created brand-new in this browser and not yet run has no backend records — // skip the guaranteed-empty query (cleared on first send; after a reload it re-hydrates). - if (initialMessages.length > 0 || isSessionFresh(sessionId)) { + if (initialMessages.length > 0 || isSessionFresh(sessionId) || resumedLiveChat) { setIsHydrating(false) return } @@ -313,11 +337,7 @@ export const useSessionHydration = ({ }, [sessionId, readLog]) // ── Follow a run happening somewhere else (#5530) ────────────────────────── - // There is no push channel to browsers: the runner publishes every event to Redis, but the only - // consumer is the ingest worker that writes them to the DB. So a session driven from another tab - // or device is followed by re-reading the durable log on a timer, and the adoption guard above - // decides whether anything actually changed. `isRunning` also covers OUR stream, so the atom - // excludes every case where this browser is the one driving (#5844). + // Live frames display immediately; durable polling converges events outside the frame subset. // // The settle stamp the derivation needs is written here rather than inside the package's // `setSessionStatusAtom`: this hook is mounted for the whole life of a session tab, which is @@ -326,6 +346,7 @@ export const useSessionHydration = ({ // `busy` stays as a second guard: it flips on the SEND commit, one commit before the status // atom the derivation reads, so it hides the strip a frame earlier when a local send takes over // a session that genuinely was running elsewhere. + const liveness = useAtomValue(sessionLivenessAtomFamily(sessionId)) const runningElsewhere = useAtomValue(sessionRunningElsewhereAtomFamily(sessionId)) && !busy useEffect(() => { @@ -392,7 +413,6 @@ export const useSessionHydration = ({ // CONCLUSIVE: `records: []` is a confirmed-empty log and stamps; `records: null` is a failed // fetch and never stamps — it retries a bounded burst, then re-arms so a later dependency // change can try again instead of latching the recovery out for the rest of the mount. - const liveness = useAtomValue(sessionLivenessAtomFamily(sessionId)) const strandedCheckRef = useRef<"idle" | "pending" | "done">("idle") useEffect(() => { if (strandedCheckRef.current !== "idle" || isHydrating || busy) return @@ -446,37 +466,66 @@ export const useSessionHydration = ({ const activeSessionId = useAtomValue(activeSessionIdAtomFamily(scopeKey)) const projectId = useAtomValue(projectIdAtom) const revalidateSessionRecords = useSetAtom(revalidateSessionRecordsAtom) - const refreshFromRecords = useCallback(() => { - // Entry check: skip while THIS tab streams (already the live truth, `onFinish` - // revalidates) OR a client-tool settle is already waiting on its resume dispatch — see - // `shouldSkipRecordsRefresh`. - if ( - shouldSkipRecordsRefresh({ - busy: busyRef.current, - pendingResume: !!pendingResumeRef.current, - }) - ) - return - // A tick usually lands inside the records query's stale window, so the shared cache would - // resolve unchanged; invalidate first, then adopt through the SAME guard as every other path. - revalidateSessionRecords(sessionId) - void readLog().then((transcript) => { + const refreshFromRecords = useCallback( + async (transcript?: SessionTranscript): Promise => { + const adoptOrConfirm = (candidate: unknown): boolean => { + if (!isSessionTranscript(candidate)) return false + const candidateWatermark = candidate.sequenceCursor ?? candidate.recordCount + const currentWatermark = + candidate.sequenceCursor === undefined + ? recordWatermarkRef.current + : sequenceWatermarkRef.current + return ( + adoptServerTranscriptRef.current(candidate, {armJump: false}) || + (currentWatermark ?? 0) >= candidateWatermark + ) + } + // Entry check: skip while THIS tab streams (already the live truth, `onFinish` + // revalidates) OR a client-tool settle is already waiting on its resume dispatch — see + // `shouldSkipRecordsRefresh`. + if ( + shouldSkipRecordsRefresh({ + busy: busyRef.current, + pendingResume: !!pendingResumeRef.current, + }) + ) + return false + if (isSessionTranscript(transcript)) { + return adoptOrConfirm(transcript) + } + // A tick usually lands inside the records query's stale window, so the shared cache would + // resolve unchanged; invalidate first, then adopt through the SAME guard as every other path. + revalidateSessionRecords(sessionId) + let refreshed: SessionTranscript | null + try { + refreshed = await readLog() + } catch { + return false + } // Adoption-point recheck: the entry check above only covers the window BEFORE this // fetch started. `loadSessionMessages` is a real network round trip, and a client-tool // settle can land while it's in flight — without re-checking here, that settle arrives - // busy=false/pendingResume=true, passes nothing, and this `.then` still clobbers it - // with the (now stale) transcript it fetched before the settle happened. + // busy=false/pendingResume=true, passes nothing, and this still clobbers it with stale data. if ( shouldSkipRecordsRefresh({ busy: busyRef.current, pendingResume: !!pendingResumeRef.current, }) ) - return + return false // A background catch-up must not yank a reader who scrolled up — as with the poll. - adoptServerTranscriptRef.current(transcript, {armJump: false}) - }) - }, [sessionId, busyRef, pendingResumeRef, revalidateSessionRecords, readLog]) + return adoptOrConfirm(refreshed) + }, + [ + sessionId, + busyRef, + pendingResumeRef, + recordWatermarkRef, + sequenceWatermarkRef, + revalidateSessionRecords, + readLog, + ], + ) // `ready` fires on every connect — each tab activation, each return to the foreground — so it // must not repeat a read the mount is already doing. A change that lands after the subscribe // arrives as `records-changed`, which is never skipped (#6296). @@ -495,11 +544,25 @@ export const useSessionHydration = ({ sessionId, projectId, // #5919 relay; this surface re-reads records on any interaction change. - onInteractionChanged: () => revalidateSessionRecords(sessionId), + onInteractionChanged: () => { + revalidateSessionRecords(sessionId) + }, enabled: activeSessionId === sessionId, onReady: refreshOnReady, - onRecordsChanged: refreshFromRecords, + onRecordsChanged: () => { + void refreshFromRecords() + }, + sharedReaderAdvertised: liveness.sharedReader, }) - return {isHydrating, hydratedEmpty, runningElsewhere} + return { + isHydrating, + hydratedEmpty, + runningElsewhere, + stopStateLoading: liveness.isLoading, + sessionTurnId: liveness.turnId, + stoppingTurnId: liveness.stoppingTurnId, + sharedReaderAdvertised: liveness.sharedReader, + refreshFromRecords, + } } diff --git a/web/oss/src/components/AgentChatSlice/hooks/useSessionRecordsWatch.ts b/web/oss/src/components/AgentChatSlice/hooks/useSessionRecordsWatch.ts index b2487d8f8ef..bf1db11cbce 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useSessionRecordsWatch.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useSessionRecordsWatch.ts @@ -1,4 +1,8 @@ +import {useRef} from "react" + +import {shouldRefreshLegacyObserverLiveness} from "@agenta/chat/model" import {useWatchEventSource} from "@agenta/sessions/watch" +import {useQueryClient} from "@tanstack/react-query" import {getAgentaApiUrl} from "@/oss/lib/helpers/api" import {refreshSession} from "@/oss/lib/helpers/auth/refreshSession" @@ -22,6 +26,7 @@ export const useSessionRecordsWatch = ({ onReady, onRecordsChanged, onInteractionChanged, + sharedReaderAdvertised, }: { sessionId: string projectId?: string | null @@ -31,16 +36,44 @@ export const useSessionRecordsWatch = ({ onReady: () => void onRecordsChanged: () => void onInteractionChanged: () => void + sharedReaderAdvertised: boolean }): void => { + const queryClient = useQueryClient() + const lastLivenessRefreshAtRef = useRef(0) const url = sessionId && projectId ? sessionWatchUrl(sessionId, projectId) : null + const refreshLiveness = () => { + lastLivenessRefreshAtRef.current = Date.now() + void queryClient.invalidateQueries({queryKey: ["session-liveness"]}) + } + const refreshLegacyObserverLiveness = () => { + const now = Date.now() + if ( + !shouldRefreshLegacyObserverLiveness({ + sharedReaderAdvertised, + lastRefreshAt: lastLivenessRefreshAtRef.current, + now, + }) + ) + return + refreshLiveness() + } useWatchEventSource({ url, enabled, refreshSession, on: { ready: onReady, - "records-changed": onRecordsChanged, + "records-changed": () => { + onRecordsChanged() + refreshLegacyObserverLiveness() + }, interaction: onInteractionChanged, + // A session that ends without this tab running it — a Stop from elsewhere, or the + // execution watchdog settling a turn whose runner went silent. The records arrive + // on their own event; this is the half that stops the session still LOOKING alive, + // which otherwise waits out the 15s liveness poll. Mobile already does this + // (web/mobile/src/features/chat/useSessionWatch.ts). + lifecycle: refreshLiveness, }, }) } diff --git a/web/oss/src/components/AgentChatSlice/state/fileLinks.ts b/web/oss/src/components/AgentChatSlice/state/fileLinks.ts index adfab70044d..b897b78d5a0 100644 --- a/web/oss/src/components/AgentChatSlice/state/fileLinks.ts +++ b/web/oss/src/components/AgentChatSlice/state/fileLinks.ts @@ -17,7 +17,7 @@ import {type ReactNode} from "react" import {atom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" export interface ChatFileLinkResolver { /** Render an inline-code span: a compact file link (icon + name, opens Quick Look) when `text` diff --git a/web/oss/src/components/AgentChatSlice/state/liveness.test.ts b/web/oss/src/components/AgentChatSlice/state/liveness.test.ts index d18a8962fd1..a10fe89f260 100644 --- a/web/oss/src/components/AgentChatSlice/state/liveness.test.ts +++ b/web/oss/src/components/AgentChatSlice/state/liveness.test.ts @@ -8,7 +8,7 @@ */ import {describe, expect, it} from "vitest" -import {isRunningElsewhere} from "./liveness" +import {deriveSessionRemoteTurnPresentation, isRunningElsewhere} from "./liveness" /** A session this browser has never run: no settle stamp, so the flag is trusted as-is. */ const neverRanHere = {localStatus: "idle", localSettledAt: undefined} as const @@ -83,3 +83,60 @@ describe("isRunningElsewhere", () => { ).toBe(true) }) }) + +describe("deriveSessionRemoteTurnPresentation", () => { + it.each([ + { + name: "renders activity and no strip for a ready reader", + input: {livenessRunning: true, sharedReaderAdvertised: true, readerReady: true}, + expected: {showActivity: true, showStrip: false}, + }, + { + name: "renders the strip while the reader is not ready", + input: {livenessRunning: true, sharedReaderAdvertised: true, readerReady: false}, + expected: {showActivity: false, showStrip: true}, + }, + { + name: "renders the strip when the feature is off", + input: {livenessRunning: true, sharedReaderAdvertised: false, readerReady: false}, + expected: {showActivity: false, showStrip: true}, + }, + { + name: "does not render the strip in the tab that owns a continuation", + input: { + livenessRunning: true, + sharedReaderAdvertised: true, + readerReady: false, + ownedContinuation: true, + }, + expected: {showActivity: false, showStrip: false}, + }, + ])("$name", ({input, expected}) => { + expect(deriveSessionRemoteTurnPresentation(input)).toEqual(expected) + }) + + it("shows the flag-off observer banner only while session-stream liveness is running", () => { + const input = { + snapshotRunning: true, + sharedReaderAdvertised: false, + readerReady: false, + } + + expect( + deriveSessionRemoteTurnPresentation({...input, livenessRunning: true}).showStrip, + ).toBe(true) + expect( + deriveSessionRemoteTurnPresentation({...input, livenessRunning: false}).showStrip, + ).toBe(false) + }) + + it("hides the banner when the advertised reader is ready", () => { + expect( + deriveSessionRemoteTurnPresentation({ + livenessRunning: true, + sharedReaderAdvertised: true, + readerReady: true, + }).showStrip, + ).toBe(false) + }) +}) diff --git a/web/oss/src/components/AgentChatSlice/state/liveness.ts b/web/oss/src/components/AgentChatSlice/state/liveness.ts index c9b2576b486..fdfa0f5e2c8 100644 --- a/web/oss/src/components/AgentChatSlice/state/liveness.ts +++ b/web/oss/src/components/AgentChatSlice/state/liveness.ts @@ -1,31 +1,21 @@ -import {type SessionRunStatus} from "@agenta/chat/model" +import {deriveRemoteTurnPresentation, type SessionRunStatus} from "@agenta/chat/model" import {sessionLocalSettledAtAtomFamily, sessionStatusAtomFamily} from "@agenta/chat/state" import { deriveSessionLifecycle, deriveStreamNest, + livenessPollInterval, querySessionStreams, type SessionLifecycle, type SessionStream, type SessionStreamNest, } from "@agenta/entities/session" import {atom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import {atomWithQuery} from "jotai-tanstack-query" import {projectIdAtom} from "@/oss/state/project" -/** - * Backend liveness for the project's sessions (cross-device truth). The tab dot reads this to - * reflect a session still running on the backend even when THIS browser isn't streaming it (a - * reopened chat, or a run started on another device). - * - * ONE project-scoped query (`is_alive=true`) backs every dot rather than one fetch per session, so - * N idle tabs cost ONE request, not N — important on cold load (see the request-count budget). Only - * alive streams come back, which is exactly what the dot needs (running/alive vs idle); a session - * absent from the result is dormant/cold/dead/new and simply reads as idle. Kept out of the live - * conversation's way: the fetch is LOW-PRIORITY, polls only WHILE something is alive (empty result - * → stop), and re-checks on tab refocus. - */ +/** One low-priority project query supplies cross-device liveness for every tab dot. */ const aliveStreamsQueryAtom = atomWithQuery((get) => { const projectId = get(projectIdAtom) return { @@ -39,7 +29,7 @@ const aliveStreamsQueryAtom = atomWithQuery((get) => { }), enabled: Boolean(projectId), staleTime: 10_000, - refetchInterval: (query) => ((query.state.data?.length ?? 0) > 0 ? 15_000 : false), + refetchInterval: (query) => livenessPollInterval(query.state.data), refetchOnWindowFocus: true, } }) @@ -57,7 +47,12 @@ export interface SessionLiveness { lifecycle: SessionLifecycle /** The stream nest + derived resumable/reattachable predicates. */ nest: SessionStreamNest + /** Current execution and durable Stop admission marker from the stream row. */ + turnId: string | null + stoppingTurnId: string | null isLoading: boolean + /** Server-advertised temporary frame relay for non-owning readers. */ + sharedReader: boolean } /** @@ -70,7 +65,10 @@ export const sessionLivenessAtomFamily = atomFamily((sessionId: string) => return { lifecycle: deriveSessionLifecycle(stream), nest: deriveStreamNest(stream), + turnId: stream?.turn_id ?? null, + stoppingTurnId: stream?.stopping_turn_id ?? null, isLoading: get(aliveStreamsQueryAtom).isLoading, + sharedReader: Boolean(stream?.capabilities?.shared_reader), } }), ) @@ -127,6 +125,9 @@ export const isRunningElsewhere = ({ return localSettledAt === undefined || livenessUpdatedAt > localSettledAt } +/** Desktop presentation for a remote/shared-path run. The strip is only the disconnected fallback. */ +export const deriveSessionRemoteTurnPresentation = deriveRemoteTurnPresentation + /** `isRunningElsewhere` bound to this session's local status and the shared liveness query. */ export const sessionRunningElsewhereAtomFamily = atomFamily((sessionId: string) => atom((get): boolean => diff --git a/web/oss/src/components/AgentChatSlice/state/projectSessions.ts b/web/oss/src/components/AgentChatSlice/state/projectSessions.ts index ab9404a0543..3dde9433ad6 100644 --- a/web/oss/src/components/AgentChatSlice/state/projectSessions.ts +++ b/web/oss/src/components/AgentChatSlice/state/projectSessions.ts @@ -3,7 +3,7 @@ import {useEffect} from "react" import {type SessionStream} from "@agenta/entities/session" import {sessionListPolicies} from "@agenta/sessions/state" import {atom, useAtomValue, useSetAtom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import {atomWithQuery} from "jotai-tanstack-query" import {isValidUUID} from "@/oss/lib/helpers/validators" diff --git a/web/oss/src/components/AgentChatSlice/state/sessionEphemera.ts b/web/oss/src/components/AgentChatSlice/state/sessionEphemera.ts index e474791ec83..848246eefd2 100644 --- a/web/oss/src/components/AgentChatSlice/state/sessionEphemera.ts +++ b/web/oss/src/components/AgentChatSlice/state/sessionEphemera.ts @@ -1,5 +1,3 @@ -import type {SessionAttachmentResponse} from "@agenta/chat/assets" -import type {StagedUpload} from "@agenta/chat/model" import {clearSessionEphemera as clearSharedSessionEphemera} from "@agenta/chat/state" import type {StateSnapshot} from "react-virtuoso" @@ -16,13 +14,8 @@ import type {StateSnapshot} from "react-virtuoso" * paints the transcript at its true geometry and scroll position in the first frame. */ export const virtStateBySession = new Map() -/** Pending (not yet sent) upload-tray entries per session — same lifetime as the drafts. - * `originFileObj` holds live File blobs; typed by the upload response the tray stores. */ -export const attachmentsBySession = new Map[]>() - /** Drop every ephemeral trace of a permanently deleted session, shared AND desktop-only. */ export const clearSessionEphemera = (sessionId: string) => { clearSharedSessionEphemera(sessionId) virtStateBySession.delete(sessionId) - attachmentsBySession.delete(sessionId) } diff --git a/web/oss/src/components/AgentChatSlice/state/sessions.archivedTabs.test.ts b/web/oss/src/components/AgentChatSlice/state/sessions.archivedTabs.test.ts new file mode 100644 index 00000000000..996b8d84d4c --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/state/sessions.archivedTabs.test.ts @@ -0,0 +1,119 @@ +/** + * Opening an archived session (#6468). + * + * Archiving closes the tab at the write site — locally, and on the next reconcile when it happened + * on another device. The tab list itself must therefore NOT re-filter archived sessions, or a + * session the user deliberately opened from the sessions page is adopted and then dropped before + * it can render. + */ +import {createStore} from "jotai" +import {describe, expect, it} from "vitest" + +import {projectIdAtom} from "@/oss/state/project" + +const { + adoptSessionAtomFamily, + addSessionAtomFamily, + archiveSessionAtomFamily, + reconcileServerSessionsAtomFamily, + sessionsListAtomFamily, + activeSessionIdAtomFamily, + unarchiveSessionAtomFamily, +} = await import("./sessions") + +const SCOPE = "app-archived-tabs" + +const newStore = () => { + const store = createStore() + store.set(projectIdAtom, "project-1") + return store +} + +const tabIds = (store: ReturnType) => + store.get(sessionsListAtomFamily(SCOPE)).map((s) => s.id) + +/** The archived row as the sessions page's server list reports it. */ +const archivedOnServer = (id: string) => ({ + id, + title: `session ${id}`, + lastMessageAt: 1, + archived: true, +}) + +describe("an archived session opened on purpose", () => { + it("becomes a tab, and stays one as the server confirms it is archived", () => { + const store = newStore() + // What "open in playground" does once the panel mounts: adopt by id, sight unseen. + store.set(adoptSessionAtomFamily(SCOPE), {id: "old", title: "session old"}) + expect(tabIds(store)).toEqual(["old"]) + expect(store.get(activeSessionIdAtomFamily(SCOPE))).toBe("old") + + store.set(reconcileServerSessionsAtomFamily(SCOPE), [archivedOnServer("old")]) + expect(tabIds(store)).toEqual(["old"]) + store.set(reconcileServerSessionsAtomFamily(SCOPE), [ + {...archivedOnServer("old"), lastMessageAt: 2}, + ]) + expect(tabIds(store)).toEqual(["old"]) + }) + + it("stays a tab when it was already known to be archived", () => { + const store = newStore() + store.set(reconcileServerSessionsAtomFamily(SCOPE), [archivedOnServer("old")]) + expect(tabIds(store)).toEqual([]) + + store.set(adoptSessionAtomFamily(SCOPE), {id: "old"}) + expect(tabIds(store)).toEqual(["old"]) + store.set(reconcileServerSessionsAtomFamily(SCOPE), [ + {...archivedOnServer("old"), lastMessageAt: 2}, + ]) + expect(tabIds(store)).toEqual(["old"]) + }) +}) + +describe("archiving still closes the tab", () => { + it("closes it when archived here", () => { + const store = newStore() + store.set(addSessionAtomFamily(SCOPE), {id: "mine"}) + store.set(addSessionAtomFamily(SCOPE), {id: "other"}) + store.set(archiveSessionAtomFamily(SCOPE), "mine") + expect(tabIds(store)).toEqual(["other"]) + }) + + it("closes it when archived on another device, and re-points the active tab", () => { + const store = newStore() + store.set(addSessionAtomFamily(SCOPE), {id: "mine"}) + store.set(addSessionAtomFamily(SCOPE), {id: "other"}) + // The server has to have answered first: that is what makes a later `archived: true` a + // change of the server's mind rather than the first thing we ever learned about it. + store.set(reconcileServerSessionsAtomFamily(SCOPE), [ + {id: "mine", lastMessageAt: 1}, + {id: "other", lastMessageAt: 1}, + ]) + store.set(reconcileServerSessionsAtomFamily(SCOPE), [ + {id: "mine", lastMessageAt: 2, archived: true}, + {id: "other", lastMessageAt: 1}, + ]) + expect(tabIds(store)).toEqual(["other"]) + expect(store.get(activeSessionIdAtomFamily(SCOPE))).toBe("other") + }) + + /** + * The reconcile query (`internal-reconciliation`) is not among the keys the session verbs + * invalidate, so an unarchive never supersedes a poll already in flight. That poll answers + * with the pre-unarchive row, and a guard reading the LOCAL flag would read the disagreement + * as a remote archive and close the tab the user just restored. + */ + it("keeps the tab when a stale poll contradicts a local unarchive", () => { + const store = newStore() + store.set(adoptSessionAtomFamily(SCOPE), {id: "old"}) + store.set(reconcileServerSessionsAtomFamily(SCOPE), [archivedOnServer("old")]) + expect(tabIds(store)).toEqual(["old"]) + + store.set(unarchiveSessionAtomFamily(SCOPE), "old") + // In flight before the unarchive landed, so it still reports the session as archived. + store.set(reconcileServerSessionsAtomFamily(SCOPE), [ + {...archivedOnServer("old"), lastMessageAt: 2}, + ]) + expect(tabIds(store)).toEqual(["old"]) + }) +}) diff --git a/web/oss/src/components/AgentChatSlice/state/sessions.teardown.test.ts b/web/oss/src/components/AgentChatSlice/state/sessions.teardown.test.ts new file mode 100644 index 00000000000..c55d470edc8 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/state/sessions.teardown.test.ts @@ -0,0 +1,182 @@ +import {sessionStatusAtomFamily, setSessionStatusAtom} from "@agenta/chat/state" +import {createStore} from "jotai" +import {beforeEach, describe, expect, it, vi} from "vitest" + +import {projectIdAtom} from "@/oss/state/project" + +import { + addSessionAtomFamily, + archiveSessionAtomFamily, + closeSessionAtomFamily, + deleteSessionAtomFamily, + pruneSessionHusksAtomFamily, + activeSessionIdAtomFamily, + openSessionIdsAtomFamily, + reconcileServerSessionsAtomFamily, + resetScopeAtomFamily, + setActiveSessionAtomFamily, +} from "./sessions" + +// The registry owns a live `Chat`; what's under test is the CONTRACT it imposes on this file — that +// every writer which makes a session unreachable also tears its runtime down. A session can die +// while its pane is unmounted (another route, another device), and then these writers are the only +// teardown signal there is. +vi.mock("@agenta/chat/state", async (importOriginal) => ({ + ...(await importOriginal()), + dropSessionChat: vi.fn(), +})) +const {dropSessionChat} = await import("@agenta/chat/state") +const dropped = dropSessionChat as ReturnType + +let seq = 0 +/** A scope of its own per test — these atoms are backed by shared localStorage-ish state. */ +const freshScope = () => `teardown-${(seq += 1)}` + +/** The session storage atoms are project-scoped, so a bare store would hold nothing. */ +const newStore = () => { + const store = createStore() + store.set(projectIdAtom, "project-1") + return store +} + +/** Two open, server-known sessions in one scope (one reconcile, so neither drops the other). */ +const twoRunningSessions = (store: ReturnType, scope: string) => { + const first = store.set(addSessionAtomFamily(scope)) + const second = store.set(addSessionAtomFamily(scope)) + store.set(reconcileServerSessionsAtomFamily(scope), [ + {id: first, title: "S"}, + {id: second, title: "S"}, + ]) + store.set(setSessionStatusAtom, {id: first, status: "running"}) + store.set(setSessionStatusAtom, {id: second, status: "running"}) + return {first, second} +} + +/** A session that is open, server-known, and reported as running by this browser. */ +const runningSession = (store: ReturnType, scope: string) => { + const id = store.set(addSessionAtomFamily(scope)) + store.set(reconcileServerSessionsAtomFamily(scope), [{id, title: "S"}]) + store.set(setSessionStatusAtom, {id, status: "running"}) + return id +} + +beforeEach(() => { + dropped.mockClear() +}) + +describe("session teardown drops the live chat", () => { + it.each([ + ["closing the tab", closeSessionAtomFamily], + ["deleting the session", deleteSessionAtomFamily], + ["archiving the session", archiveSessionAtomFamily], + ])("%s", (_label, writerFamily) => { + const store = newStore() + const scope = freshScope() + const id = runningSession(store, scope) + + store.set(writerFamily(scope), id) + + expect(dropped).toHaveBeenCalledWith(id) + // The chat is gone, so its own `onFinish` can no longer retire the dot — this writer must. + expect(store.get(sessionStatusAtomFamily(id))).toBe("idle") + }) + + it("resetting a scope drops every session in it", () => { + const store = newStore() + const scope = freshScope() + const first = runningSession(store, scope) + const second = runningSession(store, scope) + + store.set(resetScopeAtomFamily(scope)) + + expect(dropped).toHaveBeenCalledWith(first) + expect(dropped).toHaveBeenCalledWith(second) + expect(store.get(sessionStatusAtomFamily(first))).toBe("idle") + expect(store.get(sessionStatusAtomFamily(second))).toBe("idle") + }) + + it("reconciling drops a session deleted on another device", () => { + const store = newStore() + const scope = freshScope() + const id = runningSession(store, scope) + + store.set(reconcileServerSessionsAtomFamily(scope), []) + + expect(dropped).toHaveBeenCalledWith(id) + expect(store.get(sessionStatusAtomFamily(id))).toBe("idle") + }) + + it("reconciling drops a session archived on another device", () => { + const store = newStore() + const scope = freshScope() + const id = runningSession(store, scope) + + // The tab list hides an archived session from here on, so its pane never unmounts again. + store.set(reconcileServerSessionsAtomFamily(scope), [{id, title: "S", archived: true}]) + + expect(dropped).toHaveBeenCalledWith(id) + expect(store.get(sessionStatusAtomFamily(id))).toBe("idle") + }) + + it("reconciling an archive from another device closes its tab and re-points active", () => { + const store = newStore() + const scope = freshScope() + const {first: stays, second: archived} = twoRunningSessions(store, scope) + store.set(setActiveSessionAtomFamily(scope), archived) + expect(store.get(activeSessionIdAtomFamily(scope))).toBe(archived) + + store.set(reconcileServerSessionsAtomFamily(scope), [ + {id: stays, title: "S"}, + {id: archived, title: "S", archived: true}, + ]) + + // Dropping the chat is not enough: the tab list stops showing an archived session, so + // leaving it open/active renders an archived session as the active pane. + expect([...store.get(openSessionIdsAtomFamily(scope))]).not.toContain(archived) + expect(store.get(activeSessionIdAtomFamily(scope))).toBe(stays) + }) + + it("reconciling an archive leaves the active tab alone when it was not the archived one", () => { + const store = newStore() + const scope = freshScope() + const {first: archived, second: active} = twoRunningSessions(store, scope) + store.set(setActiveSessionAtomFamily(scope), active) + + store.set(reconcileServerSessionsAtomFamily(scope), [ + {id: archived, title: "S", archived: true}, + {id: active, title: "S"}, + ]) + + expect(store.get(activeSessionIdAtomFamily(scope))).toBe(active) + expect([...store.get(openSessionIdsAtomFamily(scope))]).toContain(active) + }) + + it("reconciling leaves an already-archived session alone", () => { + const store = newStore() + const scope = freshScope() + const id = runningSession(store, scope) + store.set(reconcileServerSessionsAtomFamily(scope), [{id, title: "S", archived: true}]) + dropped.mockClear() + + // A later poll repeats the same flag; only the transition is a teardown signal. + store.set(reconcileServerSessionsAtomFamily(scope), [ + {id, title: "S", archived: true, lastMessageAt: 1}, + ]) + + expect(dropped).not.toHaveBeenCalled() + }) + + it("pruning husks drops no chat, because a husk never had one", () => { + const store = newStore() + const scope = freshScope() + const id = store.set(addSessionAtomFamily(scope)) + store.set(closeSessionAtomFamily(scope), id) + dropped.mockClear() + + store.set(pruneSessionHusksAtomFamily(scope)) + + // Prune only touches sessions that are already closed, and closing dropped the chat. This + // asserts the invariant that keeps the missing call here correct rather than accidental. + expect(dropped).not.toHaveBeenCalled() + }) +}) diff --git a/web/oss/src/components/AgentChatSlice/state/sessions.ts b/web/oss/src/components/AgentChatSlice/state/sessions.ts index 438bbf0f47a..32e97523513 100644 --- a/web/oss/src/components/AgentChatSlice/state/sessions.ts +++ b/web/oss/src/components/AgentChatSlice/state/sessions.ts @@ -1,4 +1,9 @@ -import {dropSessionMessagesAtom, sessionMessagesAtom} from "@agenta/chat/state" +import { + dropSessionChat, + dropSessionMessagesAtom, + sessionMessagesAtom, + setSessionStatusAtom, +} from "@agenta/chat/state" import {markSessionFresh} from "@agenta/chat/state" import { archiveSessionRemote, @@ -9,14 +14,45 @@ import { import {pinnedSessionIdsAtom} from "@agenta/sessions/state" import {generateId} from "@agenta/shared/utils" import type {UIMessage} from "ai" -import {atom, type Getter} from "jotai" -import {atomFamily, atomWithStorage, createJSONStorage, selectAtom} from "jotai/utils" +import {atom, type Getter, type Setter} from "jotai" +import {atomWithStorage, createJSONStorage, selectAtom} from "jotai/utils" +import {atomFamily} from "jotai-family" import {routerAppIdAtom} from "@/oss/state/app/atoms/fetcher" import {projectIdAtom} from "@/oss/state/project" import {clearSessionEphemera} from "./sessionEphemera" +/** + * This session is gone from this browser: stop its chat and retire its run-state dot. Both are + * needed because either can outlive the pane — the chat when the user navigated away with the tab + * open, and the dot because a torn-down chat's `onFinish` is suppressed and can't retire it itself. + */ +const dropSessionRuntime = (set: Setter, id: string): void => { + dropSessionChat(id) + set(setSessionStatusAtom, {id, status: "idle"}) +} + +/** + * Retire an archived session from this scope's tabs: close its tab, tear its runtime down, and + * re-point the active tab if it was the one archived. Shared by the local archive writer and the + * reconciler's remote-archive branch — archiving on another device has to leave this device in the + * same state as archiving here, or the tab list hides the session while `activeByAppAtom` still + * names it and the pane renders an archived session as active. + */ +const retireArchivedSession = (get: Getter, set: Setter, key: string, id: string): void => { + const open = currentOpenIds(get, key) + const nextOpen = open.filter((x) => x !== id) + if (open.includes(id)) { + set(openIdsByAppAtom, {...get(openIdsByAppAtom), [key]: nextOpen}) + } + dropSessionRuntime(set, id) + const active = get(activeByAppAtom) + if (active[key] === id) { + set(activeByAppAtom, {...active, [key]: nextOpen[0] ?? ""}) + } +} + /** * Multi-session model for the agent chat slice. The playground hosts several parallel agent * conversations as top-level dynamic tabs (no side rail); this holds the session history, which @@ -59,6 +95,11 @@ export interface AgentChatSession { /** Hidden-but-recoverable (server `archived_at`). Filtered out of the main history/tabs and * shown only in the archived view; unarchive clears it. Distinct from `ended` (kill). */ archived?: boolean + /** The server's own last answer for `archived`, written only by the reconciler. `archived` + * alone cannot say who moved it: a local archive/unarchive writes it optimistically, so a + * stale or failed write makes local and remote disagree for reasons that are not a remote + * archive. Undefined until the server has answered once. */ + remoteArchived?: boolean } export const GLOBAL_APP_KEY = "__global__" @@ -237,6 +278,15 @@ export const sessionHistoryAtomFamily = atomFamily((key: string) => }), ) +/** Every scope the local session store holds for this project. Lets a project-wide surface reach + * sessions outside the routed playground, which the per-scope families cannot name on their own. + * Compared by value: `Object.keys` is a fresh array each read, and its consumer feeds an effect. */ +export const sessionScopeKeysAtom = selectAtom( + sessionsByAppAtom, + (byScope) => Object.keys(byScope), + (a, b) => a.length === b.length && a.every((key, i) => key === b[i]), +) + /** Archived sessions for a scope, most-recently-active first. Backs the archived view. */ export const archivedSessionHistoryAtomFamily = atomFamily((key: string) => atom((get) => { @@ -245,8 +295,8 @@ export const archivedSessionHistoryAtomFamily = atomFamily((key: string) => }), ) -/** Sessions shown as tabs for a scope, in tab order. Archived sessions are hidden even if a stale - * open-tab id lingers (e.g. archived on another device — the reconciler flips the flag). +/** Sessions shown as tabs for a scope: the open list, nothing else — archiving closes tabs where + * it is written, so filtering archived here too only hid deliberate opens (#6468). * * Pinned sessions lead (same project-wide pin the rail and sessions page use); a drag that lands * an unpinned tab among the pins is re-sorted back. */ @@ -256,7 +306,7 @@ export const sessionsListAtomFamily = atomFamily((key: string) => const pinned = new Set(get(pinnedSessionIdsAtom)) const open = currentOpenIds(get, key) .map((id) => byId.get(id)) - .filter((s): s is AgentChatSession => Boolean(s) && !s!.archived) + .filter((s): s is AgentChatSession => Boolean(s)) return open.sort((a, b) => Number(pinned.has(b.id)) - Number(pinned.has(a.id))) }), ) @@ -304,6 +354,8 @@ export const closeSessionAtomFamily = atomFamily((key: string) => const open = currentOpenIds(get, key) const nextOpen = open.filter((x) => x !== id) set(openIdsByAppAtom, {...get(openIdsByAppAtom), [key]: nextOpen}) + // Its pane unmounts and releases too, but only if it was ever mounted on this route. + dropSessionRuntime(set, id) const active = get(activeByAppAtom) if (active[key] === id) { @@ -469,6 +521,7 @@ export const deleteSessionAtomFamily = atomFamily((key: string) => set(dropSessionMessagesAtom, [id]) clearSessionEphemera(id) + dropSessionRuntime(set, id) // Tombstone BEFORE the request: it is what keeps the reconciler from re-adopting the row // if the delete fails or an in-flight server list still carries it, and it carries the @@ -510,14 +563,7 @@ export const archiveSessionAtomFamily = atomFamily((key: string) => [key]: (all[key] ?? []).map((s) => (s.id === id ? {...s, archived: true} : s)), }) - const open = currentOpenIds(get, key) - if (open.includes(id)) { - set(openIdsByAppAtom, {...get(openIdsByAppAtom), [key]: open.filter((x) => x !== id)}) - } - const active = get(activeByAppAtom) - if (active[key] === id) { - set(activeByAppAtom, {...active, [key]: open.filter((x) => x !== id)[0] ?? ""}) - } + retireArchivedSession(get, set, key, id) // Ungated and returned for the same reasons as the delete path above (#5543). const projectId = get(projectIdAtom) @@ -606,9 +652,14 @@ export const reconcileServerSessionsAtomFamily = atomFamily((key: string) => for (const s of existing) { const remote = serverById.get(s.id) if (remote) { + // Archived elsewhere, per the SERVER's answer changing (#6468). + if (s.remoteArchived === false && remote.archived) { + retireArchivedSession(get, set, key, s.id) + } merged.push({ ...s, serverKnown: true, + remoteArchived: Boolean(remote.archived), title: remote.title?.trim() ? remote.title : s.title, createdAt: s.createdAt ?? remote.createdAt, // Keep the freshest activity time: a local turn just settled may lead the @@ -636,6 +687,7 @@ export const reconcileServerSessionsAtomFamily = atomFamily((key: string) => serverKnown: true, ended: s.ended, archived: s.archived, + remoteArchived: Boolean(s.archived), }) } @@ -651,7 +703,8 @@ export const reconcileServerSessionsAtomFamily = atomFamily((key: string) => e.lastMessageAt !== m.lastMessageAt || e.serverKnown !== m.serverKnown || e.ended !== m.ended || - e.archived !== m.archived + e.archived !== m.archived || + e.remoteArchived !== m.remoteArchived ) }) if (!changed) return @@ -670,7 +723,10 @@ export const reconcileServerSessionsAtomFamily = atomFamily((key: string) => set(activeByAppAtom, {...active, [key]: nextOpen[0] ?? ""}) } set(dropSessionMessagesAtom, dropped) - for (const id of dropped) clearSessionEphemera(id) + for (const id of dropped) { + clearSessionEphemera(id) + dropSessionRuntime(set, id) + } } }), ) @@ -744,7 +800,10 @@ export const resetScopeAtomFamily = atomFamily((key: string) => set(activeByAppAtom, next) } set(dropSessionMessagesAtom, ids) - for (const id of ids) clearSessionEphemera(id) + for (const id of ids) { + clearSessionEphemera(id) + dropSessionRuntime(set, id) + } }), ) diff --git a/web/oss/src/components/AgentChatSlice/state/turnCaptures.ts b/web/oss/src/components/AgentChatSlice/state/turnCaptures.ts index 98321aaa083..7e0b6be8725 100644 --- a/web/oss/src/components/AgentChatSlice/state/turnCaptures.ts +++ b/web/oss/src/components/AgentChatSlice/state/turnCaptures.ts @@ -1,6 +1,6 @@ import {appendCapped, type TurnRequestCapture} from "@agenta/playground" import {atom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" /** Keep the last N turns' captures per session (ephemeral; debugging surface, not persisted). */ const MAX_TURNS = 20 diff --git a/web/oss/src/components/DrillInView/OSSdrillInUIProvider.tsx b/web/oss/src/components/DrillInView/OSSdrillInUIProvider.tsx index 378130834b8..3fc14feadd2 100644 --- a/web/oss/src/components/DrillInView/OSSdrillInUIProvider.tsx +++ b/web/oss/src/components/DrillInView/OSSdrillInUIProvider.tsx @@ -45,6 +45,7 @@ import {SharedEditor} from "@agenta/ui/shared-editor" import {getDefaultStore, useSetAtom} from "jotai" import {useLLMProviderConfig} from "@/oss/hooks/useLLMProviderConfig" +import {useProjectPermissions} from "@/oss/hooks/useProjectPermissions" import useURL from "@/oss/hooks/useURL" import {isDemo} from "@/oss/lib/helpers/utils" @@ -94,6 +95,11 @@ function useGatewayToolsCatalogActions(integrationKey: string) { */ export function OSSdrillInUIProvider({children}: OSSdrillInUIProviderProps) { const {llmProviderConfig, overlay: llmProviderOverlay} = useLLMProviderConfig() + const {hasPermission} = useProjectPermissions() + const permissions = useMemo( + () => ({canEditSecrets: hasPermission("edit_secret")}), + [hasPermission], + ) const toolsEnabled = isToolsEnabled() const baseWorkflowReference = useWorkflowReferenceBridge() const {baseAppURL} = useURL() @@ -119,10 +125,11 @@ export function OSSdrillInUIProvider({children}: OSSdrillInUIProviderProps) { workflowReference, openTrace, deployment, + permissions, // Rich concrete components vs the context's index-signature slots (pre-existing gap) }) as DrillInUIComponents, // openTrace is a module-level const (stable) — no dep needed. - [llmProviderConfig, workflowReference, deployment], + [llmProviderConfig, workflowReference, deployment, permissions], ) if (!toolsEnabled) { @@ -140,6 +147,7 @@ export function OSSdrillInUIProvider({children}: OSSdrillInUIProviderProps) { llmProviderConfig={llmProviderConfig} workflowReference={workflowReference} deployment={deployment} + permissions={permissions} > {children} @@ -153,11 +161,13 @@ function GatewayToolsEnabledProvider({ llmProviderConfig, workflowReference, deployment, + permissions, }: { children: ReactNode llmProviderConfig: ReturnType["llmProviderConfig"] workflowReference: WorkflowReferenceBridge deployment: {isCloud: boolean} + permissions: {canEditSecrets: boolean} }) { const {connections, isLoading, error} = useToolConnectionsQuery() const setCatalogDrawerOpen = useSetAtom(toolCatalogDrawerOpenAtom) @@ -216,9 +226,10 @@ function GatewayToolsEnabledProvider({ workflowReference, openTrace, deployment, + permissions, // Rich concrete components vs the context's index-signature slots (pre-existing gap) }) as DrillInUIComponents, - [llmProviderConfig, gatewayTools, workflowReference, deployment], + [llmProviderConfig, gatewayTools, workflowReference, deployment, permissions], ) return {children} diff --git a/web/oss/src/components/Drives/chatFileRefs.tsx b/web/oss/src/components/Drives/chatFileRefs.tsx index bc798048a65..6be39cadf07 100644 --- a/web/oss/src/components/Drives/chatFileRefs.tsx +++ b/web/oss/src/components/Drives/chatFileRefs.tsx @@ -28,7 +28,7 @@ import { import {DriveFileInlineRef} from "@agenta/entity-ui/drive" import {useDriveArtifactId, useDriveSessionId} from "@agenta/entity-ui/drive" import {atom, useAtomValue} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" /** A span that could NAME a file; strip a leading `./` and require a path-ish shape: a slash, or a * letter-led trailing extension (`.ts`, `.tar.gz`). A bare `/[./]/` matched any dotted token — diff --git a/web/oss/src/components/EvalRunDetails/atoms/annotations.ts b/web/oss/src/components/EvalRunDetails/atoms/annotations.ts index da83fc9c780..8a19d7da3da 100644 --- a/web/oss/src/components/EvalRunDetails/atoms/annotations.ts +++ b/web/oss/src/components/EvalRunDetails/atoms/annotations.ts @@ -3,7 +3,7 @@ import type {AnnotationDto} from "@agenta/entities/annotation/dto" import {createBatchFetcher, type BatchFetcher} from "@agenta/shared/utils" import {uuidToSpanId, uuidToTraceId} from "@agenta/shared/utils" import {atom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import {atomWithQuery} from "jotai-tanstack-query" import axios from "@/oss/lib/api/assets/axiosConfig" diff --git a/web/oss/src/components/EvalRunDetails/atoms/compare.ts b/web/oss/src/components/EvalRunDetails/atoms/compare.ts index cc20dd7f511..32c845f76d4 100644 --- a/web/oss/src/components/EvalRunDetails/atoms/compare.ts +++ b/web/oss/src/components/EvalRunDetails/atoms/compare.ts @@ -1,5 +1,5 @@ import {atom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import {buildRunIndex, type RunIndex} from "@/oss/lib/evaluations/buildRunIndex" diff --git a/web/oss/src/components/EvalRunDetails/atoms/invocationTraceSummary.ts b/web/oss/src/components/EvalRunDetails/atoms/invocationTraceSummary.ts index 5e3c20f2811..30ca631de04 100644 --- a/web/oss/src/components/EvalRunDetails/atoms/invocationTraceSummary.ts +++ b/web/oss/src/components/EvalRunDetails/atoms/invocationTraceSummary.ts @@ -1,5 +1,5 @@ import {atom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import type {TraceData, TraceNode} from "@/oss/lib/evaluations" diff --git a/web/oss/src/components/EvalRunDetails/atoms/metrics.ts b/web/oss/src/components/EvalRunDetails/atoms/metrics.ts index 75fdf52a7a5..d5708b02dae 100644 --- a/web/oss/src/components/EvalRunDetails/atoms/metrics.ts +++ b/web/oss/src/components/EvalRunDetails/atoms/metrics.ts @@ -1,7 +1,8 @@ import {createBatchFetcher, type BatchFetcher} from "@agenta/shared/utils" import deepEqual from "fast-deep-equal" import {atom} from "jotai" -import {atomFamily, selectAtom} from "jotai/utils" +import {selectAtom} from "jotai/utils" +import {atomFamily} from "jotai-family" import {atomWithQuery} from "jotai-tanstack-query" import axios from "@/oss/lib/api/assets/axiosConfig" diff --git a/web/oss/src/components/EvalRunDetails/atoms/query.ts b/web/oss/src/components/EvalRunDetails/atoms/query.ts index 167924b15b7..c9dd25cb772 100644 --- a/web/oss/src/components/EvalRunDetails/atoms/query.ts +++ b/web/oss/src/components/EvalRunDetails/atoms/query.ts @@ -1,5 +1,6 @@ import {createBatchFetcher} from "@agenta/shared/utils" -import {atomFamily, selectAtom} from "jotai/utils" +import {selectAtom} from "jotai/utils" +import {atomFamily} from "jotai-family" import {atomWithQuery} from "jotai-tanstack-query" import axios from "@/oss/lib/api/assets/axiosConfig" diff --git a/web/oss/src/components/EvalRunDetails/atoms/references.ts b/web/oss/src/components/EvalRunDetails/atoms/references.ts index e5de2cd7c81..431b096283f 100644 --- a/web/oss/src/components/EvalRunDetails/atoms/references.ts +++ b/web/oss/src/components/EvalRunDetails/atoms/references.ts @@ -10,7 +10,7 @@ */ import {atom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import { appReferenceAtomFamily, diff --git a/web/oss/src/components/EvalRunDetails/atoms/runDerived.ts b/web/oss/src/components/EvalRunDetails/atoms/runDerived.ts index 57bf258d382..2b312e239b5 100644 --- a/web/oss/src/components/EvalRunDetails/atoms/runDerived.ts +++ b/web/oss/src/components/EvalRunDetails/atoms/runDerived.ts @@ -1,5 +1,6 @@ import {atom} from "jotai" -import {atomFamily, selectAtom} from "jotai/utils" +import {selectAtom} from "jotai/utils" +import {atomFamily} from "jotai-family" import {activePreviewRunIdAtom} from "./run" import {evaluationRunQueryAtomFamily} from "./table/run" diff --git a/web/oss/src/components/EvalRunDetails/atoms/runMetrics.ts b/web/oss/src/components/EvalRunDetails/atoms/runMetrics.ts index 77538f1cf99..e958c20b1db 100644 --- a/web/oss/src/components/EvalRunDetails/atoms/runMetrics.ts +++ b/web/oss/src/components/EvalRunDetails/atoms/runMetrics.ts @@ -1,6 +1,7 @@ import {createBatchFetcher} from "@agenta/shared/utils" import {atom, Atom} from "jotai" -import {atomFamily, loadable} from "jotai/utils" +import {loadable} from "jotai/utils" +import {atomFamily} from "jotai-family" import {atomWithQuery} from "jotai-tanstack-query" import {evaluationRunQueryAtomFamily} from "@/oss/components/EvalRunDetails/atoms/table/run" diff --git a/web/oss/src/components/EvalRunDetails/atoms/scenarioColumnValues.ts b/web/oss/src/components/EvalRunDetails/atoms/scenarioColumnValues.ts index ad411cbf64e..8bd30450c21 100644 --- a/web/oss/src/components/EvalRunDetails/atoms/scenarioColumnValues.ts +++ b/web/oss/src/components/EvalRunDetails/atoms/scenarioColumnValues.ts @@ -1,7 +1,8 @@ import type {AnnotationDto} from "@agenta/entities/annotation/dto" import {formatMetricDisplay} from "@agenta/ui/cell-renderers" import {atom} from "jotai" -import {atomFamily, selectAtom} from "jotai/utils" +import {selectAtom} from "jotai/utils" +import {atomFamily} from "jotai-family" import type {IStepResponse} from "@/oss/lib/evaluations" import type {PreviewTestCase} from "@/oss/lib/Types" diff --git a/web/oss/src/components/EvalRunDetails/atoms/scenarioSteps.ts b/web/oss/src/components/EvalRunDetails/atoms/scenarioSteps.ts index 62f4e6d1713..350cae02552 100644 --- a/web/oss/src/components/EvalRunDetails/atoms/scenarioSteps.ts +++ b/web/oss/src/components/EvalRunDetails/atoms/scenarioSteps.ts @@ -1,6 +1,6 @@ import {createBatchFetcher, type BatchFetcher} from "@agenta/shared/utils" import {atom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import {atomWithQuery} from "jotai-tanstack-query" import axios from "@/oss/lib/api/assets/axiosConfig" diff --git a/web/oss/src/components/EvalRunDetails/atoms/scenarioTestcase.ts b/web/oss/src/components/EvalRunDetails/atoms/scenarioTestcase.ts index 7bbf8b45054..a3b301bac37 100644 --- a/web/oss/src/components/EvalRunDetails/atoms/scenarioTestcase.ts +++ b/web/oss/src/components/EvalRunDetails/atoms/scenarioTestcase.ts @@ -9,7 +9,8 @@ */ import {atom} from "jotai" -import {atomFamily, selectAtom} from "jotai/utils" +import {selectAtom} from "jotai/utils" +import {atomFamily} from "jotai-family" import {testcase} from "@/oss/state/entities/testcase" import type {FlattenedTestcase} from "@/oss/state/entities/testcase/schema" diff --git a/web/oss/src/components/EvalRunDetails/atoms/table/columnAccess.ts b/web/oss/src/components/EvalRunDetails/atoms/table/columnAccess.ts index b9422b4b34a..430af90f56b 100644 --- a/web/oss/src/components/EvalRunDetails/atoms/table/columnAccess.ts +++ b/web/oss/src/components/EvalRunDetails/atoms/table/columnAccess.ts @@ -1,5 +1,5 @@ import {atom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import type {RunIndex} from "@/oss/lib/evaluations/buildRunIndex" diff --git a/web/oss/src/components/EvalRunDetails/atoms/table/columns.ts b/web/oss/src/components/EvalRunDetails/atoms/table/columns.ts index fae571407a8..c7db46bc249 100644 --- a/web/oss/src/components/EvalRunDetails/atoms/table/columns.ts +++ b/web/oss/src/components/EvalRunDetails/atoms/table/columns.ts @@ -1,6 +1,6 @@ import {extractMetrics} from "@agenta/entities/workflow" import {atom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import type {StepMeta} from "@/oss/lib/evaluations/buildRunIndex" import {canonicalizeMetricKey} from "@/oss/lib/metricUtils" diff --git a/web/oss/src/components/EvalRunDetails/atoms/table/index.ts b/web/oss/src/components/EvalRunDetails/atoms/table/index.ts index 1a8b9696700..bda8adf87f0 100644 --- a/web/oss/src/components/EvalRunDetails/atoms/table/index.ts +++ b/web/oss/src/components/EvalRunDetails/atoms/table/index.ts @@ -6,4 +6,5 @@ export * from "./run" export * from "./scenarios" export * from "./state" export * from "./types" +export type {MetricColumnDefinition} from "./types" export * from "./testcases" diff --git a/web/oss/src/components/EvalRunDetails/atoms/table/run.ts b/web/oss/src/components/EvalRunDetails/atoms/table/run.ts index 692c5470760..d1111d45888 100644 --- a/web/oss/src/components/EvalRunDetails/atoms/table/run.ts +++ b/web/oss/src/components/EvalRunDetails/atoms/table/run.ts @@ -1,6 +1,7 @@ import type {EvaluationRun} from "@agenta/entities/evaluationRun" import {fetchWorkflowsBatch} from "@agenta/entities/workflow" -import {atomFamily, selectAtom} from "jotai/utils" +import {selectAtom} from "jotai/utils" +import {atomFamily} from "jotai-family" import {atomWithQuery} from "jotai-tanstack-query" import axios from "@/oss/lib/api/assets/axiosConfig" diff --git a/web/oss/src/components/EvalRunDetails/atoms/table/scenarios.ts b/web/oss/src/components/EvalRunDetails/atoms/table/scenarios.ts index f1a8a619f97..78271245334 100644 --- a/web/oss/src/components/EvalRunDetails/atoms/table/scenarios.ts +++ b/web/oss/src/components/EvalRunDetails/atoms/table/scenarios.ts @@ -1,5 +1,5 @@ import {atom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import {atomWithQuery} from "jotai-tanstack-query" import axios from "@/oss/lib/api/assets/axiosConfig" diff --git a/web/oss/src/components/EvalRunDetails/atoms/table/state.ts b/web/oss/src/components/EvalRunDetails/atoms/table/state.ts index c8a0adee827..24005869c72 100644 --- a/web/oss/src/components/EvalRunDetails/atoms/table/state.ts +++ b/web/oss/src/components/EvalRunDetails/atoms/table/state.ts @@ -1,5 +1,5 @@ import {atom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import {DEFAULT_SCENARIO_PAGE_SIZE} from "./constants" diff --git a/web/oss/src/components/EvalRunDetails/atoms/table/testcases.ts b/web/oss/src/components/EvalRunDetails/atoms/table/testcases.ts index d019423dddc..157bb544834 100644 --- a/web/oss/src/components/EvalRunDetails/atoms/table/testcases.ts +++ b/web/oss/src/components/EvalRunDetails/atoms/table/testcases.ts @@ -1,6 +1,7 @@ import {createBatchFetcher, type BatchFetcher} from "@agenta/shared/utils" import {atom} from "jotai" -import {atomFamily, selectAtom} from "jotai/utils" +import {selectAtom} from "jotai/utils" +import {atomFamily} from "jotai-family" import {atomWithQuery} from "jotai-tanstack-query" import axios from "@/oss/lib/api/assets/axiosConfig" diff --git a/web/oss/src/components/EvalRunDetails/atoms/testsetDetails.ts b/web/oss/src/components/EvalRunDetails/atoms/testsetDetails.ts index e770f1bcb71..f1d35676b59 100644 --- a/web/oss/src/components/EvalRunDetails/atoms/testsetDetails.ts +++ b/web/oss/src/components/EvalRunDetails/atoms/testsetDetails.ts @@ -1,4 +1,4 @@ -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import {atomWithQuery} from "jotai-tanstack-query" import axios from "@/oss/lib/api/assets/axiosConfig" diff --git a/web/oss/src/components/EvalRunDetails/atoms/traces.ts b/web/oss/src/components/EvalRunDetails/atoms/traces.ts index 944bbc9cdda..311dc70a883 100644 --- a/web/oss/src/components/EvalRunDetails/atoms/traces.ts +++ b/web/oss/src/components/EvalRunDetails/atoms/traces.ts @@ -5,7 +5,8 @@ import { } from "@agenta/entities/trace" import type {TracesResponse} from "@agenta/entities/trace" import {uuidToTraceId} from "@agenta/shared/utils" -import {atomFamily, selectAtom} from "jotai/utils" +import {selectAtom} from "jotai/utils" +import {atomFamily} from "jotai-family" import type {TraceData, TraceNode, TraceTree} from "@/oss/lib/evaluations" import type {TraceSpanNode} from "@/oss/services/tracing/types" diff --git a/web/oss/src/components/EvalRunDetails/atoms/variantConfig.ts b/web/oss/src/components/EvalRunDetails/atoms/variantConfig.ts index 8f6eb49b6b3..e001e8ad3ce 100644 --- a/web/oss/src/components/EvalRunDetails/atoms/variantConfig.ts +++ b/web/oss/src/components/EvalRunDetails/atoms/variantConfig.ts @@ -1,5 +1,5 @@ import {fetchWorkflowRevisionById} from "@agenta/entities/workflow" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import {atomWithQuery} from "jotai-tanstack-query" import {effectiveProjectIdAtom} from "./run" diff --git a/web/oss/src/components/EvalRunDetails/components/views/ConfigurationView/index.tsx b/web/oss/src/components/EvalRunDetails/components/views/ConfigurationView/index.tsx index 35b0589e903..699f26da88e 100644 --- a/web/oss/src/components/EvalRunDetails/components/views/ConfigurationView/index.tsx +++ b/web/oss/src/components/EvalRunDetails/components/views/ConfigurationView/index.tsx @@ -2,7 +2,7 @@ import {memo, useMemo, useState} from "react" import {Segmented, Typography} from "antd" import {atom, useAtomValue} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import {compareRunIdsAtom, getComparisonColor} from "../../../atoms/compare" import { diff --git a/web/oss/src/components/EvalRunDetails/components/views/SingleScenarioViewerPOC/ScenarioAnnotationPanel/atoms.ts b/web/oss/src/components/EvalRunDetails/components/views/SingleScenarioViewerPOC/ScenarioAnnotationPanel/atoms.ts index a4ac89c6241..f24303af817 100644 --- a/web/oss/src/components/EvalRunDetails/components/views/SingleScenarioViewerPOC/ScenarioAnnotationPanel/atoms.ts +++ b/web/oss/src/components/EvalRunDetails/components/views/SingleScenarioViewerPOC/ScenarioAnnotationPanel/atoms.ts @@ -2,7 +2,7 @@ import {resolveOutputSchema, resolveOutputSchemaProperties} from "@agenta/entiti import {uuidToSpanId} from "@agenta/shared/utils" import deepEqual from "fast-deep-equal" import {atom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import type { AnnotationDto, diff --git a/web/oss/src/components/EvalRunDetails/etl/scenarioFilterState.ts b/web/oss/src/components/EvalRunDetails/etl/scenarioFilterState.ts index a3438fefce6..a45e3b80ba9 100644 --- a/web/oss/src/components/EvalRunDetails/etl/scenarioFilterState.ts +++ b/web/oss/src/components/EvalRunDetails/etl/scenarioFilterState.ts @@ -10,7 +10,7 @@ import type {PredicateGroup, RowPredicate} from "@agenta/entities/evaluationRun/etl" import {atom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" const EMPTY_FILTER: PredicateGroup = {op: "and", conditions: []} diff --git a/web/oss/src/components/EvalRunDetails/evaluationPreviewTableStore.ts b/web/oss/src/components/EvalRunDetails/evaluationPreviewTableStore.ts index 6135699552e..94fac449ac4 100644 --- a/web/oss/src/components/EvalRunDetails/evaluationPreviewTableStore.ts +++ b/web/oss/src/components/EvalRunDetails/evaluationPreviewTableStore.ts @@ -6,7 +6,7 @@ import { type InfiniteDatasetStore, } from "@agenta/ui/table" import {atom, useAtom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import {effectiveProjectIdAtom} from "./atoms/run" import type {WindowingState, EvaluationScenarioRow} from "./atoms/table" diff --git a/web/oss/src/components/EvalRunDetails/hooks/useScenarioStepsSelectors.ts b/web/oss/src/components/EvalRunDetails/hooks/useScenarioStepsSelectors.ts index 792bed131d1..dbd0cda632c 100644 --- a/web/oss/src/components/EvalRunDetails/hooks/useScenarioStepsSelectors.ts +++ b/web/oss/src/components/EvalRunDetails/hooks/useScenarioStepsSelectors.ts @@ -2,7 +2,7 @@ import {useMemo} from "react" import {useAtomValue} from "jotai" import {atom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import type {IStepResponse} from "@/oss/lib/evaluations" diff --git a/web/oss/src/components/EvalRunDetails/state/evalType.ts b/web/oss/src/components/EvalRunDetails/state/evalType.ts index 2c09d27b5a2..aeec4185b58 100644 --- a/web/oss/src/components/EvalRunDetails/state/evalType.ts +++ b/web/oss/src/components/EvalRunDetails/state/evalType.ts @@ -1,5 +1,5 @@ import {atom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import { deriveEvaluationKind, diff --git a/web/oss/src/components/EvaluationRunsTablePOC/atoms/runSummaries.ts b/web/oss/src/components/EvaluationRunsTablePOC/atoms/runSummaries.ts index 4fb1d53a2ed..20c06dd2762 100644 --- a/web/oss/src/components/EvaluationRunsTablePOC/atoms/runSummaries.ts +++ b/web/oss/src/components/EvaluationRunsTablePOC/atoms/runSummaries.ts @@ -1,4 +1,4 @@ -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import {atomWithQuery} from "jotai-tanstack-query" import {snakeToCamelCaseKeys} from "@/oss/lib/helpers/casing" diff --git a/web/oss/src/components/EvaluationRunsTablePOC/atoms/tableStore.ts b/web/oss/src/components/EvaluationRunsTablePOC/atoms/tableStore.ts index f71dac8842c..4c5c17eebc5 100644 --- a/web/oss/src/components/EvaluationRunsTablePOC/atoms/tableStore.ts +++ b/web/oss/src/components/EvaluationRunsTablePOC/atoms/tableStore.ts @@ -2,8 +2,8 @@ import {createInfiniteDatasetStore} from "@agenta/ui/table" import type {WindowingState} from "@agenta/ui/table" import {atom} from "jotai" import type {PrimitiveAtom} from "jotai" -import {atomFamily} from "jotai/utils" import {atomWithStorage} from "jotai/vanilla/utils" +import {atomFamily} from "jotai-family" import type { EvaluationRunApiRow, diff --git a/web/oss/src/components/Evaluators/Table/assets/evaluatorColumns.tsx b/web/oss/src/components/Evaluators/Table/assets/evaluatorColumns.tsx index a7b8253e815..9129460ac34 100644 --- a/web/oss/src/components/Evaluators/Table/assets/evaluatorColumns.tsx +++ b/web/oss/src/components/Evaluators/Table/assets/evaluatorColumns.tsx @@ -25,7 +25,7 @@ import { } from "@phosphor-icons/react" import {Tag, Typography} from "antd" import {atom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import type {EvaluatorCategory} from "../../assets/types" import type {EvaluatorTableRow} from "../../store/evaluatorsPaginatedStore" diff --git a/web/oss/src/components/Layout/Layout.tsx b/web/oss/src/components/Layout/Layout.tsx index 812bd79a93f..67246f5eb41 100644 --- a/web/oss/src/components/Layout/Layout.tsx +++ b/web/oss/src/components/Layout/Layout.tsx @@ -1,9 +1,9 @@ import {memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode} from "react" +import {NotFoundScreen} from "@agenta/auth-ui" import {workflowLatestRevisionQueryAtomFamily} from "@agenta/entities/workflow" import {SETTINGS_SIDEBAR_SCOPE_ID} from "@agenta/navigation" import {ProjectWatch} from "@agenta/sessions/watch" -import AppMessageContext from "@agenta/ui/app-message" import {useVisualViewportHeight} from "@agenta/ui/hooks" import {ConfigProvider, Layout, Modal, theme} from "antd" import clsx from "clsx" @@ -20,7 +20,7 @@ import {routerAppIdAtom} from "@/oss/state/app/atoms/fetcher" import {appStateSnapshotAtom, requestNavigationAtom} from "@/oss/state/appState" import {layoutFullHeightRequestAtom} from "@/oss/state/layout/fullHeight" import {cacheWorkspaceOrgPair} from "@/oss/state/org/selectors/org" -import {getProjectValues, useProjectData} from "@/oss/state/project" +import {getProjectValues, routeContextAtom, useProjectData} from "@/oss/state/project" import { cacheLastUsedProjectId, demoReturnHintDismissedAtom, @@ -431,7 +431,10 @@ const App: React.FC = ({children}) => { // From the router, not the flags atom: that atom is keyed on the parsed URL and goes // stale on the hop off a 404. /404 renders bare like the auth screens. const router = useRouter() - const isBareRoute = isAuthRoute || router.pathname === "/404" + // An address naming a workspace or project that does not exist is a 404, and it is owned here + // rather than per page: every route under /w carries those ids, so one guard covers them all. + const {isNotFound: isRouteNotFound} = useAtomValue(routeContextAtom) + const isBareRoute = isAuthRoute || router.pathname === "/404" || isRouteNotFound // One owner for the whole app. A phone keyboard opens over the page, so every frame sized // against the layout viewport hides its bottom edge behind it. This publishes the visible @@ -445,11 +448,14 @@ const App: React.FC = ({children}) => { return ( <> - {typeof window === "undefined" ? null : isBareRoute ? ( - {children} + {isRouteNotFound ? ( + router.back()} path={router.asPath} /> + ) : ( + children + )} {contextHolder} diff --git a/web/oss/src/components/Playground/Components/AgentRevisionSelector/index.tsx b/web/oss/src/components/Playground/Components/AgentRevisionSelector/index.tsx index 4906f95d23e..a8e093f166d 100644 --- a/web/oss/src/components/Playground/Components/AgentRevisionSelector/index.tsx +++ b/web/oss/src/components/Playground/Components/AgentRevisionSelector/index.tsx @@ -1,47 +1,29 @@ -import {useCallback, useEffect, useMemo} from "react" +import {useEffect} from "react" import {isLocalDraftId} from "@agenta/entities/shared" import {workflowMolecule} from "@agenta/entities/workflow" -import {createWorkflowRevisionAdapter} from "@agenta/entity-ui/selection" -import {playgroundController} from "@agenta/playground" import {registerAgentAutoCommitHandler} from "@agenta/playground/state" import {AgentRevisionStatus} from "@agenta/playground-ui/agent-page-header" -import {useAtomValue, useSetAtom} from "jotai" -import dynamic from "next/dynamic" - -import {routerAppIdAtom} from "@/oss/state/app/atoms/fetcher" +import {useAtomValue} from "jotai" import {useCommitHostAdapter} from "../Modals/CommitVariantChangesModal/assets/useCommitHostAdapter" -const SelectVariant = dynamic(() => import("../Menus/SelectVariant"), {ssr: false}) - /** - * The agent playground's revision selector — the borderless "variant ⌄" picker plus a compact - * `v{n} ● Draft/Saved` status. Lifted out of the config-panel header (PlaygroundVariantConfigHeader) - * so the page header can host it next to the agent's name. Variant-scoped: it derives everything - * from `variantId`, so it stays in sync wherever it's rendered. + * The agent playground's header control — a `v{n} ⌄ ● Draft/Saved` chip that opens version + * history. Variant-scoped: it derives everything from `variantId`, so it stays in sync wherever + * it's rendered. + * + * No variant picker: an agent is edited as one thing, and the picker that used to sit here offered + * variant switching the agent surface has no use for. Other workflow kinds keep `SelectVariant` in + * the playground header. + * + * The `vN` chip IS the drawer trigger — a separate "Versions" button beside it said the same + * thing twice. Mobile's bar works the same way. */ const AgentRevisionSelector = ({variantId}: {variantId: string}) => { - // Project-scoped playground (no app in URL) browses all workflows; app-scoped stays scoped. - const appId = useAtomValue(routerAppIdAtom) - const isProjectScoped = !appId - const runnableData = useAtomValue(workflowMolecule.selectors.data(variantId || "")) const isLocalDraftVariant = variantId ? isLocalDraftId(variantId) : false - - const _variantId = runnableData?.id ?? null - - // App browse picker (project-scoped only) — skip-variant, non-evaluator. - const appOnlyAdapter = useMemo( - () => - createWorkflowRevisionAdapter({ - skipVariantLevel: true, - excludeRevisionZero: true, - flags: {is_evaluator: false, is_feedback: false}, - parentLabel: "Application", - }), - [], - ) + const workflowId = runnableData?.workflow_id ?? null // An auto-commit is still a commit, so it owes this app the same out-of-band work a manual // one did: the registry and evaluator tables live outside the entities layer and go stale @@ -57,29 +39,9 @@ const AgentRevisionSelector = ({variantId}: {variantId: string}) => { [onAfterCommit, onCommitted], ) - const switchEntity = useSetAtom(playgroundController.actions.switchEntity) - const handleSwitchVariant = useCallback( - (newVariantId: string) => { - switchEntity({currentEntityId: variantId || "", newEntityId: newVariantId}) - }, - [switchEntity, variantId], - ) - if (!variantId || isLocalDraftVariant) return null - return ( -
- handleSwitchVariant(value)} - value={_variantId ?? undefined} - borderlessTrigger - /> - -
- ) + return } export default AgentRevisionSelector diff --git a/web/oss/src/components/Playground/Components/Modals/RefinePromptModal/store/refinePromptStore.ts b/web/oss/src/components/Playground/Components/Modals/RefinePromptModal/store/refinePromptStore.ts index 34a971bb274..e39c5550dfb 100644 --- a/web/oss/src/components/Playground/Components/Modals/RefinePromptModal/store/refinePromptStore.ts +++ b/web/oss/src/components/Playground/Components/Modals/RefinePromptModal/store/refinePromptStore.ts @@ -9,7 +9,7 @@ */ import {atom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import type {PromptTemplate, RefinementIteration} from "../types" diff --git a/web/oss/src/components/Playground/PlaygroundTokenPath/chainContext.ts b/web/oss/src/components/Playground/PlaygroundTokenPath/chainContext.ts index d58d019ed7a..0080914635c 100644 --- a/web/oss/src/components/Playground/PlaygroundTokenPath/chainContext.ts +++ b/web/oss/src/components/Playground/PlaygroundTokenPath/chainContext.ts @@ -20,7 +20,7 @@ import {outputConnectionController} from "@agenta/playground" import {playgroundNodesAtom} from "@agenta/playground/state" import {atom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" export interface NodeChainContext { /** Envelope slots this editor's prompt can legally reference. */ diff --git a/web/oss/src/components/References/atoms/metricBlueprint.ts b/web/oss/src/components/References/atoms/metricBlueprint.ts index d0e9c51853f..738a364cc25 100644 --- a/web/oss/src/components/References/atoms/metricBlueprint.ts +++ b/web/oss/src/components/References/atoms/metricBlueprint.ts @@ -1,5 +1,5 @@ import {atom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import type {RunMetricDescriptor} from "@/oss/components/EvaluationRunsTablePOC/types/runMetrics" diff --git a/web/oss/src/components/References/atoms/resolvedMetricLabels.ts b/web/oss/src/components/References/atoms/resolvedMetricLabels.ts index cefa90843fd..370dfa385b2 100644 --- a/web/oss/src/components/References/atoms/resolvedMetricLabels.ts +++ b/web/oss/src/components/References/atoms/resolvedMetricLabels.ts @@ -1,5 +1,5 @@ import {atom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" export const resolvedMetricLabelsAtomFamily = atomFamily( (descriptorId: string) => atom(null), diff --git a/web/oss/src/components/References/atoms/resolvedMetricPaths.ts b/web/oss/src/components/References/atoms/resolvedMetricPaths.ts index 580dd59b0b1..c6cee69995b 100644 --- a/web/oss/src/components/References/atoms/resolvedMetricPaths.ts +++ b/web/oss/src/components/References/atoms/resolvedMetricPaths.ts @@ -1,5 +1,5 @@ import {atom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" export const resolvedMetricPathsAtomFamily = atomFamily( (descriptorId: string) => atom>({}), diff --git a/web/oss/src/components/SeedAttachments/index.tsx b/web/oss/src/components/SeedAttachments/index.tsx index 25a2eeec9f1..fe68f81cc45 100644 --- a/web/oss/src/components/SeedAttachments/index.tsx +++ b/web/oss/src/components/SeedAttachments/index.tsx @@ -1,7 +1,8 @@ -import {useCallback, useRef, useState} from "react" +import {useCallback, useEffect, useRef, useState} from "react" -import {Paperclip, X} from "@phosphor-icons/react" -import {Button, Tooltip} from "antd" +import {AttachmentCard, AttachmentCardGrid} from "@agenta/chat/components" +import {Button, SimpleTooltip} from "@agenta/ui/ui" +import {Paperclip} from "@phosphor-icons/react" import {isAgentFileUploadsEnabled} from "@/oss/components/AgentChatSlice/assets/constants" @@ -32,15 +33,17 @@ export const SeedAttachButton = ({ return ( <> - + + void }) => { + // Keyed by the File itself, not by position: `previews` is state and lags `files` by a commit, + // so an index lookup hands a surviving card the URL of the one just removed — a URL this + // effect is revoking in the same pass. + const [previews, setPreviews] = useState>(new Map()) + + useEffect(() => { + const urls = new Map() + files.forEach((file) => { + if (file.type.startsWith("image/") || file.type.startsWith("audio/")) { + urls.set(file, URL.createObjectURL(file)) + } + }) + setPreviews(urls) + return () => urls.forEach((url) => URL.revokeObjectURL(url)) + }, [files]) + if (!files.length) return null return ( -
- {files.map((file, index) => ( - - {file.name} - - - ))} +
+ + {files.map((file, index) => ( + onChange(files.filter((_, at) => at !== index))} + /> + ))} +
) } diff --git a/web/oss/src/components/SharedDrawers/AnnotateDrawer/assets/hooks/useEvaluatorSchemas.ts b/web/oss/src/components/SharedDrawers/AnnotateDrawer/assets/hooks/useEvaluatorSchemas.ts index dc66fd4bbb4..a71af1261d4 100644 --- a/web/oss/src/components/SharedDrawers/AnnotateDrawer/assets/hooks/useEvaluatorSchemas.ts +++ b/web/oss/src/components/SharedDrawers/AnnotateDrawer/assets/hooks/useEvaluatorSchemas.ts @@ -3,7 +3,7 @@ import {useMemo} from "react" import {resolveOutputSchema} from "@agenta/entities/workflow" import {getAgentaApiUrl} from "@agenta/shared/api" import {atom, useAtomValue} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import {atomWithQuery} from "jotai-tanstack-query" import {EvaluatorDto} from "@/oss/services/evaluations/api/evaluatorTypes" diff --git a/web/oss/src/components/Sidebar/dynamic/dropArchivedAgentSessions.test.ts b/web/oss/src/components/Sidebar/dynamic/dropMissingAgentSessions.test.ts similarity index 83% rename from web/oss/src/components/Sidebar/dynamic/dropArchivedAgentSessions.test.ts rename to web/oss/src/components/Sidebar/dynamic/dropMissingAgentSessions.test.ts index 4bb935282ae..ecea00033ea 100644 --- a/web/oss/src/components/Sidebar/dynamic/dropArchivedAgentSessions.test.ts +++ b/web/oss/src/components/Sidebar/dynamic/dropMissingAgentSessions.test.ts @@ -1,4 +1,4 @@ -import {dropArchivedAgentSessions, withLocalSessions} from "@agenta/navigation" +import {dropMissingAgentSessions, withLocalSessions} from "@agenta/navigation" import type {SessionSidebarRef} from "@agenta/navigation" import {describe, expect, it} from "vitest" @@ -16,40 +16,40 @@ const ref = (over: Partial & {id: string}): SessionSidebarRef ...over, }) -const ARCHIVED = new Set(["agent-archived"]) +/** The catalog lists only non-archived agents, so this is every agent still around. */ +const LIVE = new Set(["agent-live"]) -describe("dropArchivedAgentSessions", () => { - it("drops sessions whose agent is archived", () => { - const kept = dropArchivedAgentSessions( +describe("dropMissingAgentSessions", () => { + it("drops sessions whose agent is no longer listed", () => { + const kept = dropMissingAgentSessions( [ref({id: "a", appId: "agent-archived"}), ref({id: "b", appId: "agent-live"})], - ARCHIVED, + LIVE, ) expect(kept.map((r) => r.id)).toEqual(["b"]) }) // A pin is an explicit user request, the same exemption it gets from every other list rule. - it("keeps a PINNED session even when its agent is archived", () => { - const kept = dropArchivedAgentSessions( + it("keeps a PINNED session even when its agent is gone", () => { + const kept = dropMissingAgentSessions( [ref({id: "pinned", appId: "agent-archived", pinned: true})], - ARCHIVED, + LIVE, ) expect(kept.map((r) => r.id)).toEqual(["pinned"]) }) it("keeps sessions that have no agent at all", () => { - const kept = dropArchivedAgentSessions([ref({id: "orphan", appId: null})], ARCHIVED) + const kept = dropMissingAgentSessions([ref({id: "orphan", appId: null})], LIVE) expect(kept.map((r) => r.id)).toEqual(["orphan"]) }) - // Absence of evidence is not evidence of archival: until the archived-ids query answers there - // is no set at all, and a pending query must never blank the group. - it("keeps everything until the archived answer arrives (null)", () => { + // The dangerous direction: an empty set would read as "all gone" and blank the whole rail. + it("keeps everything until the catalog answers (null)", () => { const refs = [ref({id: "a", appId: "agent-archived"}), ref({id: "b", appId: "agent-live"})] - expect(dropArchivedAgentSessions(refs, null).map((r) => r.id)).toEqual(["a", "b"]) + expect(dropMissingAgentSessions(refs, null).map((r) => r.id)).toEqual(["a", "b"]) }) // The filter runs before the visible cap, so a live session behind archived ones still lands @@ -60,7 +60,7 @@ describe("dropArchivedAgentSessions", () => { ref({id: "live", appId: "agent-live"}), ] - expect(dropArchivedAgentSessions(refs, ARCHIVED).map((r) => r.id)).toEqual(["live"]) + expect(dropMissingAgentSessions(refs, LIVE).map((r) => r.id)).toEqual(["live"]) }) }) diff --git a/web/oss/src/components/Sidebar/dynamic/localSessionRefs.ts b/web/oss/src/components/Sidebar/dynamic/localSessionRefs.ts index 7bfadaf0d37..219c9bd8c35 100644 --- a/web/oss/src/components/Sidebar/dynamic/localSessionRefs.ts +++ b/web/oss/src/components/Sidebar/dynamic/localSessionRefs.ts @@ -8,6 +8,7 @@ import { defaultScopeKeyAtom, isSessionHusk, sessionHasMessagesAtomFamily, + sessionScopeKeysAtom, sessionsListAtomFamily, type AgentChatSession, } from "@/oss/components/AgentChatSlice/state/sessions" @@ -16,9 +17,9 @@ import {isValidUUID} from "@/oss/lib/helpers/validators" /** * OSS binding for `@agenta/navigation`'s local-session seam (#5974). * - * Two playground sessions qualify, and neither is abandoned: the one you are looking at, and any - * that is RUNNING or awaiting you. Scope is the routed app id, so rows disappear when you leave - * that playground — a blank chat never lingers. + * A session qualifies while it is the one you are looking at, while it is running or awaiting you, + * or while the server list cannot carry it yet. The last of those spans EVERY playground scope, so + * a session survives navigating to another agent; husks never qualify, so a blank chat cannot. * * `error` is settled, not live: an errored empty session is as abandoned as an untouched one. */ @@ -42,13 +43,22 @@ export const activePlaygroundSessionIdAtom = atom((get) => { return isSessionHusk(active, get(sessionHasMessagesAtomFamily(active.id))) ? null : active.id }) +/** + * Does this session still need the local seam to be listed at all? + * + * `serverKnown` is the one signal that survives a tab switch. `isActive` and `isLive` both go false + * the moment you look away — `isLive` reads a record only a MOUNTED conversation writes — so a + * session whose first turn was still in flight vanished from the rail until the server list caught + * up (#6494). An unconfirmed session drops out by itself on the first reconcile. + */ +export const qualifiesForLocalRail = ( + session: AgentChatSession, + {isActive, isLive}: {isActive: boolean; isLive: boolean}, +): boolean => isActive || isLive || !session.serverKnown + export const localPlaygroundSessionRefsAtom = atom((get) => { - const scope = get(defaultScopeKeyAtom) - // The scope key doubles as the app id for the row's link, so it must be a real one. - if (!isValidUUID(scope)) return [] - const sessions = get(sessionsListAtomFamily(scope)) + const routedScope = get(defaultScopeKeyAtom) const activeId = get(activePlaygroundSessionIdAtom) - const active = sessions.find((session) => session.id === activeId) ?? null const pinned = get(pinnedSessionIdsAtom) const statusOf = (id: string) => get(sessionStatusAtomFamily(id)) const isLive = (id: string) => { @@ -66,20 +76,39 @@ export const localPlaygroundSessionRefsAtom = atom((get) => const at = session.lastMessageAt ?? session.createdAt return at ? new Date(at).toISOString() : null } + // EVERY playground scope, not just the routed one: a session you started and then left behind + // by opening a different agent is exactly the one the server list cannot show yet (#6494). + // A non-UUID scope (`__global__`, `drawer:*`, `onboarding`) never reconciles, so `serverKnown` + // is never set there and its sessions would qualify forever — those are skipped. + const scopeById = new Map() + const sessions: AgentChatSession[] = [] + for (const scope of get(sessionScopeKeysAtom).filter(isValidUUID)) { + for (const session of get(sessionsListAtomFamily(scope))) { + scopeById.set(session.id, scope) + sessions.push(session) + } + } + // The scope key doubles as the app id for the row's link, so it must be a real one. + const scopeOf = (id: string) => scopeById.get(id) ?? routedScope return sessions - .filter((session) => session.id === active?.id || isLive(session.id)) + .filter((session) => + qualifiesForLocalRail(session, { + isActive: scopeOf(session.id) === routedScope && session.id === activeId, + isLive: isLive(session.id), + }), + ) .filter((session) => !isHusk(session)) .map((session) => ({ id: session.id, sessionId: session.id, name: session.title?.trim() || null, - appId: scope, - agentId: scope, + appId: scopeOf(session.id), + agentId: scopeOf(session.id), pinned: pinned.includes(session.id), alive: false, activityAt: activityAt(session), - // A client-created session has no server row yet, so it cannot be archived. - archived: false, + // The server source excludes archived rows, so this ref is their only way in (#6468). + archived: Boolean(session.archived), // A playground chat is never a trigger run. isAutomation: false, // Running and awaiting are DIFFERENT signals — one spins, the other goes amber — so diff --git a/web/oss/src/components/Sidebar/dynamic/qualifiesForLocalRail.test.ts b/web/oss/src/components/Sidebar/dynamic/qualifiesForLocalRail.test.ts new file mode 100644 index 00000000000..9a8871c5268 --- /dev/null +++ b/web/oss/src/components/Sidebar/dynamic/qualifiesForLocalRail.test.ts @@ -0,0 +1,43 @@ +/** + * Which playground sessions the local seam must carry (#5974, #6494). + * + * The server list cannot show a session until its first turn lands, so until then this rule is the + * only thing keeping the row on the rail. It has regressed twice by leaning on signals that go + * false as soon as you look away. + */ +import {describe, expect, it} from "vitest" + +import type {AgentChatSession} from "@/oss/components/AgentChatSlice/state/sessions" + +import {qualifiesForLocalRail} from "./localSessionRefs" + +const session = (over: Partial = {}): AgentChatSession => ({ + id: "s1", + ...over, +}) + +/** Looking elsewhere, with nothing mounted for this session. */ +const lookedAway = {isActive: false, isLive: false} + +describe("qualifiesForLocalRail", () => { + // The repro: send the first message, switch tabs before the turn lands. + it("keeps a session the server has not confirmed yet", () => { + expect(qualifiesForLocalRail(session(), lookedAway)).toBe(true) + }) + + it("drops it once the server list can carry it", () => { + expect(qualifiesForLocalRail(session({serverKnown: true}), lookedAway)).toBe(false) + }) + + it("keeps the session you are looking at", () => { + expect( + qualifiesForLocalRail(session({serverKnown: true}), {isActive: true, isLive: false}), + ).toBe(true) + }) + + it("keeps a running or awaiting session", () => { + expect( + qualifiesForLocalRail(session({serverKnown: true}), {isActive: false, isLive: true}), + ).toBe(true) + }) +}) diff --git a/web/oss/src/components/Sidebar/scopes/mainScope.tsx b/web/oss/src/components/Sidebar/scopes/mainScope.tsx index 17fb622419f..983e9299f0b 100644 --- a/web/oss/src/components/Sidebar/scopes/mainScope.tsx +++ b/web/oss/src/components/Sidebar/scopes/mainScope.tsx @@ -8,9 +8,10 @@ import type { } from "@agenta/navigation" import {HOME_SIDEBAR_KEY, MAIN_SIDEBAR_SCOPE_ID, SESSIONS_SIDEBAR_KEY} from "@agenta/navigation" import {SidebarLogo} from "@agenta/navigation-ui" -import {useAtomValue} from "jotai" +import {atom, useAtomValue} from "jotai" import SidePanelSubscriptionInfo from "@/oss/components/SidePanel/Subscription" +import {appStateSnapshotAtom} from "@/oss/state/appState" import {homeNavHighlightedAtom} from "@/oss/state/onboarding" import ProjectOrgSwitcher from "../components/ProjectOrgSwitcher" @@ -35,6 +36,10 @@ const MainSidebarAfterBottom = ({collapsed}: SidebarSlotContext) => ( ) +// The open session is a fact about the playground, not about where you are: the tab list is +// persisted per agent, so off that route the pin outranked the row the route itself selects (#6389). +const playgroundRouteAtom = atom((get) => get(appStateSnapshotAtom).restPath[0] === "playground") + // During onboarding the route is the ephemeral playground, but Home IS the surface — pin it selected. const useMainSidebarSelection = (): SidebarSelection => { const highlightHome = useAtomValue(homeNavHighlightedAtom) @@ -42,16 +47,17 @@ const useMainSidebarSelection = (): SidebarSelection => { // the agent row won every tie. Pin the open session instead; when that row is not rendered // (its group collapsed, or filtered out) the shell selects nothing rather than the agent. const activeSessionId = useAtomValue(activePlaygroundSessionIdAtom) + const onPlaygroundRoute = useAtomValue(playgroundRouteAtom) return useMemo(() => { if (highlightHome) return {mode: "route", selectedKeyOverride: HOME_SIDEBAR_KEY} - if (activeSessionId) { + if (onPlaygroundRoute && activeSessionId) { return { mode: "route", selectedKeyOverride: `${SESSIONS_SIDEBAR_KEY}-${activeSessionId}`, } } return {mode: "route"} - }, [activeSessionId, highlightHome]) + }, [activeSessionId, highlightHome, onPlaygroundRoute]) } const useMainSidebarSections = (): SidebarSection[] => { diff --git a/web/oss/src/components/VariantsComponents/Table/assets/registryColumns.tsx b/web/oss/src/components/VariantsComponents/Table/assets/registryColumns.tsx index b7ffee1f247..15fd38ce1df 100644 --- a/web/oss/src/components/VariantsComponents/Table/assets/registryColumns.tsx +++ b/web/oss/src/components/VariantsComponents/Table/assets/registryColumns.tsx @@ -16,7 +16,7 @@ import { } from "@phosphor-icons/react" import {Typography} from "antd" import {atom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import type {RegistryRevisionRow} from "../../store/registryStore" diff --git a/web/oss/src/components/VariantsComponents/store/selectionAtoms.ts b/web/oss/src/components/VariantsComponents/store/selectionAtoms.ts index 0c4f7796823..c88fff49e83 100644 --- a/web/oss/src/components/VariantsComponents/store/selectionAtoms.ts +++ b/web/oss/src/components/VariantsComponents/store/selectionAtoms.ts @@ -1,5 +1,5 @@ import {atom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" /** Holds selected row keys for a given table scope */ export const variantTableSelectionAtomFamily = atomFamily((_scopeId: string) => diff --git a/web/oss/src/components/pages/WorkspaceProjectRedirect/index.tsx b/web/oss/src/components/pages/WorkspaceProjectRedirect/index.tsx index 7f6a446c457..0d479fe14ce 100644 --- a/web/oss/src/components/pages/WorkspaceProjectRedirect/index.tsx +++ b/web/oss/src/components/pages/WorkspaceProjectRedirect/index.tsx @@ -17,10 +17,6 @@ const WorkspaceProjectRedirect = () => { } }, [router, baseAppURL]) - if (baseAppURL && router.asPath === baseAppURL) { - return null - } - return (
diff --git a/web/oss/src/components/pages/WorkspaceRedirect/index.tsx b/web/oss/src/components/pages/WorkspaceRedirect/index.tsx index 662de4583a3..4db15235681 100644 --- a/web/oss/src/components/pages/WorkspaceRedirect/index.tsx +++ b/web/oss/src/components/pages/WorkspaceRedirect/index.tsx @@ -39,10 +39,6 @@ const WorkspaceRedirect = () => { void router.replace(targetPath) }, [router, targetPath]) - if (targetPath && router.asPath.split("?")[0] === targetPath.split("?")[0]) { - return null - } - return (
diff --git a/web/oss/src/components/pages/overview/agent/AgentOverview.tsx b/web/oss/src/components/pages/overview/agent/AgentOverview.tsx index d243e92d4bc..1a7e699f244 100644 --- a/web/oss/src/components/pages/overview/agent/AgentOverview.tsx +++ b/web/oss/src/components/pages/overview/agent/AgentOverview.tsx @@ -1,8 +1,7 @@ -import {useCallback, useEffect} from "react" +import {useCallback} from "react" import {AgentOverviewBody} from "@agenta/entity-ui/agent" import {RichChatInput} from "@agenta/ui/rich-chat-input" -import {useSetAtom} from "jotai" import {useStartAgentSession} from "@/oss/components/AgentChatSlice/hooks/useStartAgentSession" import {sessionRouteModes} from "@/oss/components/pages/sessions/assets/sessionRouteScope" @@ -15,7 +14,6 @@ import { import UsageSummary from "@/oss/components/UsageSummary" import {usePlaygroundNavigation} from "@/oss/hooks/usePlaygroundNavigation" import useURL from "@/oss/hooks/useURL" -import {layoutFullHeightRequestAtom} from "@/oss/state/layout/fullHeight" interface Props { appId: string @@ -35,21 +33,12 @@ interface Props { * it in the main column a waiting session sat below six rows of settings. The rail is also the * width the config rows were designed for — they come from the playground's panel, which is narrow. * - * Columns scroll independently, as Home's do. They did not when this page held one list; with - * Sessions and Automation runs both in the main column it outgrew the rail, and a whole-page - * scroll meant losing the configuration while reading the sessions. The full-height frame is - * requested rather than matched on the path, because this route also serves the prompt and - * evaluator overviews, which still flow. + * The page scrolls as one. The columns used to scroll independently inside a bounded frame, + * which put two scrollbars on one page and left the rail and the reading column disagreeing + * about where the top was. */ const AgentOverview = ({appId, agentName}: Props) => { const startSession = useStartAgentSession() - // The layout can't tell an agent overview from a prompt one by its path, so this branch asks - // for the bounded frame and releases it on the way out. - const requestFullHeight = useSetAtom(layoutFullHeightRequestAtom) - useEffect(() => { - requestFullHeight(true) - return () => requestFullHeight(false) - }, [requestFullHeight]) // "View all" stays on this agent's rail rather than dropping you on the project list with a // filter you then have to trust. diff --git a/web/oss/src/components/pages/sessions/SessionsPage.tsx b/web/oss/src/components/pages/sessions/SessionsPage.tsx index 400438115c9..9fc86cc9bb6 100644 --- a/web/oss/src/components/pages/sessions/SessionsPage.tsx +++ b/web/oss/src/components/pages/sessions/SessionsPage.tsx @@ -96,6 +96,7 @@ const SessionsPage = ({scopedAgentId, title = "Sessions"}: Props) => { const target = targetFor(vm) if (key === "open") handleOpen(vm) if (key === "pin") togglePin(vm.id) + if (key === "copy-link") void sessionActions.copyShareLink(target) if (key === "archive") void sessionActions.setArchived(target) if (key === "delete") sessionActions.remove(target) automation.onSelect(vm, key) diff --git a/web/oss/src/lib/hooks/usePreviewEvaluations/index.ts b/web/oss/src/lib/hooks/usePreviewEvaluations/index.ts index 12829321c7f..126729c50b6 100644 --- a/web/oss/src/lib/hooks/usePreviewEvaluations/index.ts +++ b/web/oss/src/lib/hooks/usePreviewEvaluations/index.ts @@ -2,7 +2,7 @@ import {useCallback, useMemo} from "react" import {useAtomValue} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import {atomWithQuery} from "jotai-tanstack-query" import {useSWRConfig} from "swr" import {v4 as uuidv4} from "uuid" diff --git a/web/oss/src/lib/onboarding/atoms.ts b/web/oss/src/lib/onboarding/atoms.ts index d4fc0d3baea..738b4758d85 100644 --- a/web/oss/src/lib/onboarding/atoms.ts +++ b/web/oss/src/lib/onboarding/atoms.ts @@ -1,6 +1,7 @@ import {activeUserIdAtom} from "@agenta/shared/state" import {atom} from "jotai" -import {atomFamily, atomWithStorage} from "jotai/utils" +import {atomWithStorage} from "jotai/utils" +import {atomFamily} from "jotai-family" import type {CurrentStepState} from "./types" diff --git a/web/oss/src/lib/onboarding/widget/store.ts b/web/oss/src/lib/onboarding/widget/store.ts index 21528c0683a..ce70d864af7 100644 --- a/web/oss/src/lib/onboarding/widget/store.ts +++ b/web/oss/src/lib/onboarding/widget/store.ts @@ -1,5 +1,6 @@ import {atom} from "jotai" -import {atomFamily, atomWithStorage} from "jotai/utils" +import {atomWithStorage} from "jotai/utils" +import {atomFamily} from "jotai-family" import {onboardingStorageUserIdAtom} from "../atoms" diff --git a/web/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/overview/index.tsx b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/overview/index.tsx index d0cd030558e..35b844a0ad1 100644 --- a/web/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/overview/index.tsx +++ b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/overview/index.tsx @@ -1,8 +1,9 @@ -import {memo, useState} from "react" +import {memo, useEffect, useState} from "react" -import {AgentActionsMenu} from "@agenta/entity-ui/agent" +import {AgentActionsMenu, AgentOverviewSkeleton} from "@agenta/entity-ui/agent" import {PageLayout} from "@agenta/ui" import {pageContentWidthClass} from "@agenta/ui/components/page-width" +import {SkeletonBlock} from "@agenta/ui/ui" import {Space, Typography} from "antd" import clsx from "clsx" import {useAtomValue, useSetAtom} from "jotai" @@ -19,7 +20,8 @@ import WorkflowPageTitle from "@/oss/components/PageTitle/WorkflowPageTitle" import RequireWorkflowKind from "@/oss/components/RequireWorkflowKind" import {useAppId} from "@/oss/hooks/useAppId" import {useAppsData} from "@/oss/state/app" -import {currentWorkflowAtom} from "@/oss/state/workflow" +import {layoutFullHeightRequestAtom} from "@/oss/state/layout/fullHeight" +import {currentWorkflowAtom, playgroundEarlyAgentStateAtom} from "@/oss/state/workflow" const CustomWorkflowHistory: any = dynamic( () => import("@/oss/components/pages/app-management/drawers/CustomWorkflowHistory"), @@ -51,9 +53,14 @@ const AppDetailsSection = memo(() => { return ( <> - - {workflowName} - + {/* An empty

is a title-shaped hole; hold the line's height until the name lands. */} + {workflowName ? ( + + {workflowName} + + ) : ( + + )} { const agents = useAtomValue(agentsWorkflowsAtom) const agentsLoading = useAtomValue(agentsWorkflowsLoadingAtom) const isAgent = Boolean(appId) && agents.some((agent) => agent.workflowId === appId) + // Which SKELETON to hold the page with while the list above resolves — never which branch + // renders. Revision-derived like the list, but answered by the lightweight latest-revision + // query (with a persisted fallback), so it lands first. A prompt app must not be shown an + // agent's surface, so only a workflow this does not rule out gets the agent placeholder. + const earlyAgentState = useAtomValue(playgroundEarlyAgentStateAtom) + const skeletonIsAgentShaped = agentsLoading && earlyAgentState !== "non-agent" + // The agent layout scrolls INSIDE itself, so it needs the bounded frame. Requested here + // rather than inside `AgentOverview` so the placeholder gets the same frame as the body it + // stands in for — asking only once the body mounted made the page reflow at the handoff. + const needsFullHeight = isAgent || skeletonIsAgentShaped + const requestFullHeight = useSetAtom(layoutFullHeightRequestAtom) + useEffect(() => { + requestFullHeight(needsFullHeight) + return () => requestFullHeight(false) + }, [needsFullHeight, requestFullHeight]) // An agent's overview is about its work, not its prompt revisions or evaluation runs. // Held while the list resolves so those sections never flash in and then vanish. const showWorkflowSections = !isAgent && !agentsLoading @@ -107,12 +129,12 @@ const OverviewContent = () => { return ( <> - {/* The agent branch runs inside the layout's bounded frame (it asks for it), so the - page column must be allowed to shrink or its children can't take a definite - height and the per-column scrolls collapse back into one page scroll. It also - takes the shared centred column, like Home — the prompt-app/evaluator branch - below stays full width for its charts and evaluation tables. */} - + {/* The agent branch takes the shared centred column, like Home; the prompt-app and + evaluator branch below stays full width for its charts and evaluation tables. The + placeholder claims the same column so the page does not change width under it. */} + {/* An agent's overview is its own surface. Charts move into that layout's usage @@ -122,6 +144,10 @@ const OverviewContent = () => { so neither flashes in and vanishes. */} {isAgent && appId ? ( + ) : skeletonIsAgentShaped ? ( + // Waiting is not nothing: this branch rendered null, so the page sat blank + // under its own title for the whole classification. + ) : agentsLoading ? null : ( <> diff --git a/web/oss/src/state/customWorkflow/modalAtoms.ts b/web/oss/src/state/customWorkflow/modalAtoms.ts index b0e2b8db104..e1cc1f4921c 100644 --- a/web/oss/src/state/customWorkflow/modalAtoms.ts +++ b/web/oss/src/state/customWorkflow/modalAtoms.ts @@ -1,6 +1,6 @@ import {workflowRevisionsByWorkflowListDataAtomFamily} from "@agenta/entities/workflow" import {atom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import {atomWithImmer} from "jotai-immer" import {appsAtom} from "@/oss/state/app" diff --git a/web/oss/src/state/entities/shared/createEntityController.ts b/web/oss/src/state/entities/shared/createEntityController.ts index f5b98058aaa..1af6e37f7cc 100644 --- a/web/oss/src/state/entities/shared/createEntityController.ts +++ b/web/oss/src/state/entities/shared/createEntityController.ts @@ -58,7 +58,7 @@ */ import {atom, type Atom, type Getter, type Setter, type WritableAtom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" // ============================================================================ // PATH ITEM TYPE (shared with DrillInView component) diff --git a/web/oss/src/state/entities/shared/createEntityDraftState.ts b/web/oss/src/state/entities/shared/createEntityDraftState.ts index 71454844e40..8c4dc74533f 100644 --- a/web/oss/src/state/entities/shared/createEntityDraftState.ts +++ b/web/oss/src/state/entities/shared/createEntityDraftState.ts @@ -6,7 +6,7 @@ import { type Setter, type WritableAtom, } from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" // ============================================================================ // TYPES diff --git a/web/oss/src/state/entities/shared/createPaginatedEntityStore.ts b/web/oss/src/state/entities/shared/createPaginatedEntityStore.ts index 71519bdccd0..74ddaeffb1e 100644 --- a/web/oss/src/state/entities/shared/createPaginatedEntityStore.ts +++ b/web/oss/src/state/entities/shared/createPaginatedEntityStore.ts @@ -85,7 +85,7 @@ import { } from "@agenta/ui/table" import {atom} from "jotai" import type {Atom, PrimitiveAtom, WritableAtom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" // ============================================================================ // TYPES diff --git a/web/oss/src/state/entities/shared/createStatefulEntityAtomFamily.ts b/web/oss/src/state/entities/shared/createStatefulEntityAtomFamily.ts index 30ae03fa968..850e8b53034 100644 --- a/web/oss/src/state/entities/shared/createStatefulEntityAtomFamily.ts +++ b/web/oss/src/state/entities/shared/createStatefulEntityAtomFamily.ts @@ -1,6 +1,6 @@ import {atom} from "jotai" import type {Atom, Getter} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" /** * Query result type matching React Query structure diff --git a/web/oss/src/state/entities/testcase/columnState.ts b/web/oss/src/state/entities/testcase/columnState.ts index 48abc9d4980..dbf090c0adb 100644 --- a/web/oss/src/state/entities/testcase/columnState.ts +++ b/web/oss/src/state/entities/testcase/columnState.ts @@ -1,5 +1,5 @@ import {atom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import { createColumnFromKey, diff --git a/web/oss/src/state/entities/testcase/displayRows.ts b/web/oss/src/state/entities/testcase/displayRows.ts index 3c9768f423a..8ba07b08f5a 100644 --- a/web/oss/src/state/entities/testcase/displayRows.ts +++ b/web/oss/src/state/entities/testcase/displayRows.ts @@ -1,5 +1,5 @@ import {atom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import type {FlattenedTestcase} from "./schema" import { diff --git a/web/oss/src/state/entities/testcase/testcaseEntity.ts b/web/oss/src/state/entities/testcase/testcaseEntity.ts index 5ea02d8cc03..5ec21ef44af 100644 --- a/web/oss/src/state/entities/testcase/testcaseEntity.ts +++ b/web/oss/src/state/entities/testcase/testcaseEntity.ts @@ -7,7 +7,8 @@ import { setValueAtPath, } from "@agenta/shared/utils" import {atom, type Getter} from "jotai" -import {atomFamily, selectAtom} from "jotai/utils" +import {selectAtom} from "jotai/utils" +import {atomFamily} from "jotai-family" import {atomWithQuery, queryClientAtom} from "jotai-tanstack-query" import axios from "@/oss/lib/api/assets/axiosConfig" diff --git a/web/oss/src/state/entities/testset/controller.ts b/web/oss/src/state/entities/testset/controller.ts index 73881c5207d..f88fadfaf5c 100644 --- a/web/oss/src/state/entities/testset/controller.ts +++ b/web/oss/src/state/entities/testset/controller.ts @@ -46,7 +46,7 @@ */ import {atom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import {atomWithQuery} from "jotai-tanstack-query" import axios from "@/oss/lib/api/assets/axiosConfig" diff --git a/web/oss/src/state/entities/testset/revisionEntity.ts b/web/oss/src/state/entities/testset/revisionEntity.ts index 95d87769c74..7f89a8c88f7 100644 --- a/web/oss/src/state/entities/testset/revisionEntity.ts +++ b/web/oss/src/state/entities/testset/revisionEntity.ts @@ -1,6 +1,6 @@ import {createBatchFetcher} from "@agenta/shared/utils" import {atom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import {atomWithQuery} from "jotai-tanstack-query" import axios from "@/oss/lib/api/assets/axiosConfig" diff --git a/web/oss/src/state/entities/testset/store.ts b/web/oss/src/state/entities/testset/store.ts index 689d78b8706..885509ade74 100644 --- a/web/oss/src/state/entities/testset/store.ts +++ b/web/oss/src/state/entities/testset/store.ts @@ -1,5 +1,5 @@ import {atom, getDefaultStore} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import {atomWithQuery, queryClientAtom} from "jotai-tanstack-query" import axios from "@/oss/lib/api/assets/axiosConfig" diff --git a/web/oss/src/state/observability/atoms.ts b/web/oss/src/state/observability/atoms.ts index 2184b46e690..d9d86f94a2e 100644 --- a/web/oss/src/state/observability/atoms.ts +++ b/web/oss/src/state/observability/atoms.ts @@ -6,7 +6,8 @@ * payload, and the two onboarding flags (which key off the onboarding user id). */ import {atom} from "jotai" -import {atomFamily, atomWithStorage} from "jotai/utils" +import {atomWithStorage} from "jotai/utils" +import {atomFamily} from "jotai-family" import type {TestsetTraceData} from "@/oss/components/SharedDrawers/AddToTestsetDrawer/assets/types" import {onboardingStorageUserIdAtom} from "@/oss/lib/onboarding/atoms" diff --git a/web/oss/src/state/project/selectors/project.ts b/web/oss/src/state/project/selectors/project.ts index d5bd4a26151..77f37c5b0e1 100644 --- a/web/oss/src/state/project/selectors/project.ts +++ b/web/oss/src/state/project/selectors/project.ts @@ -10,9 +10,17 @@ import {atomWithQuery} from "jotai-tanstack-query" import {queryClient} from "@/oss/lib/api/queryClient" import {appIdentifiersAtom, appStateSnapshotAtom, requestNavigationAtom} from "@/oss/state/appState" import {selectedOrgAtom, selectedOrgIdAtom} from "@/oss/state/org/selectors/org" +import {userAtom} from "@/oss/state/profile/selectors/user" import {sessionExistsAtom} from "@/oss/state/session" import {jwtReadyAtom} from "@/oss/state/session/jwt" +import { + NEUTRAL_ROUTE_CONTEXT, + resolveRouteContext, + shouldRunRouteGuard, + type RouteContext, +} from "./routeContext" + // Re-export the shared projectIdAtom so all OSS code uses the same atom as entity packages export {projectIdAtom} @@ -113,6 +121,12 @@ export const projectsQueryAtom = atomWithQuery((get) => { // parallel with /profile/, so this does not reintroduce the sequential // profile-wait that 2ede5faa10 removed to fix the demo-banner cold-load race. enabled: get(sessionExistsAtom) && jwtReady && !isAcceptRoute && !!orgId, + // A 4xx is an answer, not a hiccup: most often a workspace id that does not exist. + retry: (failureCount, error: any) => { + const status = error?.response?.status + if (status && status >= 400 && status < 500) return false + return failureCount < 2 + }, // Paint from disk + background revalidate; key includes orgId so no cross-org bleed. persister: catalogPersister.persisterFn, } @@ -151,6 +165,72 @@ const projectMatchesWorkspace = ( return false } +/** + * Backs the route-level id guard: does the address name a workspace and project that exist? + * + * Unscoped on purpose: the workspace-scoped request 401s for an id that does not exist and then + * never settles, so the check has to ride on a call that always answers. The handler returns every + * membership either way, so one response settles both ids. + * + * Keyed by the ids it judges even though the response is not. A shared key would answer from a + * list fetched before the workspace or project existed and 404 it: creating an org refetches only + * orgs, and creating a project navigates before its refetch lands. + */ +const routeGuardProjectsQueryAtom = atomWithQuery((get) => { + const {routeLayer} = get(appStateSnapshotAtom) + const {workspaceId, projectId} = get(appIdentifiersAtom) + const userId = (get(userAtom) as {id?: string} | null)?.id + const jwtReady = Boolean((get(jwtReadyAtom) as any)?.data) + return { + // Account-scoped like orgsQueryAtom: a sign-out that skips useSession.logout leaves this + // membership answer in the cache, and the next account must not be judged by it. + queryKey: ["projects", "route-guard", userId ?? "", workspaceId ?? "", projectId ?? ""], + queryFn: async () => fetchAllProjects(), + staleTime: 60_000, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + retry: (failureCount, error: any) => { + const status = error?.response?.status + if (status && status >= 400 && status < 500) return false + return failureCount < 2 + }, + enabled: + shouldRunRouteGuard(routeLayer) && + !!workspaceId && + !!userId && + get(sessionExistsAtom) && + jwtReady, + } +}) + +/** Route-gated before the query is read, so routes carrying no ids never subscribe to it. */ +export const routeContextAtom = atom((get) => { + const {routeLayer} = get(appStateSnapshotAtom) + if (!shouldRunRouteGuard(routeLayer)) return NEUTRAL_ROUTE_CONTEXT + + const {workspaceId, projectId} = get(appIdentifiersAtom) + const query = get(routeGuardProjectsQueryAtom) as { + isPending?: boolean + error?: unknown + data?: ProjectsResponse[] + } + // The raw list, not projectsAtom: a workspace holding only demo projects is still real. + const rows = query.data ?? [] + + return resolveRouteContext({ + routeLayer, + workspaceId, + projectId, + isPending: query.isPending ?? true, + failed: Boolean(query.error), + workspaceHoldsProject: rows.some((row) => projectMatchesWorkspace(row, workspaceId)), + projectInWorkspace: rows.some( + (row) => row.project_id === projectId && projectMatchesWorkspace(row, workspaceId), + ), + }) +}) + /** * Exported for unit-test access (projectAtom.race.test.ts). Internal to this * module otherwise — callers should go through projectAtom. diff --git a/web/oss/src/state/project/selectors/routeContext.test.ts b/web/oss/src/state/project/selectors/routeContext.test.ts new file mode 100644 index 00000000000..bad167fad6a --- /dev/null +++ b/web/oss/src/state/project/selectors/routeContext.test.ts @@ -0,0 +1,66 @@ +import {describe, expect, it} from "vitest" + +import {resolveRouteContext, type RouteContextInput} from "./routeContext" + +const guard = (overrides: Partial = {}): RouteContextInput => ({ + routeLayer: "project", + workspaceId: "ws-1", + projectId: "p-1", + isPending: false, + failed: false, + workspaceHoldsProject: true, + projectInWorkspace: true, + ...overrides, +}) + +const NEUTRAL = {isResolving: false, isNotFound: false, isError: false} + +describe("resolveRouteContext", () => { + it("stays neutral off a route that carries these ids", () => { + expect(resolveRouteContext(guard({routeLayer: "root"}))).toEqual(NEUTRAL) + }) + + it("stays neutral when the URL names no workspace", () => { + expect(resolveRouteContext(guard({workspaceId: null}))).toEqual(NEUTRAL) + }) + + it("resolves while the guard query is pending", () => { + expect(resolveRouteContext(guard({isPending: true})).isResolving).toBe(true) + }) + + it("stays neutral when both ids resolve", () => { + expect(resolveRouteContext(guard())).toEqual(NEUTRAL) + }) + + it("reads a workspace the account holds no project in as not found", () => { + const ctx = guard({workspaceHoldsProject: false, projectInWorkspace: false}) + expect(resolveRouteContext(ctx).isNotFound).toBe(true) + }) + + it("reads a project that is not in the named workspace as not found", () => { + expect(resolveRouteContext(guard({projectInWorkspace: false})).isNotFound).toBe(true) + }) + + it("ignores the project check on a route that names no project", () => { + const ctx = guard({routeLayer: "workspace", projectId: null, projectInWorkspace: false}) + expect(resolveRouteContext(ctx)).toEqual(NEUTRAL) + }) + + it("guards app routes too, so a bad workspace 404s under /apps", () => { + const ctx = guard({routeLayer: "app", workspaceHoldsProject: false}) + expect(resolveRouteContext(ctx).isNotFound).toBe(true) + }) + + it("never shows the 404 when the guard query itself failed", () => { + const ctx = guard({failed: true, workspaceHoldsProject: false, projectInWorkspace: false}) + expect(resolveRouteContext(ctx)).toEqual({ + isResolving: false, + isNotFound: false, + isError: true, + }) + }) + + it("prefers pending over a stale failure", () => { + expect(resolveRouteContext(guard({isPending: true, failed: true})).isResolving).toBe(true) + }) +}) diff --git a/web/oss/src/state/project/selectors/routeContext.ts b/web/oss/src/state/project/selectors/routeContext.ts new file mode 100644 index 00000000000..1c600c13ef8 --- /dev/null +++ b/web/oss/src/state/project/selectors/routeContext.ts @@ -0,0 +1,62 @@ +import type {RouteLayer} from "@/oss/state/appState" + +/** Terminal states of the route-level id guard, shaped like `CurrentWorkflowContext`. */ +export interface RouteContext { + isResolving: boolean + isNotFound: boolean + isError: boolean +} + +export const NEUTRAL_ROUTE_CONTEXT: RouteContext = { + isResolving: false, + isNotFound: false, + isError: false, +} + +const RESOLVING: RouteContext = {isResolving: true, isNotFound: false, isError: false} +const NOT_FOUND: RouteContext = {isResolving: false, isNotFound: true, isError: false} +const ERRORED: RouteContext = {isResolving: false, isNotFound: false, isError: true} + +/** `/w/:workspace_id` and `/p/:project_id` gate every route under them, so the guard owns all three. */ +export const shouldRunRouteGuard = (routeLayer: RouteLayer): boolean => + routeLayer === "workspace" || routeLayer === "project" || routeLayer === "app" + +export interface RouteContextInput { + routeLayer: RouteLayer + workspaceId: string | null + /** Null on `/w/:id`, which names no project. */ + projectId: string | null + /** The guard query before it settles. A disabled query counts as pending, which is right. */ + isPending: boolean + /** The guard query settled with an error, which says nothing about the address. */ + failed: boolean + /** The account holds some project in the workspace the URL names. */ + workspaceHoldsProject: boolean + /** The project the URL names is in the workspace the URL names. */ + projectInWorkspace: boolean +} + +/** + * Whether the ids in the address resolve. + * + * Membership, not a status code: the workspace-scoped projects request 401s for an id that does + * not exist and then never settles, so only the unscoped list can answer. Both ids come from that + * one response. A guard query that failed outright must never reach the 404. + */ +export const resolveRouteContext = ({ + routeLayer, + workspaceId, + projectId, + isPending, + failed, + workspaceHoldsProject, + projectInWorkspace, +}: RouteContextInput): RouteContext => { + if (!shouldRunRouteGuard(routeLayer) || !workspaceId) return NEUTRAL_ROUTE_CONTEXT + if (isPending) return RESOLVING + if (failed) return ERRORED + if (!workspaceHoldsProject) return NOT_FOUND + // A project that exists but sits in another workspace makes the pair in the URL wrong. + if (projectId && !projectInWorkspace) return NOT_FOUND + return NEUTRAL_ROUTE_CONTEXT +} diff --git a/web/oss/src/state/queries/atoms/fetcher.ts b/web/oss/src/state/queries/atoms/fetcher.ts index fcc07d273be..0f408181f9e 100644 --- a/web/oss/src/state/queries/atoms/fetcher.ts +++ b/web/oss/src/state/queries/atoms/fetcher.ts @@ -1,5 +1,6 @@ import {atom} from "jotai" -import {atomFamily, selectAtom} from "jotai/utils" +import {selectAtom} from "jotai/utils" +import {atomFamily} from "jotai-family" import {atomWithQuery} from "jotai-tanstack-query" import {queryClient} from "@/oss/lib/api/queryClient" diff --git a/web/oss/src/state/testsetSelection/atoms.ts b/web/oss/src/state/testsetSelection/atoms.ts index 9350cc26ca6..5fe59a09f04 100644 --- a/web/oss/src/state/testsetSelection/atoms.ts +++ b/web/oss/src/state/testsetSelection/atoms.ts @@ -1,5 +1,5 @@ import {atom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import { fetchRevisionsList, diff --git a/web/oss/src/state/workspace/atoms/selectors.ts b/web/oss/src/state/workspace/atoms/selectors.ts index 5e2afd9b868..1de311bd317 100644 --- a/web/oss/src/state/workspace/atoms/selectors.ts +++ b/web/oss/src/state/workspace/atoms/selectors.ts @@ -1,6 +1,6 @@ import type {WorkspaceMember} from "@agenta/entities/organization" import {atom} from "jotai" -import {atomFamily} from "jotai/utils" +import {atomFamily} from "jotai-family" import {selectedOrgAtom} from "../../org/selectors/org" diff --git a/web/oss/tests/playwright/acceptance/agent-chat/attach-send-render-reload.spec.ts b/web/oss/tests/playwright/acceptance/agent-chat/attach-send-render-reload.spec.ts index a070b94ec7a..90fcb273c7f 100644 --- a/web/oss/tests/playwright/acceptance/agent-chat/attach-send-render-reload.spec.ts +++ b/web/oss/tests/playwright/acceptance/agent-chat/attach-send-render-reload.spec.ts @@ -38,11 +38,12 @@ const IMAGE = Buffer.from( test( "an uploaded attachment renders after send and after reload", {tag: tags}, - async ({page, seedAgentChatApp, navigateToAgentPlayground}) => { + async ({page, seedAgentChatApp, navigateToAgentPlayground, testProviderHelpers}) => { // API seeding + 2 navigations + file upload + SSE-mocked run + reload is heavier // than the 60s default (siblings that do less than this already bump to 120s+, #5695). test.setTimeout(120000) await expectAuthenticatedSession(page) + await testProviderHelpers.ensureTestProvider() const appId = await seedAgentChatApp() await navigateToAgentPlayground(appId) @@ -62,7 +63,9 @@ test( }) const composer = page.getByRole("textbox").last() - await page.getByRole("button", {name: "Attach files"}).click() + const attachButton = page.getByRole("button", {name: "Attach files"}) + await expect(attachButton).toBeEnabled() + await attachButton.click() const uploadResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()) diff --git a/web/oss/tests/playwright/acceptance/app/test.ts b/web/oss/tests/playwright/acceptance/app/test.ts index 29747f76da3..30414e8f6f4 100644 --- a/web/oss/tests/playwright/acceptance/app/test.ts +++ b/web/oss/tests/playwright/acceptance/app/test.ts @@ -156,32 +156,28 @@ const testWithAppFixtures = baseTest.extend({ // 5. Click the "Create" button. CommitVariantChangesButton shows "Create" // (not "Commit") for ephemeral local-* entities. Clicking it opens // CommitVariantChangesModal (EntityCommitModal with actionLabel="Create"). - await uiHelpers.clickButton("Create", drawer) - // The confirmation modal opens — accept it. Wait for the submit - // button before clicking; isVisible() is an immediate snapshot and - // can race the modal render. - // Match the dialog by role, not by a component-library class: - // `EntityCommitModal` renders through `EnhancedModal`, now a facade over - // the @agenta/ui (Radix) `Dialog`, so no `.ant-modal-wrap` exists here. - // Radix `aria-hidden`s the launching antd drawer while the modal is open, - // so exactly one dialog resolves. - const confirmModal = page - .getByRole("dialog") - .filter({has: page.getByRole("button", {name: "Create", exact: true})}) - .last() - const confirmButton = confirmModal.getByRole("button", { - name: "Create", + const confirmModal = page.getByRole("dialog", { + name: "Create changes", exact: true, }) - await expect(confirmModal).toBeVisible({timeout: 15000}) - await expect(confirmButton).toBeVisible({timeout: 15000}) - await expect(confirmButton).toBeEnabled({timeout: 15000}) - await confirmButton.click({force: true}) + const [createAppResponse] = await Promise.all([ + createAppPromise, + (async () => { + await uiHelpers.clickButton("Create", drawer) + const confirmButton = confirmModal.getByRole("button", { + name: "Create", + exact: true, + }) + await expect(confirmModal).toBeVisible({timeout: 15000}) + await expect(confirmButton).toBeVisible({timeout: 15000}) + await expect(confirmButton).toBeEnabled({timeout: 15000}) + await confirmButton.click({force: true}) + })(), + ]) // 6. Wait for the create response and for the confirmation modal // to close. The current create flow returns to /apps; callers // assert the created app is visible there. - const createAppResponse = await createAppPromise expect(createAppResponse.ok()).toBe(true) const response = (await createAppResponse.json()) as CreateAppResponse diff --git a/web/oss/tests/playwright/acceptance/auto-evaluation/assets/types.ts b/web/oss/tests/playwright/acceptance/auto-evaluation/assets/types.ts index 74e1ae6d68d..7f9a131db93 100644 --- a/web/oss/tests/playwright/acceptance/auto-evaluation/assets/types.ts +++ b/web/oss/tests/playwright/acceptance/auto-evaluation/assets/types.ts @@ -1,10 +1,10 @@ -import {GenerationChatRow, GenerationInputRow} from "@/oss/components/Playground/state/types" +export type GenerationChatRow = Record +export type GenerationInputRow = Record import type {ConfigMetadata} from "@agenta/entities/shared/execution" import type {OpenAPISpec} from "@agenta/entities/shared/openapi" import type {Workflow} from "@agenta/entities/workflow" import {BaseFixture} from "@agenta/web-tests/tests/fixtures/base.fixture/types" - -export type InvokedVariant = { +export interface InvokedVariant { variant: Workflow allMetadata: Record inputRow: GenerationInputRow @@ -31,7 +31,7 @@ export enum Role { TOOL = "tool", FUNCTION = "function", } -export type RunAutoEvalFixtureType = { +export interface RunAutoEvalFixtureType { name?: string evaluators: string[] testset: string diff --git a/web/oss/tests/playwright/acceptance/auto-evaluation/tests.ts b/web/oss/tests/playwright/acceptance/auto-evaluation/tests.ts index 377f27c207e..0d86317159e 100644 --- a/web/oss/tests/playwright/acceptance/auto-evaluation/tests.ts +++ b/web/oss/tests/playwright/acceptance/auto-evaluation/tests.ts @@ -173,11 +173,6 @@ const selectAutoEvaluationModalTableInput = async ({ }) => { const activePane = modal.locator(".ant-tabs-tabpane-active").last() const searchInput = activePane.locator('input[placeholder="Search"]').first() - const inputSelector = - 'input[type="checkbox"], input[type="radio"], .ant-checkbox-input, .ant-radio-input' - const controlSelector = - '.ant-checkbox, .ant-checkbox-wrapper, .ant-radio, .ant-radio-wrapper, [role="checkbox"], [role="radio"]' - const selectedTags = modal.locator(".ant-tabs-tab .ant-tag") if (rowText && (await pollLocatorState(() => searchInput.isVisible()))) { await typeIntoLocator(searchInput, rowText) @@ -196,45 +191,22 @@ const selectAutoEvaluationModalTableInput = async ({ : activePane.locator("[data-row-key]").first() await expect(targetRow).toBeVisible({timeout: 30000}) - const targetRowKey = await targetRow.getAttribute("data-row-key") - const stableRow = targetRowKey - ? modal.locator(`[data-row-key="${targetRowKey}"]`).first() - : targetRow - await expect(stableRow).toBeVisible({timeout: 30000}) - - const isSelected = async () => { - const rowClassName = await stableRow.getAttribute("class").catch(() => null) - if (rowClassName?.includes("ant-table-row-selected")) { - return true - } - - const ariaSelected = await stableRow.getAttribute("aria-selected").catch(() => null) - if (ariaSelected === "true") { - return true - } - - const selectionInput = stableRow.locator(inputSelector).first() - if ((await selectionInput.count().catch(() => 0)) > 0) { - return await pollLocatorState(() => selectionInput.isChecked()) - } - - if (typeof rowText === "string") { - return (await selectedTags.filter({hasText: rowText}).count()) > 0 - } + const selectedTabId = await modal.getByRole("tab", {selected: true}).getAttribute("id") + const selectionControl = targetRow + .getByRole("checkbox") + .or(targetRow.getByRole("radio")) + .first() - return false + if (!(await selectionControl.isChecked())) { + await selectionControl.click() } - - if (!(await isSelected())) { - const selectionControl = stableRow.locator(controlSelector).first() - if ((await selectionControl.count().catch(() => 0)) > 0) { - await selectionControl.click({force: true}) - } else { - await stableRow.click({force: true}) - } + // Selection can advance the tab and replace grouped rows. Check the persisted summary. + await expect( + modal.locator(`[id="${selectedTabId}"]`).getByRole("img", {name: "check-circle"}), + ).toBeVisible({timeout: 30000}) + if (rowText) { + await expect(modal.locator(`[id="${selectedTabId}"]`)).toContainText(rowText) } - - await expect.poll(isSelected, {timeout: 30000}).toBe(true) } const waitForAutoResultsPage = async (page: Page, appId: string) => { diff --git a/web/oss/tests/playwright/acceptance/human-annotation/tests.ts b/web/oss/tests/playwright/acceptance/human-annotation/tests.ts index ac0ea34eba9..69570625d62 100644 --- a/web/oss/tests/playwright/acceptance/human-annotation/tests.ts +++ b/web/oss/tests/playwright/acceptance/human-annotation/tests.ts @@ -548,11 +548,6 @@ const selectHumanEvaluationModalTableInput = async ({ }) => { const activePane = getActiveHumanEvaluationPane(modal) const searchInput = activePane.locator('input[placeholder="Search"]').first() - const inputSelector = - 'input[type="checkbox"], input[type="radio"], .ant-checkbox-input, .ant-radio-input' - const controlSelector = - '.ant-checkbox, .ant-checkbox-wrapper, .ant-radio, .ant-radio-wrapper, [role="checkbox"], [role="radio"]' - const selectedTags = modal.locator(".ant-tabs-tab .ant-tag") if (typeof rowText === "string" && (await pollLocatorState(() => searchInput.isVisible()))) { await typeIntoLocator(searchInput, rowText) @@ -570,63 +565,19 @@ const selectHumanEvaluationModalTableInput = async ({ ? activePane.locator("[data-row-key]").filter({hasText: rowText}).first() : activePane.locator("[data-row-key]").first() await expect(targetRow).toBeVisible({timeout: 30000}) - const targetRowKey = await targetRow.getAttribute("data-row-key") - const stableSelectionInput = targetRowKey - ? modal.locator(`[data-row-key="${targetRowKey}"]`).locator(inputSelector).first() - : targetRow.locator(inputSelector).first() - const stableRow = targetRowKey - ? modal.locator(`[data-row-key="${targetRowKey}"]`).first() - : targetRow - await expect(stableRow).toBeVisible({timeout: 30000}) - - const isSelected = async () => { - const rowClassName = await stableRow.getAttribute("class").catch(() => null) - if (rowClassName?.includes("ant-table-row-selected")) { - return true - } - - const ariaSelected = await stableRow.getAttribute("aria-selected").catch(() => null) - if (ariaSelected === "true") { - return true - } - - if ((await stableSelectionInput.count().catch(() => 0)) > 0) { - return await pollLocatorState(() => stableSelectionInput.isChecked()) - } - - if (typeof rowText === "string") { - return (await selectedTags.filter({hasText: rowText}).count()) > 0 - } - - return false - } - - if (!(await isSelected())) { - const selectionControl = stableRow.locator(controlSelector).first() - if ((await selectionControl.count().catch(() => 0)) > 0) { - await selectionControl.click({force: true}) - } else { - await stableRow.click({force: true}) - } - } - - if (_inputType === "radio" && typeof rowText === "string") { - await expect - .poll( - async () => { - if (await isSelected()) { - return true - } + const selectedTabId = await modal.getByRole("tab", {selected: true}).getAttribute("id") + const selectionControl = targetRow + .getByRole("checkbox") + .or(targetRow.getByRole("radio")) + .first() - return (await selectedTags.filter({hasText: rowText}).count()) > 0 - }, - {timeout: 30000}, - ) - .toBe(true) - return + if (!(await selectionControl.isChecked())) { + await selectionControl.click() } - - await expect.poll(isSelected, {timeout: 30000}).toBe(true) + // Selection can advance the tab and replace grouped rows. Check the persisted summary. + await expect( + modal.locator(`[id="${selectedTabId}"]`).getByRole("img", {name: "check-circle"}), + ).toBeVisible({timeout: 30000}) } const waitForHumanEvaluatorPane = async (modal: Locator) => { @@ -663,59 +614,7 @@ const ensureSingleHumanEvaluatorSelection = async ({ modal: Locator evaluatorName?: string }) => { - const activePane = await waitForHumanEvaluatorPane(modal) - - if (!evaluatorName) { - const firstEvaluatorRow = activePane.locator("[data-row-key]").first() - const hasSelectedEvaluator = async () => { - // evaluateAll runs over every matched row regardless of count, so it can never - // throw a strict-mode violation — pollLocatorState would add nothing here. - const selectedRows = await activePane - .locator("[data-row-key]") - .evaluateAll((rows) => - rows.some( - (row) => - row.className.includes("ant-table-row-selected") || - row.getAttribute("aria-selected") === "true", - ), - ) - .catch(() => false) - - if (selectedRows) { - return true - } - - return ( - (await activePane - .locator('input[type="checkbox"]:checked, input[type="radio"]:checked') - .count()) > 0 - ) - } - - await expect(firstEvaluatorRow).toBeVisible({timeout: 30000}) - - await expect - .poll( - async () => { - if (await hasSelectedEvaluator()) return true - - const checkboxes = activePane.getByRole("checkbox") - if ((await checkboxes.count()) > 1) { - await checkboxes - .nth(1) - .click({force: true}) - .catch(() => null) - } else { - await firstEvaluatorRow.click({force: true}).catch(() => null) - } - - return hasSelectedEvaluator() - }, - {timeout: 30000}, - ) - .toBe(true) - return - } + await waitForHumanEvaluatorPane(modal) await selectHumanEvaluationModalTableInput({ modal, diff --git a/web/oss/tests/playwright/acceptance/observability/index.ts b/web/oss/tests/playwright/acceptance/observability/index.ts index ca0ea5b8579..62fae7bac25 100644 --- a/web/oss/tests/playwright/acceptance/observability/index.ts +++ b/web/oss/tests/playwright/acceptance/observability/index.ts @@ -254,7 +254,7 @@ const observabilityTests = () => { ) // Use the search input to filter by content - const searchInput = page.getByRole("searchbox").first() + const searchInput = page.getByLabel("Search observability data") await expect(searchInput).toBeVisible({timeout: 10000}) // Typing a search term narrows the table; press Enter to apply @@ -302,6 +302,11 @@ const observabilityTests = () => { // Click the first data row to open the trace drawer. The virtual table body // does not expose stable cell tags, but the row click handler is the behavior // users rely on. + const selectedSpanName = await getFirstTraceRow(page) + .locator("span[title]") + .first() + .getAttribute("title") + expect(selectedSpanName).toBeTruthy() await clickFirstTraceRow(page) // TraceDrawer renders through EnhancedDrawer, a facade over the @agenta/ui @@ -315,10 +320,12 @@ const observabilityTests = () => { const treeSearchInput = drawer.getByPlaceholder("Search in tree") await expect(treeSearchInput).toBeVisible({timeout: 10000}) - // Each span in the tree renders a square avatar (AvatarTreeContent → antd Avatar - // shape="square"). At least one confirms the tree has nodes. - const spanAvatar = drawer.locator(".ant-avatar-square").first() - await expect(spanAvatar).toBeVisible({timeout: 10000}) + await expect( + drawer + .getByTestId("trace-tree") + .getByText(selectedSpanName!, {exact: true}) + .first(), + ).toBeVisible({timeout: 10000}) }, ) }) @@ -336,33 +343,24 @@ const observabilityTests = () => { testProviderHelpers, ) - // The three trace-type tabs are AntD Radio.Buttons: Root | LLM | All - const rootTab = page - .locator(".ant-radio-button-wrapper") - .filter({hasText: "Root"}) - .first() - const llmTab = page - .locator(".ant-radio-button-wrapper") - .filter({hasText: "LLM"}) - .first() - const allTab = page - .locator(".ant-radio-button-wrapper") - .filter({hasText: "All"}) - .first() + const traceProjection = page.getByRole("radiogroup", {name: "Trace projection"}) + const rootTab = traceProjection.getByRole("radio", {name: "Root", exact: true}) + const llmTab = traceProjection.getByRole("radio", {name: "LLM", exact: true}) + const allTab = traceProjection.getByRole("radio", {name: "All", exact: true}) await expect(rootTab).toBeVisible({timeout: 10000}) // Switch to LLM await llmTab.click() - await expect(llmTab).toHaveClass(/ant-radio-button-wrapper-checked/, {timeout: 5000}) + await expect(llmTab).toBeChecked({timeout: 5000}) // Switch to All await allTab.click() - await expect(allTab).toHaveClass(/ant-radio-button-wrapper-checked/, {timeout: 5000}) + await expect(allTab).toBeChecked({timeout: 5000}) // Switch back to Root await rootTab.click() - await expect(rootTab).toHaveClass(/ant-radio-button-wrapper-checked/, {timeout: 5000}) + await expect(rootTab).toBeChecked({timeout: 5000}) }, ) diff --git a/web/oss/tests/playwright/acceptance/playground/assets/types.ts b/web/oss/tests/playwright/acceptance/playground/assets/types.ts index a0fb40d9591..f9541200c81 100644 --- a/web/oss/tests/playwright/acceptance/playground/assets/types.ts +++ b/web/oss/tests/playwright/acceptance/playground/assets/types.ts @@ -1,10 +1,12 @@ -import {GenerationChatRow, GenerationInputRow} from "@/oss/components/Playground/state/types" import type {ConfigMetadata} from "@agenta/entities/shared/execution" import type {OpenAPISpec} from "@agenta/entities/shared/openapi" import type {Workflow} from "@agenta/entities/workflow" import {BaseFixture} from "@agenta/web-tests/tests/fixtures/base.fixture/types" -export type InvokedVariant = { +export type GenerationChatRow = Record +export type GenerationInputRow = Record + +export interface InvokedVariant { variant: Workflow allMetadata: Record inputRow: GenerationInputRow @@ -19,7 +21,7 @@ export type InvokedVariant = { headers: Record projectId: string messageId?: string - chatHistory?: any[] + chatHistory?: unknown[] spec: OpenAPISpec runId: string } diff --git a/web/oss/tests/playwright/acceptance/playground/tests.ts b/web/oss/tests/playwright/acceptance/playground/tests.ts index 5cee6221738..83d963ddd3a 100644 --- a/web/oss/tests/playwright/acceptance/playground/tests.ts +++ b/web/oss/tests/playwright/acceptance/playground/tests.ts @@ -350,10 +350,7 @@ const testWithVariantFixtures = baseTest.extend({ } // 1. Click on the save button - const commitButton = page - .locator("button.ant-btn-primary") - .filter({hasText: "Commit"}) - .first() + const commitButton = page.getByRole("button", {name: "Commit", exact: true}) const isCommitButtonDisabled = await commitButton.isDisabled() if (!isCommitButtonDisabled) { diff --git a/web/oss/tests/playwright/acceptance/prompts/test.ts b/web/oss/tests/playwright/acceptance/prompts/test.ts index ac945f22684..1823c74d26d 100644 --- a/web/oss/tests/playwright/acceptance/prompts/test.ts +++ b/web/oss/tests/playwright/acceptance/prompts/test.ts @@ -79,30 +79,31 @@ const testWithPromptsFixtures = baseTest.extend({ return payload.includes(promptName) }) - const createButton = drawer.getByRole("button", {name: "Create", exact: true}).first() - await expect(createButton).toBeVisible({timeout: 15000}) - await expect(createButton).toBeEnabled({timeout: 15000}) - await createButton.click() - - // Match the dialog by role, not by a component-library class: - // `EntityCommitModal` renders through `EnhancedModal`, now a facade over - // the @agenta/ui (Radix) `Dialog`, so no `.ant-modal-wrap` exists here. - // Radix `aria-hidden`s the launching antd drawer while the modal is open, - // so exactly one dialog resolves. - const confirmModal = page - .getByRole("dialog") - .filter({has: page.getByRole("button", {name: "Create", exact: true})}) - .last() - const confirmButton = confirmModal.getByRole("button", { - name: "Create", + const confirmModal = page.getByRole("dialog", { + name: "Create changes", exact: true, }) - await expect(confirmModal).toBeVisible({timeout: 15000}) - await expect(confirmButton).toBeVisible({timeout: 15000}) - await expect(confirmButton).toBeEnabled({timeout: 15000}) - await confirmButton.click({force: true}) + const [createPromptResponse] = await Promise.all([ + createPromptPromise, + (async () => { + const createButton = drawer + .getByRole("button", {name: "Create", exact: true}) + .first() + await expect(createButton).toBeVisible({timeout: 15000}) + await expect(createButton).toBeEnabled({timeout: 15000}) + await createButton.click() + + const confirmButton = confirmModal.getByRole("button", { + name: "Create", + exact: true, + }) + await expect(confirmModal).toBeVisible({timeout: 15000}) + await expect(confirmButton).toBeVisible({timeout: 15000}) + await expect(confirmButton).toBeEnabled({timeout: 15000}) + await confirmButton.click({force: true}) + })(), + ]) - const createPromptResponse = await createPromptPromise expect(createPromptResponse.ok()).toBe(true) await expect(confirmModal).toBeHidden({timeout: 15000}) }) diff --git a/web/oss/tests/playwright/acceptance/testsset/testset-management.ts b/web/oss/tests/playwright/acceptance/testsset/testset-management.ts index f217cd6df5e..af049656f86 100644 --- a/web/oss/tests/playwright/acceptance/testsset/testset-management.ts +++ b/web/oss/tests/playwright/acceptance/testsset/testset-management.ts @@ -209,10 +209,7 @@ const testsetTests = () => { }) // Click a data row cell to open the TestcaseEditDrawer - const cell = page - .locator(".ant-table-cell") - .filter({hasText: "original value"}) - .first() + const cell = page.getByRole("cell", {name: "original value", exact: true}) await expect(cell).toBeVisible({timeout: 10000}) await cell.click() @@ -308,9 +305,9 @@ const testsetTests = () => { await expect(page.locator("th").filter({hasText: "input"}).first()).toBeVisible( {timeout: 10000}, ) - await expect(page.locator(".ant-table-tbody")).toContainText("existing row", { - timeout: 10000, - }) + await expect( + page.getByRole("row").filter({hasText: "existing row"}).first(), + ).toBeVisible({timeout: 10000}) // Add a new row (auto-opens the edit drawer for the new row) await page.getByRole("button", {name: "Add row"}).click() @@ -339,11 +336,10 @@ const testsetTests = () => { page.locator("[data-row-key]").filter({hasText: "new-row-value"}).first(), ).toBeVisible({timeout: 5000}) - // Add a new column — the button has a PlusOutlined (anticon-plus) icon and - // sits in the table header before the column-visibility gear button. - // ant-table-cell-fix-right may not be applied without horizontal scroll, - // so locate by the AntD icon class which is unique in the thead. - await page.locator(".ant-table-thead .anticon-plus").click() + await page + .getByRole("columnheader") + .getByRole("button", {name: "plus", exact: true}) + .click() const addColumnModal = page .locator(".ant-modal") diff --git a/web/oss/tests/playwright/acceptance/use-api/index.ts b/web/oss/tests/playwright/acceptance/use-api/index.ts index 2055197acf2..7024c3949ef 100644 --- a/web/oss/tests/playwright/acceptance/use-api/index.ts +++ b/web/oss/tests/playwright/acceptance/use-api/index.ts @@ -124,13 +124,12 @@ const openVariantUseApiDrawer = async (page: any) => { // so select the first row explicitly when nothing is checked. // Scoped to the registry table's BODY rows: the header select-all and any checkbox // outside the table must neither satisfy the check nor receive the click. - const checkedRow = page.locator(".ant-table-tbody .ant-checkbox-checked").first() - if (!(await checkedRow.isVisible().catch(() => false))) { - const firstRowCheckbox = page.locator(".ant-table-tbody .ant-checkbox-input").first() - await expect(firstRowCheckbox).toBeVisible({timeout: 15000}) + const firstRowCheckbox = page.getByRole("checkbox", {name: /^Select row /}).first() + await expect(firstRowCheckbox).toBeVisible({timeout: 15000}) + if (!(await firstRowCheckbox.isChecked())) { await firstRowCheckbox.click() } - await expect(checkedRow).toBeVisible({timeout: 15000}) + await expect(firstRowCheckbox).toBeChecked({timeout: 15000}) const useApiButton = page.locator('[data-tour="api-code-button"]') await expect(useApiButton).toBeVisible({timeout: 15000}) await expect(useApiButton).toBeEnabled({timeout: 5000}) diff --git a/web/oss/tests/playwright/unit/api-helpers.spec.ts b/web/oss/tests/playwright/unit/api-helpers.spec.ts index 98cdb5c5d9c..769000a9322 100644 --- a/web/oss/tests/playwright/unit/api-helpers.spec.ts +++ b/web/oss/tests/playwright/unit/api-helpers.spec.ts @@ -36,6 +36,15 @@ test("latest revision flags classify prompt apps", () => { expect(appMatchesType(artifact, "custom", {flags: {is_custom: true}})).toBe(true) }) +test("an app with only a seed revision is not reused as a completion prompt", () => { + const artifact = app({is_application: true}) + const revisions = selectLatestAppRevisions([ + {workflow_id: artifact.id, version: "0", flags: {is_agent: true}}, + ]) + + expect(appMatchesType(artifact, "completion", revisions.get(artifact.id))).toBe(false) +}) + test("latest revision selection ignores v0 records", () => { const latestByAppId = selectLatestAppRevisions([ { diff --git a/web/package.json b/web/package.json index 24b61ea4b00..cc0a88ef4e7 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "agenta-web", - "version": "0.114.4", + "version": "0.115.1", "workspaces": [ "ee", "mobile", diff --git a/web/packages/agenta-annotation/src/state/controllers/annotationFormController.ts b/web/packages/agenta-annotation/src/state/controllers/annotationFormController.ts index d1092b00d3c..8748d94bb2b 100644 --- a/web/packages/agenta-annotation/src/state/controllers/annotationFormController.ts +++ b/web/packages/agenta-annotation/src/state/controllers/annotationFormController.ts @@ -62,8 +62,8 @@ import {axios, getAgentaApiUrl, getHostQueryClient} from "@agenta/shared/api" import {projectIdAtom} from "@agenta/shared/state" import deepEqual from "fast-deep-equal" import {atom, type Getter} from "jotai" -import {atomFamily} from "jotai/utils" import {getDefaultStore} from "jotai/vanilla" +import {atomFamily} from "jotai-family" import {mergeTestcaseAnnotationTags, selectQueueScopedAnnotation} from "../testsetSync" import type { diff --git a/web/packages/agenta-api-client/package.json b/web/packages/agenta-api-client/package.json index 4c3265debab..ea2c50c5da0 100644 --- a/web/packages/agenta-api-client/package.json +++ b/web/packages/agenta-api-client/package.json @@ -1,6 +1,6 @@ { "name": "@agentaai/api-client", - "version": "0.114.4", + "version": "0.115.1", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts index 2155960e439..6f796e6e95c 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts @@ -2536,4 +2536,148 @@ export class SessionsClient { return handleNonStatusCodeError(_response.error, _response.rawResponse, "POST", "/sessions/unarchive"); } + + /** + * @param {AgentaApi.CancelSessionExecutionRequest} request + * @param {SessionsClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.sessions.cancelSessionExecution({ + * session_id: "session_id", + * body: {} + * }) + */ + public cancelSessionExecution( + request: AgentaApi.CancelSessionExecutionRequest, + requestOptions?: SessionsClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__cancelSessionExecution(request, requestOptions)); + } + + private async __cancelSessionExecution( + request: AgentaApi.CancelSessionExecutionRequest, + requestOptions?: SessionsClient.RequestOptions, + ): Promise> { + const { session_id: sessionId, body: _body } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `sessions/${core.url.encodePathParam(sessionId)}/cancel`, + ), + method: "POST", + headers: _headers, + contentType: "application/json", + queryParameters: requestOptions?.queryParams, + requestType: "json", + body: _body, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "POST", + "/sessions/{session_id}/cancel", + ); + } + + /** + * Fetch a consistent reconnect snapshot and its durable sequence watermark. + * + * @param {AgentaApi.GetSessionSnapshotRequest} request + * @param {SessionsClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + */ + public getSessionSnapshot( + request: AgentaApi.GetSessionSnapshotRequest, + requestOptions?: SessionsClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__getSessionSnapshot(request, requestOptions)); + } + + private async __getSessionSnapshot( + request: AgentaApi.GetSessionSnapshotRequest, + requestOptions?: SessionsClient.RequestOptions, + ): Promise> { + const { session_id: sessionId } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `sessions/${core.url.encodePathParam(sessionId)}`, + ), + method: "GET", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as AgentaApi.SessionSnapshotResponse, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "GET", "/sessions/{session_id}"); + } } diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/CancelSessionExecutionRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/CancelSessionExecutionRequest.ts new file mode 100644 index 00000000000..d3cb6ce31e2 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/CancelSessionExecutionRequest.ts @@ -0,0 +1,15 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../../../../index.js"; + +/** + * @example + * { + * session_id: "session_id", + * body: {} + * } + */ +export interface CancelSessionExecutionRequest { + session_id: string; + body: AgentaApi.SessionCancelRequest | null; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/GetSessionSnapshotRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/GetSessionSnapshotRequest.ts new file mode 100644 index 00000000000..25ca5cedb26 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/GetSessionSnapshotRequest.ts @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * session_id: "session_id" + * } + */ +export interface GetSessionSnapshotRequest { + session_id: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionRecordQueryRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionRecordQueryRequest.ts index 4d26e502d8e..8f82e13f76c 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionRecordQueryRequest.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionRecordQueryRequest.ts @@ -1,5 +1,7 @@ // This file was auto-generated by Fern from our API Definition. +import type * as AgentaApi from "../../../../index.js"; + /** * @example * { @@ -8,4 +10,5 @@ */ export interface SessionRecordQueryRequest { session_id: string; + windowing?: AgentaApi.SessionTranscriptWindowing | undefined; } diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionStreamCommandRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionStreamCommandRequest.ts index 5d66eef4a2f..a2f69e304b0 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionStreamCommandRequest.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionStreamCommandRequest.ts @@ -13,4 +13,5 @@ export interface SessionStreamCommandRequest { data?: AgentaApi.WorkflowRequestData | null; force?: boolean; detached?: boolean; + expected_execution_id?: string | null; } diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts index 5107e65e7d3..c6f8ae0fe7c 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts @@ -1,5 +1,6 @@ export type { ArchiveSessionRequest } from "./ArchiveSessionRequest.js"; export type { BodyUploadSessionMountFile } from "./BodyUploadSessionMountFile.js"; +export type { CancelSessionExecutionRequest } from "./CancelSessionExecutionRequest.js"; export type { CreateSessionAttachmentRequest } from "./CreateSessionAttachmentRequest.js"; export type { DeleteSessionRequest } from "./DeleteSessionRequest.js"; export type { DeleteSessionStreamRequest } from "./DeleteSessionStreamRequest.js"; @@ -9,6 +10,7 @@ export type { FetchInteractionRequest } from "./FetchInteractionRequest.js"; export type { FetchSessionMountsRequest } from "./FetchSessionMountsRequest.js"; export type { FetchSessionStreamRequest } from "./FetchSessionStreamRequest.js"; export type { FetchTurnRequest } from "./FetchTurnRequest.js"; +export type { GetSessionSnapshotRequest } from "./GetSessionSnapshotRequest.js"; export type { GetRecordEventRequest } from "./GetRecordEventRequest.js"; export type { SessionAttachmentReferenceRequest } from "./SessionAttachmentReferenceRequest.js"; export type { SessionDetachRequest } from "./SessionDetachRequest.js"; diff --git a/web/packages/agenta-api-client/src/generated/api/types/CustomSecretSettingsDto.ts b/web/packages/agenta-api-client/src/generated/api/types/CustomSecretSettingsDto.ts index 5c8b02bb272..cdacadfa0ef 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/CustomSecretSettingsDto.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/CustomSecretSettingsDto.ts @@ -4,6 +4,7 @@ import type * as AgentaApi from "../index.js"; export interface CustomSecretSettingsDto { format: AgentaApi.CustomSecretFormat; + default_env_var?: (string | null) | undefined; content?: (CustomSecretSettingsDto.Content | null) | undefined; } diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionCancelRequest.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionCancelRequest.ts new file mode 100644 index 00000000000..5010870009e --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionCancelRequest.ts @@ -0,0 +1,5 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface SessionCancelRequest { + expected_execution_id?: (string | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionRecord.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionRecord.ts index 8f868ae1761..d6394c0cd94 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionRecord.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionRecord.ts @@ -10,6 +10,7 @@ export interface SessionRecord { record_id: string; session_id: string; project_id: string; + sequence?: (number | null) | undefined; record_index?: (number | null) | undefined; timestamp?: (string | null) | undefined; record_type?: (string | null) | undefined; diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionRecordsQueryResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionRecordsQueryResponse.ts index 306892fcec6..cead75fae9f 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionRecordsQueryResponse.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionRecordsQueryResponse.ts @@ -5,4 +5,5 @@ import type * as AgentaApi from "../index.js"; export interface SessionRecordsQueryResponse { count: number; records: AgentaApi.SessionRecord[]; + windowing?: (AgentaApi.SessionTranscriptWindowing | null) | undefined; } diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionRecordsReadState.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionRecordsReadState.ts new file mode 100644 index 00000000000..f4c1e63501e --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionRecordsReadState.ts @@ -0,0 +1,6 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface SessionRecordsReadState { + latest_sequence: number; + history_complete: boolean; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotPending.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotPending.ts new file mode 100644 index 00000000000..9a107715202 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotPending.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface SessionSnapshotPending { + inputs?: unknown[] | undefined; + interactions?: AgentaApi.SessionInteraction[] | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotResponse.ts new file mode 100644 index 00000000000..3253bdc3987 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotResponse.ts @@ -0,0 +1,10 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface SessionSnapshotResponse { + session: AgentaApi.SessionStream; + execution?: (AgentaApi.SessionTurn | null) | undefined; + pending: AgentaApi.SessionSnapshotPending; + read: AgentaApi.SessionRecordsReadState; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionStream.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionStream.ts index 90177651fb0..6e663895c08 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionStream.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionStream.ts @@ -18,6 +18,8 @@ export interface SessionStream { tags?: (Record | null) | undefined; meta?: (Record | null) | undefined; turn_id?: (string | null) | undefined; + turn_started_at?: (string | null) | undefined; + stopping_turn_id?: (string | null) | undefined; references?: (AgentaApi.SessionReference[] | null) | undefined; archived_at?: (string | null) | undefined; origin?: (AgentaApi.SessionOrigin | null) | undefined; diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionStreamCommandResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionStreamCommandResponse.ts index 18d1acc5032..2ae62484a8e 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionStreamCommandResponse.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionStreamCommandResponse.ts @@ -8,4 +8,5 @@ export interface SessionStreamCommandResponse { turn_id?: (string | null) | undefined; watcher_id?: (string | null) | undefined; detached?: boolean | undefined; + cancelled_turn_ids?: string[] | undefined; } diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionTranscriptWindowing.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionTranscriptWindowing.ts new file mode 100644 index 00000000000..7b2d2ba30cc --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionTranscriptWindowing.ts @@ -0,0 +1,7 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface SessionTranscriptWindowing { + offset?: number | undefined; + limit?: number | undefined; + through_sequence: number; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/index.ts b/web/packages/agenta-api-client/src/generated/api/types/index.ts index 42bca07e3be..cad4a931cad 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/index.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/index.ts @@ -378,6 +378,7 @@ export * from "./Selector.js"; export * from "./SessionAttachment.js"; export * from "./SessionAttachmentResponse.js"; export * from "./SessionAttachmentsResponse.js"; +export * from "./SessionCancelRequest.js"; export * from "./SessionDelivery.js"; export * from "./SessionExcludeRequest.js"; export * from "./SessionExpansion.js"; @@ -403,6 +404,9 @@ export * from "./SessionPredicatesRequest.js"; export * from "./SessionRecord.js"; export * from "./SessionRecordResponse.js"; export * from "./SessionRecordsQueryResponse.js"; +export * from "./SessionRecordsReadState.js"; +export * from "./SessionSnapshotPending.js"; +export * from "./SessionSnapshotResponse.js"; export * from "./SessionReference.js"; export * from "./SessionResponse.js"; export * from "./SessionStream.js"; @@ -412,6 +416,7 @@ export * from "./SessionStreamHeaderEdit.js"; export * from "./SessionStreamQueryFlags.js"; export * from "./SessionStreamResponse.js"; export * from "./SessionStreamsResponse.js"; +export * from "./SessionTranscriptWindowing.js"; export * from "./SessionsResponse.js"; export * from "./SessionTrigger.js"; export * from "./SessionTriggerKind.js"; diff --git a/web/packages/agenta-chat/src/assets/agentTurn.ts b/web/packages/agenta-chat/src/assets/agentTurn.ts new file mode 100644 index 00000000000..842d49a59e4 --- /dev/null +++ b/web/packages/agenta-chat/src/assets/agentTurn.ts @@ -0,0 +1,18 @@ +import type {UIMessage} from "ai" + +/** Read the runner-minted turn id from merged stream metadata. */ +export const getMessageTurnId = (message: UIMessage | undefined): string | null => { + const turnId = (message?.metadata as {turnId?: unknown} | undefined)?.turnId + return typeof turnId === "string" && turnId.trim() ? turnId : null +} + +/** Read only the newest assistant turn id; older ids are unsafe Stop guards. */ +export const latestTurnId = (messages: UIMessage[]): string | null => { + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index] + if (message.role === "user") return null + if (message.role !== "assistant") continue + return getMessageTurnId(message) + } + return null +} diff --git a/web/packages/agenta-chat/src/assets/attachmentRules.ts b/web/packages/agenta-chat/src/assets/attachmentRules.ts index 9fb213a589c..3a86a47ef3c 100644 --- a/web/packages/agenta-chat/src/assets/attachmentRules.ts +++ b/web/packages/agenta-chat/src/assets/attachmentRules.ts @@ -98,7 +98,7 @@ export const formatBytes = (n: number): string => { export interface AttachmentRejection { /** The file's name, for the inline message. */ name: string - /** Why it was rejected (verb phrase): "is too large (8.2 MB) · max 10 MB for images". */ + /** Why it was rejected (verb phrase): "is too large, max 10.0 MB supported". */ reason: string } @@ -133,7 +133,7 @@ export const validateIncoming = ( if (file.size > maxBytes) { rejections.push({ name: file.name, - reason: `is too large (${formatBytes(file.size)}) · max ${formatBytes(maxBytes)} for ${KIND_NOUN[kind]}`, + reason: `is too large, max ${formatBytes(maxBytes)} supported`, }) continue } @@ -148,9 +148,34 @@ export const validateIncoming = ( return {accepted, rejections} } -/** A file kind an attachment viewer can preview; audio plays inline in the tray instead. */ +/** Extensions worth spelling out on a card tile; anything else falls back to the real extension. */ +const BADGE_BY_TYPE: Record = { + "application/pdf": "pdf", + "application/json": "json", + "text/csv": "csv", + "text/markdown": "md", + "text/plain": "txt", +} + +/** + * Short lowercase label for a document tile ("csv", "pdf"). Prefers the media type, because a + * download can arrive with a generic name, and falls back to the filename's extension so an + * `application/octet-stream` still reads as something. Capped at four characters — the tile is a + * 32px square, and a longer string renders as a smear. + */ +export const typeBadgeFor = (mediaType: string, name?: string): string => { + const known = BADGE_BY_TYPE[mediaType] + if (known) return known + const ext = name?.includes(".") ? name.split(".").pop()?.toLowerCase() : undefined + if (ext && ext.length <= 4 && /^[a-z0-9]+$/.test(ext)) return ext + const subtype = mediaType.split("/")[1] + return subtype && subtype.length <= 4 ? subtype : "file" +} + +/** A file kind the Files drawer can preview; audio is excluded because it plays in its card. */ export const isViewable = (mediaType: string): boolean => mediaType.startsWith("image/") || + mediaType.startsWith("video/") || mediaType === "application/pdf" || mediaType.startsWith("text/") || mediaType === "application/json" diff --git a/web/packages/agenta-chat/src/assets/composerState.ts b/web/packages/agenta-chat/src/assets/composerState.ts new file mode 100644 index 00000000000..0e02866cd0a --- /dev/null +++ b/web/packages/agenta-chat/src/assets/composerState.ts @@ -0,0 +1,8 @@ +/** A parked approval remains stoppable after streaming pauses. */ +export const shouldShowStopControl = ({ + busy, + hitlPending, +}: { + busy: boolean + hitlPending: boolean +}): boolean => busy || hitlPending diff --git a/web/packages/agenta-chat/src/assets/index.ts b/web/packages/agenta-chat/src/assets/index.ts index 3dc5493b5b1..472c119882d 100644 --- a/web/packages/agenta-chat/src/assets/index.ts +++ b/web/packages/agenta-chat/src/assets/index.ts @@ -2,6 +2,7 @@ export * from "./toolFormat" export * from "./trace" export * from "./attachmentRules" export * from "./attachmentTransport" +export * from "./composerState" export * from "./files" export * from "./rewind" export * from "./transcriptToMessages" @@ -10,3 +11,5 @@ export * from "./conversationLayout" export * from "./jumpToLatest" export * from "./boundedRequest" export {startupLabelFromDataPart} from "./startupPhases" +export {getMessageTurnId, latestTurnId} from "./agentTurn" +export * from "./resolveStopExecution" diff --git a/web/packages/agenta-chat/src/assets/loadSession.ts b/web/packages/agenta-chat/src/assets/loadSession.ts index 960fb0e21f5..d445f05f4b7 100644 --- a/web/packages/agenta-chat/src/assets/loadSession.ts +++ b/web/packages/agenta-chat/src/assets/loadSession.ts @@ -33,11 +33,16 @@ import {transcriptToMessages} from "./transcriptToMessages" export interface SessionTranscript { messages: UIMessage[] /** - * How many durable records this transcript was built from. The log is append-only and ordered, - * so this is an EXACT "has the server moved on?" watermark — unlike a message count, which - * `transcriptToMessages` deliberately holds flat while a turn grows (issue #5530). + * How many durable records this transcript was built from. This remains distinct from the + * sequence cursor because retention can hold the row count flat while the log moves forward. */ recordCount: number + /** + * Highest durable sequence covered by this transcript. Undefined for legacy, unsequenced logs. + * Snapshot hydration supplies its authoritative upper bound even when retention or filtered + * records make the visible sequence values sparse. + */ + sequenceCursor?: number /** * The interaction lifecycle rows this transcript was replayed against (#5942). Records never * carry a row's later lifecycle, so this is the only place the adoption guard can see whether @@ -47,6 +52,25 @@ export interface SessionTranscript { interactionRows?: SessionInteractionRowStates } +/** Runtime boundary for watch callbacks and best-effort transcript reads. */ +export const isSessionTranscript = (value: unknown): value is SessionTranscript => { + if (!value || typeof value !== "object") return false + const candidate = value as Partial + return ( + Array.isArray(candidate.messages) && + typeof candidate.recordCount === "number" && + Number.isFinite(candidate.recordCount) && + (candidate.sequenceCursor === undefined || + (typeof candidate.sequenceCursor === "number" && + Number.isFinite(candidate.sequenceCursor))) + ) +} + +const sequenceCursorForRecords = (records: {sequence?: number | null}[]): number | undefined => { + const cursor = records.reduce((latest, record) => Math.max(latest, record.sequence ?? 0), 0) + return cursor || undefined +} + export const loadSessionMessages = async ( sessionId: string, onRefreshed?: (transcript: SessionTranscript) => void, @@ -72,6 +96,7 @@ export const loadSessionMessages = async ( onRefreshed({ messages: freshMsgs, recordCount: fresh.length, + sequenceCursor: sequenceCursorForRecords(fresh), interactionRows: interactionRowStates, }) } @@ -86,7 +111,12 @@ export const loadSessionMessages = async ( if (!records || records.length === 0) return null const messages = transcriptToMessages(records, {interactionRowStates}) return messages - ? {messages, recordCount: records.length, interactionRows: interactionRowStates} + ? { + messages, + recordCount: records.length, + sequenceCursor: sequenceCursorForRecords(records), + interactionRows: interactionRowStates, + } : null } catch (err) { console.warn("[loadSessionMessages] hydration fetch failed:", err) diff --git a/web/packages/agenta-chat/src/assets/resolveStopExecution.ts b/web/packages/agenta-chat/src/assets/resolveStopExecution.ts new file mode 100644 index 00000000000..c88ea03ffc8 --- /dev/null +++ b/web/packages/agenta-chat/src/assets/resolveStopExecution.ts @@ -0,0 +1,41 @@ +export type StopExecutionResolution = + | {status: "resolved"; executionId: string} + | {status: "settled"} + | {status: "timed_out"} + | {status: "aborted"} + +const waitForPoll = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) + +/** + * Wait for the runner-minted execution id during the short window between sending a turn and + * receiving its first live frame. An unnamed Stop in that window can reach the server before the + * turn is admitted and incorrectly conclude that the session is idle. + */ +export const resolveStopExecution = async ({ + readExecutionId, + isRunActive, + signal, + timeoutMs = 5_000, + pollMs = 25, + now = Date.now, + wait = waitForPoll, +}: { + readExecutionId: () => string | undefined + isRunActive: () => boolean + signal?: AbortSignal + timeoutMs?: number + pollMs?: number + now?: () => number + wait?: (ms: number) => Promise +}): Promise => { + const deadline = now() + timeoutMs + while (true) { + if (signal?.aborted) return {status: "aborted"} + const executionId = readExecutionId() + if (executionId) return {status: "resolved", executionId} + if (!isRunActive()) return {status: "settled"} + const remaining = deadline - now() + if (remaining <= 0) return {status: "timed_out"} + await wait(Math.min(pollMs, remaining)) + } +} diff --git a/web/packages/agenta-chat/src/assets/startupPhases.ts b/web/packages/agenta-chat/src/assets/startupPhases.ts index 350075a01ca..910bfa99118 100644 --- a/web/packages/agenta-chat/src/assets/startupPhases.ts +++ b/web/packages/agenta-chat/src/assets/startupPhases.ts @@ -1,5 +1,8 @@ +// In the order the runner emits them; create_session alone is ~78% of a cold start. const STARTUP_LABELS = { environment_starting: "Starting the agent", + preparing_workspace: "Preparing the workspace", + opening_session: "Opening the agent session", environment_ready: "Agent ready", } as const diff --git a/web/packages/agenta-chat/src/assets/trace.ts b/web/packages/agenta-chat/src/assets/trace.ts index 6fe9d6b11e5..2dbd71a34a9 100644 --- a/web/packages/agenta-chat/src/assets/trace.ts +++ b/web/packages/agenta-chat/src/assets/trace.ts @@ -64,6 +64,11 @@ export const getMessageRunErrorCode = (message: UIMessage): string | undefined = return typeof partCode === "string" && partCode.trim() ? partCode : undefined } +/** A request that never reached Agenta: retryable as-is, and it carries no failure code. */ +export const isMessageRunErrorTransport = (message: UIMessage): boolean => + (message.metadata as {runError?: {transport?: unknown}} | undefined)?.runError?.transport === + true + /** Token/cost fields in `ExecutionMetricsDisplay`'s shape. */ export interface MessageUsageMetrics { promptTokens?: number diff --git a/web/packages/agenta-chat/src/assets/transcriptToMessages.ts b/web/packages/agenta-chat/src/assets/transcriptToMessages.ts index df6e8131317..d834ac38669 100644 --- a/web/packages/agenta-chat/src/assets/transcriptToMessages.ts +++ b/web/packages/agenta-chat/src/assets/transcriptToMessages.ts @@ -71,6 +71,8 @@ interface DraftMessage { runError?: string /** That error's stable failure class (`error.code`), so a reload keeps the callout's action. */ runErrorCode?: string + /** The terminal `done` carried `stopReason:"cancelled"` — a user Stop, not a failure. */ + runStopped?: boolean } interface TranscriptIndex { @@ -579,6 +581,20 @@ export function transcriptToMessages( current.paused = true continue } + if (p.stopReason === "cancelled") { + // Keep a carrier so a content-free cancellation can still render Stopped. + if (!current || current.role !== "assistant") { + current = newDraft(row.id, "assistant") + drafts.push(current) + } + current.runStopped = true + current.paused = false + for (const part of current.parts) { + if (part.state === "approval-requested") part.state = "output-denied" + } + current = null + continue + } // A resumed-then-completed turn is no longer paused. if (current?.paused) current.resumed = true if (current) current.paused = false @@ -597,13 +613,7 @@ export function transcriptToMessages( // Recorded results win; otherwise saved answers, neutral terminal state, then pending. applyInteractionRowStates(index, options?.interactionRowStates) - // A RESUMED turn's gate was answered by definition — the runner only emits post-pause records - // once the user responded (a deny settles its own part via `tool_result denied`). The durable - // log doesn't always persist the `interaction_response`, so settle whatever is left awaiting: - // otherwise a completed turn replays as still parked and the reload keeps the approval dock up. - // Runs AFTER the rows on purpose: this sweep knows only THAT a gate was answered, never how, so - // ahead of them it consumed the `approval-requested` state the row's verdict is applied to, and - // every denied gate replayed as approved. + // A resumed turn's remaining approval gate was answered even when its response row is absent. for (const d of drafts) { if (!d.resumed) continue for (const part of d.parts) { @@ -613,7 +623,7 @@ export function transcriptToMessages( const messages = drafts // A turn whose only content was the failure has no parts — keep it, or the error vanishes. - .filter((d) => d.parts.length > 0 || d.runError) + .filter((d) => d.parts.length > 0 || d.runError || d.runStopped) .map((d) => { // `getMessageTraceId`/`getMessageUsage` read exactly these, so the hover trace actions // and metrics bar light up on reload. traceId stays absent until the backend stamps one; @@ -622,7 +632,8 @@ export function transcriptToMessages( if (d.traceId) metadata.traceId = d.traceId if (d.usage) metadata.usage = d.usage if (d.paused) metadata.paused = true - if (d.runError) + if (d.runStopped) metadata.runStopped = true + if (d.runError && !d.runStopped) metadata.runError = { message: d.runError, ...(d.runErrorCode ? {code: d.runErrorCode} : {}), diff --git a/web/packages/agenta-chat/src/clientTools/index.ts b/web/packages/agenta-chat/src/clientTools/index.ts index c482f893d2d..16158eac3dd 100644 --- a/web/packages/agenta-chat/src/clientTools/index.ts +++ b/web/packages/agenta-chat/src/clientTools/index.ts @@ -14,3 +14,5 @@ export { getPendingElicitationInteractions, hasEarlierElicitationDegradation, } from "./elicitationInteractions" + +export {getPendingSecretInteractions} from "./secretInteractions" diff --git a/web/packages/agenta-chat/src/clientTools/secretInteractions.ts b/web/packages/agenta-chat/src/clientTools/secretInteractions.ts new file mode 100644 index 00000000000..0d0a272224b --- /dev/null +++ b/web/packages/agenta-chat/src/clientTools/secretInteractions.ts @@ -0,0 +1,19 @@ +import {buildRenderMap, isPendingClientToolInteraction} from "@agenta/playground" +import {canonicalClientToolName} from "@agenta/shared/clientTools" +import type {ToolUIPart, UIMessage} from "ai" + +import {clientToolMeta} from "./meta" + +export const getPendingSecretInteractions = (messages: UIMessage[]) => { + const message = messages[messages.length - 1] + if (message?.role !== "assistant") return [] + const renderMap = buildRenderMap(message.parts as {type?: string; data?: unknown}[]) + return message.parts + .filter((part) => isPendingClientToolInteraction(part, renderMap)) + .map((part) => clientToolMeta(part as ToolUIPart, renderMap)) + .filter( + (meta) => + meta.renderKind === "secret" || + canonicalClientToolName(meta.toolName) === "request_secret", + ) +} diff --git a/web/packages/agenta-chat/src/components/ApprovalCard.tsx b/web/packages/agenta-chat/src/components/ApprovalCard.tsx index 725cfe252f0..0b68010ee67 100644 --- a/web/packages/agenta-chat/src/components/ApprovalCard.tsx +++ b/web/packages/agenta-chat/src/components/ApprovalCard.tsx @@ -10,6 +10,7 @@ */ import {useEffect, useId, useMemo, useRef, useState} from "react" +import {useToolIntegrationDetail} from "@agenta/entities/gatewayTool" import {isOnScreen, isOverlayOpen, shortcutAria} from "@agenta/shared/utils" import {HeightCollapse} from "@agenta/ui/height-collapse" import {ShortcutKeys} from "@agenta/ui/shortcuts" @@ -106,9 +107,14 @@ export const ApprovalCard = ({ // A commit gate parses its whole delta + manifest, so memoize on the gate id (a gate's payload // is immutable) rather than re-parsing on every keystroke and `responding` toggle. + const base = useMemo(() => (current ? describeApproval(current) : null), [current?.approvalId]) + // The catalog answers late, so re-describe once it names the slug (#6349). Disabled on "". + const sourceKey = base?.sourceKey ?? "" + const {integration} = useToolIntegrationDetail(sourceKey) + const appName = sourceKey ? integration?.name : undefined const preview = useMemo( - () => (current ? describeApproval(current) : null), - [current?.approvalId], + () => (current && appName ? describeApproval(current, appName) : base), + [current?.approvalId, appName, base], ) // A batch answers as a whole, so the rows list the pending ACTIONS rather than one gate's // changes — this is what replaced the peek popover. diff --git a/web/packages/agenta-chat/src/components/AttachmentCard.tsx b/web/packages/agenta-chat/src/components/AttachmentCard.tsx new file mode 100644 index 00000000000..084dc0ab537 --- /dev/null +++ b/web/packages/agenta-chat/src/components/AttachmentCard.tsx @@ -0,0 +1,265 @@ +import {useEffect, useRef, useState} from "react" + +import { + ArrowClockwise, + DownloadSimple, + ImageBroken, + Pause, + Play, + WarningCircle, + X, +} from "@phosphor-icons/react" + +import {typeBadgeFor} from "../assets/attachmentRules" + +/** One height for every card: uniformity is what lets the grid wrap without ragged rows. */ +const CARD_HEIGHT = "h-9" + +/** Leading square — thumbnail, play control or type badge — always the same box. */ +const TILE = "h-6 w-6 shrink-0 rounded" + +export type AttachmentCardState = "idle" | "uploading" | "error" + +/** What sits at the card's trailing edge. The composer removes; a sent message downloads. */ +export type AttachmentCardAction = "remove" | "download" | "none" + +export interface AttachmentCardProps { + name: string + mediaType: string + /** Thumbnail for images and the source for audio playback; absent while it resolves. */ + src?: string + /** The source is still resolving — show a placeholder rather than a broken thumbnail. */ + loading?: boolean + state?: AttachmentCardState + /** 0-100, drawn as a bar along the bottom edge while `state` is "uploading". */ + progress?: number + /** Replaces the filename when `state` is "error" — "too large", "upload failed". */ + errorReason?: string + action?: AttachmentCardAction + onRemove?: () => void + onDownload?: () => void + /** Re-run a failed upload. Rejections never had one, so they pass nothing. */ + onRetry?: () => void + /** Opens the attachment in a viewer. Audio never uses this: it plays in place. */ + onView?: () => void + className?: string +} + +/** Trailing control, a sibling of the view surface: a button inside a button is unreachable. */ +const CardAction = ({ + action, + name, + onRemove, + onDownload, +}: Pick) => { + if (action === "remove" && onRemove) { + return ( + + ) + } + // Revealed on hover of THIS card only: the turn row is itself a bare `group`, so an unnamed + // group-hover lit up every card in the message at once. Hidden only where hovering exists. + if (action === "download" && onDownload) { + return ( + + ) + } + return null +} + +/** Play/pause only — a transport bar would not fit a one-row card. */ +const AudioTile = ({src, name}: {src?: string; name: string}) => { + const ref = useRef(null) + const [playing, setPlaying] = useState(false) + + useEffect(() => { + const el = ref.current + if (!el) return + const onPlay = () => setPlaying(true) + const onStop = () => setPlaying(false) + el.addEventListener("play", onPlay) + el.addEventListener("pause", onStop) + el.addEventListener("ended", onStop) + return () => { + el.removeEventListener("play", onPlay) + el.removeEventListener("pause", onStop) + el.removeEventListener("ended", onStop) + } + // The

+ + + + + + + + + + + + + + + + + + + + + + + + +
WhereCopy
Card headerThe agent is waiting for you
Card body{name} — {reason} · Proposed environment variable {ENV} · saved to {agent} · {variant}
Card actionsNot now · Configure
Transcript marker (pending)Set up {name} below
Card (after save)Saved to {agent} · {variant} — Configuration saved to {agent} · {variant}. → Applying credentials and resuming the conversation…
Transcript marker (settled){ENV} attached · {name} not configured · Set up anyway
Card (after reload)Ready to continue — {ENV} is attached to {agent} · {variant}. The conversation is paused where the agent asked for it. · Continue
Run error (resume failed)The agent couldn't resume this conversation. Your configuration is saved — nothing needs to be entered again. · Retry run
Run error (secret deleted)The run stopped before the agent started: the secret behind {ENV} is no longer in this project. Replace it or remove the attachment in Secrets, then send again.
Drawer title / subtitleAttach secret — Attached to [{agent} · {variant}] · Edit binding — Saved to [...] · Replace secret
Target mismatch noteYou're now viewing {selected}. This secret will still be attached to {target}, the agent that asked for it.
PickerChoose existing · Create new · Only text secrets can be attached. JSON secrets stay available for other uses. · JSON — can't be attached
Create form hintsA label for people. Shown in Settings and in this agent's Secrets list. · Sent straight to the project vault. It won't appear in the conversation or be shown again.
Advanced (drawer)Create new: Environment variable (optional) — How the agent's scripts read it. Leave blank to use {DERIVED}[ and the agent's request]. · Choose existing / edit: Advanced · Available to the agent as {ENV} · Environment variable name · Derived from the secret's name. Rename it if the agent's scripts expect something else.
ValidationEnter an environment variable name. · Use letters, digits and underscores only, and don't start with a digit. · {X} is reserved by the system and can't be overridden. · {X} is already set by the model connection. · {X} is already used by {secret} on this agent. · (warning) Environment variables are conventionally upper case.
Footer actionsCancel · Attach → Saving secret… → Attaching… · Save (edit) · Retry attach
BannersThe secret couldn't be saved — Nothing was attached. Your entries are still here — check them and try again. · Saved in vault; not attached. — {name} is saved to the project, but attaching it to {target} failed. Retry attaches the saved secret without creating it again. · {target} was changed by someone else — … Reload to review the current configuration, then attach again. · Reload configuration
Secrets sectionAdvanced (panel row, summary "Sandbox: daytona · N secrets") · Secrets (group) · Project secrets exposed to the agent's scripts as environment variables. · No secrets attached — Attach a project secret, for example a GitHub token as GITHUB_TOKEN. · Attach secret · Secret unavailable · Replace secret
Remove confirmation (inline)Remove {ENV} from {agent}? The secret stays in the project vault. This takes effect before the next run and may restart the agent's processes; the conversation and saved files are kept. · Remove attachment · Keep
Unsaved editsSave or discard your unsaved changes before attaching a secret. The attachment is saved as its own version, so it can't include edits you haven't reviewed.
PermissionsYou don't have permission to attach secrets to this agent. Ask a project admin to attach {ENV}, then send your request again. · You can view attached secrets but not change them… · You can attach existing project secrets but not create new ones. Ask a project admin to add a secret first.
+ + + +
+

State transitions and action behavior

+
    +
  • Request card: pending → configuring (drawer open; closing the drawer returns to pending, it is not a cancel) → saved (binding committed, conversation config updated) → resuming (settle configured, submit next run) → done. pending → cancelled via Not now (settle cancelled once). resuming → resumeFailed keeps the configured result; Retry run resubmits the same saved revision. Reload with binding present and request unsettled → recovered; Continue → resuming.
  • +
  • Drawer submit: guarded by phase (idle only) and by the conflict banner. Create path: save secret → lock picker to the saved reference → attach. Attach path: commit revision with base_revision_id. Failures return to idle with a banner; the saved reference survives in the drawer for retry. Raw content is cleared the moment the vault accepts it and on close.
  • +
  • Target: fixed at open (originating agent/variant for cards, selected agent for configuration). Switching the header selection shows the mismatch note and does not change the target.
  • +
  • Unsaved edits: Add secret / Configure show the blocking notice and route to the existing Save / Discard bar; no side-effect save.
  • +
  • Config list actions: Edit binding opens the drawer in edit mode (replace secret and/or rename variable, value never shown). Remove uses an inline confirmation (not a modal) and removes only the attachment. Replace secret on an unavailable row is edit mode with a warning and the variable kept.
  • +
  • Duplicate events: Attach, Continue, Retry run and Not now are idempotent on status; the dock and marker never both offer actions for the same request.
  • +
  • Mobile: single column with Chat / Configure tabs; the drawer is a full-screen sheet; card actions are 40px targets.
  • +
+
+ +
+

Assumptions and open decisions

+
    +
  • Visuals follow the v0.112.0 warm palette from the design system; the entity-ui drawer and connection dock were read from source, not rendered. The shipped shadcn Button/Drawer skins may differ slightly.
  • +
  • Per review: the variable name is derived from the secret (or the agent's proposal) and lives under Advanced; the footer disclosure sentence was removed. The brief asked for that sentence near Save — confirm dropping it is intended.
  • +
  • The picker defaults to Choose existing even when the agent asked for a secret. If telemetry shows most requests end in Create new, flip the default when no name-matching text secret exists.
  • +
  • Closing the drawer with Cancel returns the card to pending rather than settling the request; only Not now settles it. Confirm this matches the connect-flow contract.
  • +
  • Slugs are hidden everywhere in this flow (per review). Two secrets with the same display name are indistinguishable in the picker — decide whether Settings should enforce unique names or the picker should show a disambiguator on collision.
  • +
  • Conflict handling reloads the target revision in place and asks the user to attach again; it never merges. If the other user attached the same variable name, validation now flags the duplicate.
  • +
  • "Set up anyway" on a cancelled marker opens the configuration-origin drawer; the agent re-asks only when the user sends a new message. Alternative: re-issue the request automatically on attach.
  • +
  • Restart consequence copy now appears only in the inline remove confirmation (the edit-mode note was removed per review). Decide whether replace/rename should warn at all.
  • +
  • Permission split assumed: attach requires agent-edit + secret-edit; create additionally requires vault write. Copy addresses both cases without naming permissions.
  • +
+
+ + + + diff --git a/web/storybook/public/agent-custom-secrets/_ds/agenta-design-system-0263eb0f-a904-4476-a73b-7afca7b6d749/README.md b/web/storybook/public/agent-custom-secrets/_ds/agenta-design-system-0263eb0f-a904-4476-a73b-7afca7b6d749/README.md new file mode 100644 index 00000000000..f03ff1ee95b --- /dev/null +++ b/web/storybook/public/agent-custom-secrets/_ds/agenta-design-system-0263eb0f-a904-4476-a73b-7afca7b6d749/README.md @@ -0,0 +1,170 @@ +# Agenta Design System + +A design system for **Agenta** — an open-source LLMOps platform that helps developers build reliable AI agents through prompt management, evaluation, and observability. + +## What is Agenta? + +Agenta is a developer tool (LLMOps platform) with three core capabilities: + +1. **Prompt Management** — playground, versioning, deployment of prompts +2. **Evaluation** — auto, human, SDK, and online evaluations of LLM outputs against test sets +3. **Observability** — tracing, sessions, and LLM telemetry (OpenTelemetry-based) + +As of v0.112.0 the app is agent-centric: **Agents** and **Sessions** are first-class nav surfaces alongside Prompts, Test sets, Evaluators, Evaluations, Annotation Queues, Observability, plus the Home dashboard (requests / latency / cost / tokens). + +The product targets *developers* building LLM apps. The tone is technical, pragmatic, and understated — no marketing gloss. + +## Sources this system was built from + +- **Codebase (primary):** `github.com/Agenta-AI/agenta` @ `release/v0.112.0` (PR #5827 — the 2026-08 **warm brand recolor** + dark theme) + - `web/oss/src/styles/theme/palette.ts` — **the** source of truth for all color tokens, light + dark + - `web/oss/src/components/Sidebar/hooks/useSidebarConfig/index.tsx` — nav structure (Agents before Sessions) + - `web/oss/package.json` — stack: Next.js, **Ant Design 6**, **Tailwind 3**, Tremor, Lucide, Phosphor icons, Inter font +- **Screenshots (supplementary):** 5 pre-recolor app screenshots in `uploads/` (layout/density reference only — colors are outdated there) +- **Brand assets:** `assets/agenta-logo-full-light.png` and dark variant + +## Stack + +- **UI framework:** Ant Design 6 (light tokens overridden by the palette; dark via darkAlgorithm + overrides) +- **Utility CSS:** Tailwind 3 (preflight disabled) +- **Charts:** Tremor / Recharts +- **Icons:** Phosphor + Lucide + Ant Design icons (mixed) +- **Font:** Inter (`--font-inter` / Next font) +- **State:** Jotai + Redux Toolkit + SWR + TanStack Query + +## File index + +| File | Purpose | +|---|---| +| `colors_and_type.css` | All color & type tokens as CSS variables (`--fg1`, `--brand`, `--hero-bg`, `--tag-*`, `--chart-*`) + a `[data-theme="dark"]` block + semantic classes. Import this everywhere. | +| `assets/` | Logos (light/dark), favicon | +| `preview/` | Design System preview cards (one per concept, ~700px wide) | +| `components/` | Compiled React primitives exposed on `window.AgentaDesignSystem_0263eb` | +| `ui_kits/web/` | The Agenta web app UI kit — React components recreating the real product surfaces | +| `SKILL.md` | Cross-compatible skill manifest | +| `github.md` | Source-repo sync record | + +--- + +## Content fundamentals + +Agenta's copy is **terse, technical, and second-person**. It reads like good developer documentation, not marketing. + +### Voice & tone +- **Direct and informative.** Feature cards state the *action* then the *outcome*. Ex: "Create a prompt — Start with a prompt and test it in the playground." No adjectives, no hype. +- **Second person ("you/your").** "What do you want to do?", "Send traces from your AI app to debug and improve reliability." +- **Imperative headings for tasks.** "Create a prompt", "Run an evaluation", "Set up tracing". +- **Plain-English status words.** "Pending", "0 out of 3", "No output yet", "No filters applied". Never cute, never emoji-driven. + +### Casing +- **Sentence case** for all UI: page titles, buttons, menu items. (Title Case appears *only* on primary CTAs with multiple words.) +- **Nav items in sentence case:** Home, Prompts, Agents, Sessions, Test sets, Evaluators, Evaluations, Annotation Queues, Observability. +- Product name is always **"Agenta"** (capital A, never ALLCAPS). + +### Number & unit formatting +- Currency as `$0.001541`, `$0.00` — not abbreviated. +- Tokens as `8,357` with comma separators; averages as `146.61`. +- Latency in milliseconds: `0.83ms`, `4ms`. +- Time ranges: "Last 1 month", "Last 3 months", "25 Mar", "1 Apr". +- Percentages keep two-decimals when small: `0.07%`. + +### Emoji & symbols +- **No emoji.** Ever. +- **No decorative unicode.** `—` (em dash) is the "no value" placeholder in table cells. + +--- + +## Visual foundations + +### Color — the warm recolor (v0.112.0) +- **Light mode is a warm paper/ink ramp.** Page ground `#f6f5f3`, white cards, ink text `#242424`. The old navy `#1c2c3d` system is retired. +- **Primary (`colorPrimary`) is warm ink `#242424`** — primary buttons are ink with white text; hover `#413f3f`, active `#1e1c1d`. +- **Brand yellow `#f2f25c` is a FILL only** — never text, icon, or link in light mode. It marks the single **hero ("keycap") action** per screen (Commit-class buttons): flat yellow fill, ink text, hover `#e7e712`. +- **Links are olive `#5e5e08`** — the only text-safe yellow-family step. Hover steps to ink. +- **Focus is a lime ring:** `rgba(217,217,44,0.35)` glow + `#413f3f` border on focused controls. +- **Neutrals are the warm zinc ladder** `#f6f5f3 → #1e1c1d` (see `--zinc-1..10`). Not gray, not navy. +- **Text hierarchy:** `#242424` (fg1) → `#676770` (fg2) → `#848b8c` (fg3, also resting icons) → `#a3a19f` (fg4, disabled/placeholder). +- **Semantic colors are deep and muted:** success `#2e7d3a`, warning `#8a6400`, error `#5e0908` (body text; `#d94c4a` is border/large-text only), info deep blue `#113955`. +- **One categorical set for ALL tags/badges/chips — six slots** (blue, neutral, amber, olive, red, green; see `--tag-*`). The Ant 10-step hue ramps are gone. Thirteen legacy tag names map onto six slots — some are deliberately indistinguishable. **Agent is pinned to the blue slot** (`#e5f1f9` / `#113955`). +- **Reference tags are the one outlined tag family** (border = slot text at 22% alpha); every other tag is a flat fill. +- **Chart series are fixed-order** (`--chart-1..5`: terracotta, sky, lime, deep blue, gray) — assigned by position, cycled, never picked per item. +- **No gradients.** Flat fills only. No glassmorphism. + +### Dark mode +Ships with v0.112.0. Core inversions (all in the `[data-theme="dark"]` block of `colors_and_type.css`): +- Surfaces: container `#141414`, elevated `#242424`, layout black; rail `#101010`. +- **Brand yellow becomes the primary** (`#f2f25c`, dark text `#141414` on it); hero action is `#d1d151`. +- Links are light blue `#8ccfff` (olive fails on dark). +- Text is white at alpha steps (0.85 / 0.65 / 0.45 / 0.25). +- Nav selection is an olive pill `#3e3d1a` with `#d1d151` text. + +### Typography +- **Inter** across the entire UI. Monospace is JetBrains Mono / Menlo for code (`--bg-muted` background). +- **Sizes are small and information-dense.** 14px body, 12px labels, 13px inline code. Page H1 ~30px. +- **Weight range is narrow:** 400 regular, 500 medium (selected states), 600 semibold headings. No 700 in chrome. +- **Tight tracking,** tabular numbers on metrics. + +### Layout & shell +- **Fixed left sidebar** (236px expanded, 80px collapsed) — now a **warm rail** (`#f6f5f3`) beside white content; all frame lines share `#e5e5e3`. +- **Selected nav row is a WHITE pill** with a hairline border on the warm rail (light). Resting nav icons are grey (`#848b8c`). +- **Project switcher is borderless at rest.** +- Nav order: Home, Prompts, **Agents, Sessions**, Test sets, Evaluators, Evaluations, Annotation Queues, Observability. +- **Main content is white**, ~24px padding; breadcrumb strip in tertiary text; version badge top-right. +- The **breadcrumb + title + filter bar + table** pattern recurs across list pages. + +### Backgrounds +- White cards on the `#f6f5f3` ground; `#fbfaf8` (paper) for tinted panels, section headers, table headers — "one step off the plain card without going grey." +- Overlay masks use `rgba(36, 36, 36, 0.45)`. + +### Borders, radii, shadows +- **Default radius 6px** (4px small, 8px cards, 2px XS). +- **Borders are 1px solid.** Controls `#d7d7d7`; cards/sections `#e5e5e3` (strong hairline); row dividers `#f0efed` (soft hairline). +- **Shadows are minimal:** card `--shadow-card` (three soft ≤4px drops), dropdowns `--shadow-dropdown`, modals `--shadow-modal`. No inner or colored shadows in light mode. + +### Hover / press / focus +- **Hover on rows:** `rgba(36,36,36,0.04)`; press `rgba(36,36,36,0.15)`. Never a scale transform. +- **Primary button:** `#242424` → hover `#413f3f` → active `#1e1c1d`. +- **Focus:** lime glow `rgba(217,217,44,0.35)` + darker border. No thick focus rings. + +### Scrollbars +- **Slim and trackless** (6px, transparent track), thumb `rgba(36,36,36,0.22)` → hover `0.38`, scroll-aware fading in the real app. + +### Motion +- **Minimal.** `transition: opacity 0.3s ease` for hover-revealed actions; 300ms for sidebar collapse. No bounces, scales, or parallax. + +### Iconography +- **Mixed icon system:** `@ant-design/icons` (table chrome), `@phosphor-icons/react` (sidebar nav), `lucide-react` (newer components). +- **Style:** outlined, 1.5–2px stroke, 16–20px. Resting state is grey `#848b8c`; active/selected steps to ink. +- **No emoji. No hand-drawn SVGs.** + +--- + +## Iconography CDN + +- Lucide: `https://cdn.jsdelivr.net/npm/lucide-static@latest/icons/.svg` +- Phosphor: `https://unpkg.com/@phosphor-icons/core@2.1.1/assets/regular/.svg` +- Sidebar Phosphor mapping: House (Home), GridFour (Prompts), Robot (Agents), ChatsCircle (Sessions), TestTube (Test sets), SwatchBook (Evaluators), ChartBar (Evaluations), Queue (Annotation Queues), ChartLineUp (Observability), Gear (Settings). +- Table rows use Ant icons: `SettingOutlined`, `MoreOutlined`, `FilterOutlined`, `SearchOutlined`, `ExportOutlined`. + +## Fonts + +**Inter** via Google Fonts CDN (mirrors Next's runtime font loading): + +```html + + + +``` + +## Components + +Compiled React components on `window.AgentaDesignSystem_0263eb`: + +- **Button** — `variant`: primary (ink), hero (yellow keycap), default, text, danger; `size="sm"`, `disabled` +- **Tag** — `slot`: one of the six categorical slots (blue, neutral, amber, olive, red, green); `outlined` for reference tags + +## What's in `ui_kits/` + +- `ui_kits/web/` — the Agenta web app: sidebar shell (warm rail, Agents/Sessions), breadcrumb, Home dashboard, Evaluations table, Observability table, Annotation Queues, and core primitives (`.btn` incl. `.btn.hero`, `.input`, `.tag`, `.card`, `.tbl`). Open `ui_kits/web/index.html` for the interactive prototype. + +No marketing website UI kit. If you need a landing page, ask the user for marketing brand guidelines. diff --git a/web/storybook/public/agent-custom-secrets/_ds/agenta-design-system-0263eb0f-a904-4476-a73b-7afca7b6d749/_adherence.oxlintrc.json b/web/storybook/public/agent-custom-secrets/_ds/agenta-design-system-0263eb0f-a904-4476-a73b-7afca7b6d749/_adherence.oxlintrc.json new file mode 100644 index 00000000000..06783c44180 --- /dev/null +++ b/web/storybook/public/agent-custom-secrets/_ds/agenta-design-system-0263eb0f-a904-4476-a73b-7afca7b6d749/_adherence.oxlintrc.json @@ -0,0 +1,339 @@ +{ + "plugins": [ + "react", + "import" + ], + "rules": { + "react/forbid-elements": [ + "warn", + { + "forbid": [] + } + ], + "no-restricted-imports": [ + "warn", + { + "patterns": [ + { + "group": [ + "components/Button/**", + "components/Tag/**", + "ui_kits/web/**" + ], + "message": "Import design-system components from 'index.js', not component internals." + } + ] + } + ], + "no-restricted-syntax": [ + "warn", + { + "selector": "Literal[value=/#[0-9a-fA-F]{3,8}\\b/]", + "message": "Raw hex color — use a design-system color token via var()." + }, + { + "selector": "Literal[value=/\\b\\d+px\\b/]", + "message": "Raw px value — use a design-system spacing token via var()." + }, + { + "selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute > JSXIdentifier[name!=/^(?:variant|size|disabled|onClick|style|children|key|ref|className|style|children)$/]", + "message": " - - + + - + + + diff --git a/website/src/pages/api.astro b/website/src/pages/api.astro new file mode 100644 index 00000000000..c4eb37d1260 --- /dev/null +++ b/website/src/pages/api.astro @@ -0,0 +1,126 @@ +--- +import Site from "../layouts/Site.astro"; +import Section from "../components/Section.astro"; +import Badge from "../components/Badge.astro"; +import { API_EXAMPLE, WHEN_TO_USE } from "../lib/siteSummary"; + +// Same-origin API entry point. The full reference lives on docs.agenta.ai and +// the machine-readable surface is /openapi.json; this page is the signpost +// between them, so an agent (or a developer) that lands on agenta.ai finds the +// base URLs, the auth header, and one working request without leaving the site. +// +// Panels reuse the .ag-info-panel pattern from contact.astro and imprint.astro. +// Facts here are mirrored from docs/docs/reference/api-guide/01-overview.mdx. +// Do not restate the endpoint reference: link it. +const panel = + "border:1px solid var(--ag-d-border-subtle);padding:40px 40px 36px;background:var(--ag-d-bg-2);"; +const heading = + "margin:0 0 24px;font:300 28px/1.15 var(--font-display,'GT Alpina',serif);color:var(--ag-d-text-hi);"; +const term = "color:var(--ag-d-text-faint);font:var(--text-label);"; +const body = "font:var(--text-body-md);color:var(--ag-d-text-mut);"; +const link = "color:var(--ag-d-text);text-decoration:underline;"; + +const bases = [ + ["Cloud (US)", "https://us.cloud.agenta.ai/api"], + ["Cloud (EU)", "https://eu.cloud.agenta.ai/api"], + ["Self-hosted", "$AGENTA_HOST/api"], +]; +--- + + +
+
+ API +

+ Build with the Agenta API +

+

+ Everything the app does, the REST API does: create agents, commit + revisions, run evaluations, and read traces. The full specification is + published at /openapi.json. +

+
+
+ +
+
+
+

Base URL and authentication

+ +
+ { + bases.map(([label, url]) => ( + <> +
{label}
+
+ {url} +
+ + )) + } +
Authorization
+
+ Authorization: ApiKey $AGENTA_API_KEY +
+
+ +

+ An API key is scoped to a single project, so endpoints take no + project_id. Create one in your project settings on + Agenta Cloud. +

+
+ +
+

Example request

+
{API_EXAMPLE}
+
+ +
+

What Agenta is for

+
    + {WHEN_TO_USE.map((item) =>
  • {item}
  • )} +
+
+ +
+

Reference

+ +

+ Python SDK: pip install agenta. +

+
+
+
+
diff --git a/website/src/pages/api.md.ts b/website/src/pages/api.md.ts new file mode 100644 index 00000000000..a9e2eda342a --- /dev/null +++ b/website/src/pages/api.md.ts @@ -0,0 +1,42 @@ +// Markdown twin of /api — the same signpost, in the shape an agent reads. +import type { APIRoute } from "astro"; +import { markdownResponse, page } from "../lib/markdown"; +import { + API_EXAMPLE, + HOW_TO_CALL, + WHEN_NOT_TO_USE, + WHEN_TO_USE, +} from "../lib/siteSummary"; + +const body = `## When to use Agenta + +${WHEN_TO_USE.map((item) => `- ${item}`).join("\n")} + +## When not to use Agenta + +${WHEN_NOT_TO_USE.map((item) => `- ${item}`).join("\n")} + +## How to call it + +${HOW_TO_CALL.map((item) => `- ${item}`).join("\n")} + +## Example request + +\`\`\`bash +${API_EXAMPLE} +\`\`\` + +An API key is scoped to a single project, so endpoints take no \`project_id\`. +Create one in your project settings on https://cloud.agenta.ai/. +`; + +export const GET: APIRoute = () => + markdownResponse( + page({ + title: "Agenta API", + description: + "The Agenta REST API: base URLs, authentication, an example request, and the OpenAPI specification.", + path: "/api", + body, + }), + ); diff --git a/website/src/pages/authors.md.ts b/website/src/pages/authors.md.ts new file mode 100644 index 00000000000..85fc4eaa61f --- /dev/null +++ b/website/src/pages/authors.md.ts @@ -0,0 +1,31 @@ +// Markdown twin of the authors index — /authors.md. Flat filename for the same +// reason as blog.md.ts (the worker fetches ".md"). +import type { APIRoute } from "astro"; +import { getCollection } from "astro:content"; +import { markdownResponse, page } from "../lib/markdown"; +import { authorPosts } from "../lib/blog"; +import { SITE_URL } from "../lib/siteSummary"; + +export const GET: APIRoute = async () => { + const authors = await getCollection("authors"); + const posts = await getCollection("posts"); + + const body = `## Authors + +${authors + .map((author) => { + const count = authorPosts(author.id, posts).length; + return `- [${author.data.name}](${SITE_URL}/authors/${author.id}) — ${author.data.role}. ${count} post${count === 1 ? "" : "s"}.`; + }) + .join("\n")} +`; + + return markdownResponse( + page({ + title: "Authors", + description: "The people writing on the Agenta blog.", + path: "/authors", + body, + }), + ); +}; diff --git a/website/src/pages/authors/[slug].astro b/website/src/pages/authors/[slug].astro index f3eb240298e..0161a376c52 100644 --- a/website/src/pages/authors/[slug].astro +++ b/website/src/pages/authors/[slug].astro @@ -1,4 +1,5 @@ --- +import Section from "../../components/Section.astro"; import type { GetStaticPaths } from "astro"; import Site from "../../layouts/Site.astro"; import PostCard from "../../components/PostCard.astro"; @@ -26,7 +27,6 @@ export const getStaticPaths = (async () => { const { author, posts } = Astro.props; const { name, role, avatar, bio, socials } = author.data; -const sectionBorder = "border:1px solid rgba(255,255,255,0.07);margin-top:-1px;"; --- -
+ { bio && ( -
-

+

{bio}

-
+ ) } -
) : ( -

+

No posts yet from {name}.

) } -
+ + +
diff --git a/website/src/pages/authors/[slug].md.ts b/website/src/pages/authors/[slug].md.ts new file mode 100644 index 00000000000..0f5b20f3262 --- /dev/null +++ b/website/src/pages/authors/[slug].md.ts @@ -0,0 +1,50 @@ +// Markdown twin of every author profile — /authors/.md. +import type { APIRoute, GetStaticPaths } from "astro"; +import { getCollection } from "astro:content"; +import { markdownResponse, page } from "../../lib/markdown"; +import { authorPosts, byDateDesc, formatDate } from "../../lib/blog"; +import { SITE_URL } from "../../lib/siteSummary"; + +export const getStaticPaths = (async () => { + const authors = await getCollection("authors"); + const posts = await getCollection("posts"); + return authors.map((author) => ({ + params: { slug: author.id }, + props: { author, posts: authorPosts(author.id, posts).sort(byDateDesc) }, + })); +}) satisfies GetStaticPaths; + +export const GET: APIRoute = async ({ props }) => { + const { author, posts } = props as { + author: Awaited>>[number]; + posts: Awaited>>; + }; + const { name, role, bio, socials } = author.data; + + const links = socials?.length + ? `\n\n## Elsewhere\n\n${socials + .map((social) => `- [${social.platform}](${social.url})`) + .join("\n")}` + : ""; + + const body = `${bio ? `${bio}\n\n` : ""}Role: ${role} + +## Posts + +${posts + .map( + (post) => + `- [${post.data.title}](${SITE_URL}/blog/${post.id}) — ${formatDate(post.data.date)}`, + ) + .join("\n")}${links} +`; + + return markdownResponse( + page({ + title: name, + description: bio ?? `${name} — ${role}. Posts on the Agenta blog.`, + path: `/authors/${author.id}`, + body, + }), + ); +}; diff --git a/website/src/pages/authors/index.astro b/website/src/pages/authors/index.astro index 449fb8f221e..3587e833010 100644 --- a/website/src/pages/authors/index.astro +++ b/website/src/pages/authors/index.astro @@ -1,4 +1,5 @@ --- +import Section from "../../components/Section.astro"; import Site from "../../layouts/Site.astro"; import { getCollection } from "astro:content"; import { authorPosts, socialIcon } from "../../lib/blog"; @@ -11,7 +12,6 @@ const posts = await getCollection("posts"); // Count includes posts where the author is primary OR a co-author. const postCount = (id: string) => authorPosts(id, posts).length; -const sectionBorder = "border:1px solid rgba(255,255,255,0.07);margin-top:-1px;"; --- -
- Our authors + Our authors

The people behind the blog

-
+ -
- + {author.data.name} - + {author.data.role} - + {postCount(author.id)} {postCount(author.id) === 1 ? "post" : "posts"} {author.data.socials && author.data.socials.length > 0 && (
{author.data.socials.map((s) => ( - + ))} @@ -88,5 +85,36 @@ const sectionBorder = "border:1px solid rgba(255,255,255,0.07);margin-top:-1px;" )) }
-
+ + +
diff --git a/website/src/pages/blog.md.ts b/website/src/pages/blog.md.ts new file mode 100644 index 00000000000..6fa60a1a51d --- /dev/null +++ b/website/src/pages/blog.md.ts @@ -0,0 +1,33 @@ +// Markdown twin of the blog index — /blog.md. +// +// The filename is flat (blog.md.ts, not blog/index.md.ts) on purpose: the edge +// worker asks ASSETS for ".md", so the twin of /blog must be /blog.md. +import type { APIRoute } from "astro"; +import { getCollection } from "astro:content"; +import { markdownResponse, page } from "../lib/markdown"; +import { byDateDesc, formatDate } from "../lib/blog"; +import { SITE_URL } from "../lib/siteSummary"; + +export const GET: APIRoute = async () => { + const posts = (await getCollection("posts")).sort(byDateDesc); + + const body = `## All posts + +${posts + .map( + (post) => + `- [${post.data.title}](${SITE_URL}/blog/${post.id}) — ${post.data.category}, ${formatDate(post.data.date)}. ${post.data.description}`, + ) + .join("\n")} +`; + + return markdownResponse( + page({ + title: "Agenta Blog", + description: + "Articles and engineering posts from the Agenta team on building, evaluating, and shipping AI agents.", + path: "/blog", + body, + }), + ); +}; diff --git a/website/src/pages/blog/[slug].astro b/website/src/pages/blog/[slug].astro index 01f4ea97836..93e5796610f 100644 --- a/website/src/pages/blog/[slug].astro +++ b/website/src/pages/blog/[slug].astro @@ -1,4 +1,5 @@ --- +import Section from "../../components/Section.astro"; import type { GetStaticPaths } from "astro"; import Site from "../../layouts/Site.astro"; import PostCard from "../../components/PostCard.astro"; @@ -67,7 +68,6 @@ const articleJsonLd = { }, }; -const sectionBorder = "border:1px solid rgba(255,255,255,0.07);margin-top:-1px;"; --- -
+

{title}

{description}

- + {authors.map((a, i) => ( <> - {i > 0 && & } + {i > 0 && & } {a.data.name} @@ -186,7 +186,7 @@ const sectionBorder = "border:1px solid rgba(255,255,255,0.07);margin-top:-1px;" loading="eager" fetchpriority="high" decoding="async" - style="display:block;width:100%;height:auto;aspect-ratio:16/9;object-fit:cover;border-radius:14px;box-shadow:inset 0 0 0 1px rgba(255,255,255,0.07);" + style="display:block;width:100%;height:auto;aspect-ratio:16/9;object-fit:cover;border-radius:14px;box-shadow:inset 0 0 0 1px var(--th-border-subtle);" />
) @@ -199,27 +199,27 @@ const sectionBorder = "border:1px solid rgba(255,255,255,0.07);margin-top:-1px;" > -
+ { related.length > 0 && ( -
-
+ ) } diff --git a/website/src/pages/contact.md.ts b/website/src/pages/contact.md.ts new file mode 100644 index 00000000000..cb0a72977ca --- /dev/null +++ b/website/src/pages/contact.md.ts @@ -0,0 +1,36 @@ +// Markdown twin of /contact. The page is short and hand-built, so the body is +// written here — keep it in step with src/pages/contact.astro. +import type { APIRoute } from "astro"; +import { markdownResponse, page } from "../lib/markdown"; + +const body = `## Book a demo + +30 minutes. We'll show you how Agenta fits your LLM workflow and answer any +questions you have. + +- [Book a 30-minute demo](https://cal.com/mahmoud-mabrouk-ogzgey/demo?duration=30) +- [Get started free](https://cloud.agenta.ai/) + +## Direct contact + +- Email: team@agenta.ai +- Phone: +49-(0)-152-31036519 +- Address: Agentatech UG (haftungsbeschränkt), c/o betahaus, Rudi-Dutschke-Straße 23, 10969 Berlin, Germany + +## Community + +- [GitHub](https://github.com/Agenta-AI/agenta) +- [Slack](https://join.slack.com/t/agenta-hq/shared_invite/zt-1zsafop5i-Y7~ZySbhRZvKVPV5DO_7IA) +- [LinkedIn](https://www.linkedin.com/company/agenta-ai/) +`; + +export const GET: APIRoute = () => + markdownResponse( + page({ + title: "Contact Agenta", + description: + "Get in touch with the Agenta team. Book a 30-minute demo, email us, or join the community on GitHub, Slack, and LinkedIn.", + path: "/contact", + body, + }), + ); diff --git a/website/src/pages/imprint.astro b/website/src/pages/imprint.astro index 4cc0e38425e..4aa3d25da6f 100644 --- a/website/src/pages/imprint.astro +++ b/website/src/pages/imprint.astro @@ -1,4 +1,6 @@ --- +import Badge from "../components/Badge.astro"; +import Section from "../components/Section.astro"; import Site from "../layouts/Site.astro"; // Legal data confirmed by the founder on 2026-07-22 (register HRB 254081 B, @@ -11,33 +13,30 @@ import Site from "../layouts/Site.astro"; showCta={false} > -
- Legal + Legal

Imprint

Information in accordance with § 5 DDG (Digitale-Dienste-Gesetz). This page also serves as the contact page for Agenta.

-
+ -
+
@@ -45,22 +44,22 @@ import Site from "../layouts/Site.astro"; -
+
+ + diff --git a/website/src/pages/imprint.md.ts b/website/src/pages/imprint.md.ts new file mode 100644 index 00000000000..9dbfa0810ea --- /dev/null +++ b/website/src/pages/imprint.md.ts @@ -0,0 +1,34 @@ +// Markdown twin of /imprint. Legal text — keep it identical to +// src/pages/imprint.astro; do not paraphrase. +import type { APIRoute } from "astro"; +import { markdownResponse, page } from "../lib/markdown"; + +const body = `Information in accordance with § 5 DDG (Digitale-Dienste-Gesetz). This page +also serves as the contact page for Agenta. + +## Legal notice + +- Company: Agentatech UG (haftungsbeschränkt) +- Address: c/o betahaus, Rudi-Dutschke-Straße 23, 10969 Berlin, Germany +- Represented by: Managing director (Geschäftsführer): Mahmoud Mabrouk +- Commercial register: Amtsgericht Charlottenburg (Berlin), HRB 254081 B +- VAT ID: USt-IdNr. in accordance with § 27a UStG: DE363150015 +- Responsible for content: Mahmoud Mabrouk (address as above), § 18 Abs. 2 MStV + +## Contact + +- Phone: +49-(0)-152-31036519 +- Email: team@agenta.ai +- Website: https://agenta.ai +`; + +export const GET: APIRoute = () => + markdownResponse( + page({ + title: "Imprint", + description: + "Legal imprint for Agentatech UG (haftungsbeschränkt), Berlin. Required by § 5 DDG.", + path: "/imprint", + body, + }), + ); diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro index 2a5c9fe0f4a..3211c344b56 100644 --- a/website/src/pages/index.astro +++ b/website/src/pages/index.astro @@ -1,14 +1,11 @@ --- -import Base from "../layouts/Base.astro"; -import SiteNav from "../components/SiteNav.astro"; +import Site from "../layouts/Site.astro"; import Hero from "../components/Hero.astro"; import TemplateSection from "../components/TemplateSection.astro"; import HowItWorks from "../components/HowItWorks.astro"; import OpenStandards from "../components/OpenStandards.astro"; import Reliability from "../components/Reliability.astro"; import OpenSource from "../components/OpenSource.astro"; -import CtaBand from "../components/CtaBand.astro"; -import SiteFooter from "../components/SiteFooter.astro"; // Organization structured data (schema.org JSON-LD). Emitted only on the landing // page so crawlers attach the knowledge-panel entity to the site root. @@ -29,7 +26,8 @@ const orgJsonLd = { }; --- - @@ -41,21 +39,10 @@ const orgJsonLd = { /> - -
- -
- - - - - - -
- - -
- + + + + + + + diff --git a/website/src/pages/index.md.ts b/website/src/pages/index.md.ts new file mode 100644 index 00000000000..b98df23442d --- /dev/null +++ b/website/src/pages/index.md.ts @@ -0,0 +1,60 @@ +// Markdown twin of the landing page — /index.md, and what the worker serves +// for `GET / ` with `Accept: text/markdown`. +// +// Copy comes from src/lib/siteSummary.ts, the same source as /llms.txt. +import type { APIRoute } from "astro"; +import { markdownResponse, page } from "../lib/markdown"; +import { + ABOUT, + API, + HOSTING, + HOW_TO_CALL, + LINKS, + TAGLINE, + WHEN_NOT_TO_USE, + WHEN_TO_USE, +} from "../lib/siteSummary"; + +const body = `## What Agenta is + +${TAGLINE.replace(/\n/g, " ")} + +## What you can do + +${ABOUT.map((item) => `- ${item}`).join("\n")} + +## When to use Agenta + +${WHEN_TO_USE.map((item) => `- ${item}`).join("\n")} + +## When not to use Agenta + +${WHEN_NOT_TO_USE.map((item) => `- ${item}`).join("\n")} + +## How to call Agenta + +${HOW_TO_CALL.map((item) => `- ${item}`).join("\n")} + +## Hosting + +${HOSTING.map((item) => `- ${item}`).join("\n")} + +## API + +${API.map((item) => `- ${item}`).join("\n")} + +## Links + +${LINKS.map((link) => `- [${link.label}](${link.href}): ${link.note}.`).join("\n")} +`; + +export const GET: APIRoute = () => + markdownResponse( + page({ + title: "Agenta — The open-source workspace for your agents", + description: + "Agenta is the open-source workspace for your agents. Build agents through chat, improve them with feedback, and share them with your whole team — self-hosted or in the cloud.", + path: "/", + body, + }), + ); diff --git a/website/src/pages/llms.txt.ts b/website/src/pages/llms.txt.ts index 4726576b8ca..c28443ff05f 100644 --- a/website/src/pages/llms.txt.ts +++ b/website/src/pages/llms.txt.ts @@ -1,36 +1,62 @@ // Dynamic llms.txt — served at /llms.txt, mirroring src/pages/robots.txt.ts. // // Follows the llms.txt convention (https://llmstxt.org): a short, factual map of -// the site for LLMs and AI crawlers, which we allow (see robots.txt.ts). Copy is -// pulled from the landing page, not invented — keep it true to the actual site. +// the site for LLMs and AI crawlers, which we allow (see robots.txt.ts). The +// copy lives in src/lib/siteSummary.ts so this file and the homepage markdown +// twin (index.md.ts) can never drift apart. import type { APIRoute } from "astro"; +import { + ABOUT, + API, + HOSTING, + HOW_TO_CALL, + LINKS, + SITE_URL, + TAGLINE, + WHEN_NOT_TO_USE, + WHEN_TO_USE, +} from "../lib/siteSummary"; + +const quoted = TAGLINE.split("\n") + .map((line) => `> ${line}`) + .join("\n"); const body = `# Agenta -> Agenta is the open-source workspace for your agents: build agents through chat, -> improve them with feedback, and share them with your whole team. Open source and -> self-hostable, or hosted in the cloud. +${quoted} ## About -- Build agents through chat: describe the job, give the agent context and tools, and improve it through real work and feedback. -- Share agents with your team, and run them on a schedule or when an event happens in a connected app. -- Consequential actions can wait for human approval before they run. -- Prompts, skills, and tools are versioned like code, so you can roll back to any revision. -- MIT-licensed and yours to run: self-host on your own infrastructure to keep your agents and data with you. +${ABOUT.map((item) => `- ${item}`).join("\n")} + +## When to use Agenta + +${WHEN_TO_USE.map((item) => `- ${item}`).join("\n")} + +## When not to use Agenta + +${WHEN_NOT_TO_USE.map((item) => `- ${item}`).join("\n")} + +## How to call Agenta + +${HOW_TO_CALL.map((item) => `- ${item}`).join("\n")} ## Links -- [Website](https://agenta.ai): the marketing site. -- [Documentation](https://docs.agenta.ai): guides and API reference. -- [GitHub](https://github.com/Agenta-AI/agenta): the open-source repository. -- [Pricing](https://agenta.ai/pricing): hosted plans and the open-source tier. -- [Blog](https://agenta.ai/blog): articles and updates. +${LINKS.map((link) => `- [${link.label}](${link.href}): ${link.note}.`).join("\n")} ## Hosting -- Cloud: hosted at https://cloud.agenta.ai — start free, no infrastructure to run. -- Self-hosted: run Agenta on your own infrastructure under the MIT license. +${HOSTING.map((item) => `- ${item}`).join("\n")} + +## API + +${API.map((item) => `- ${item}`).join("\n")} + +## Machine-readable + +- Sitemap: ${SITE_URL}/sitemap-index.xml +- Every page also serves a markdown representation: request it with \`Accept: text/markdown\`, or append \`.md\` to the path (the homepage twin is \`/index.md\`). `; export const GET: APIRoute = () => diff --git a/website/src/pages/pricing.astro b/website/src/pages/pricing.astro index de15ac1183b..5b571a1390b 100644 --- a/website/src/pages/pricing.astro +++ b/website/src/pages/pricing.astro @@ -1,4 +1,6 @@ --- +import Badge from "../components/Badge.astro"; +import Section from "../components/Section.astro"; import Site from "../layouts/Site.astro"; import SectionTitle from "../components/SectionTitle.astro"; import HostingToggle from "../components/HostingToggle.tsx"; @@ -28,7 +30,7 @@ const comparisonModes = [ ]; const linkStyle = - "color:var(--yellow-400);text-decoration:underline;text-underline-offset:2px;"; + "color:var(--th-link);text-decoration:underline;text-underline-offset:2px;"; ---
-
+
- {hero.eyebrow} + {hero.eyebrow}

{hero.headline}

{hero.description}

@@ -79,24 +78,24 @@ const linkStyle = options={hostingToggle.options as { mode: "cloud" | "selfHosted"; label: string; sublabel?: string }[]} />
-
+
-
+
{plansByMode.cloud.map((plan: Plan) => )}
{plansByMode.selfHosted.map((plan: Plan) => )}
-
+
-
+
{ comparisonModes.map(({ cls, data }) => { const cols = data.columns.length; @@ -115,7 +114,7 @@ const linkStyle = `--cmp-cols-m:${colsM};--cmp-minw-m:${minwM}px;`; return (
- + {/* Mobile-only affordance: the table scrolls sideways to reveal the other plan columns, which isn't obvious on a phone. */} @@ -128,17 +127,17 @@ const linkStyle = {/* header row */}
- + Features {data.columns.map((c) => (
- + {c.name} - + {c.price}
@@ -148,35 +147,35 @@ const linkStyle = {data.groups.map((g) => (
- + {g.title}
{g.rows.map((r) => (
- + {linkify(r.label, links).map((part) => (part.href ? {part.text} : {part.text}))} {r.cells.map((cell) => ( {cell === true ? ( - + ) : cell === false ? ( - + ) : ( - + {cell} )} @@ -192,13 +191,12 @@ const linkStyle = ); }) } -
+
-
+
@@ -232,35 +230,27 @@ const linkStyle = }
- +
+ + diff --git a/website/src/pages/pricing.md.ts b/website/src/pages/pricing.md.ts new file mode 100644 index 00000000000..de039c67978 --- /dev/null +++ b/website/src/pages/pricing.md.ts @@ -0,0 +1,65 @@ +// Markdown twin of /pricing, rendered from the same src/data/pricing.json that +// drives the HTML page — so the plans an agent reads are the plans on screen. +import type { APIRoute } from "astro"; +import { markdownResponse, page } from "../lib/markdown"; +import pricing from "../data/pricing.json"; + +type Plan = { + name: string; + tagline: string; + price: string; + unit: string; + includesLabel: string; + features: string[]; + cta: { label: string; href: string }; +}; + +const mode = (label: string, plans: Plan[]) => `### ${label} + +${plans + .map( + (plan) => `#### ${plan.name} — ${plan.price} ${plan.unit} + +${plan.tagline} + +${plan.includesLabel}: + +${plan.features.map((feature) => `- ${feature}`).join("\n")} + +[${plan.cta.label}](${plan.cta.href})`, + ) + .join("\n\n")}`; + +const modes = pricing.hostingToggle.options + .map((option) => + mode( + `${option.label} ${option.sublabel}`, + pricing.plansByMode[option.mode as keyof typeof pricing.plansByMode] as Plan[], + ), + ) + .join("\n\n"); + +const faqs = pricing.faqs + .map((faq) => `### ${faq.question}\n\n${faq.answer}`) + .join("\n\n"); + +const body = `${pricing.hero.description} + +## Plans + +${modes} + +## Frequently asked questions + +${faqs} +`; + +export const GET: APIRoute = () => + markdownResponse( + page({ + title: pricing.hero.headline, + description: pricing.hero.description, + path: "/pricing", + body, + }), + ); diff --git a/website/src/styles/global.css b/website/src/styles/global.css index 4fef3e1c159..50d645856e7 100644 --- a/website/src/styles/global.css +++ b/website/src/styles/global.css @@ -1,11 +1,12 @@ @import "./tokens.css"; @import "./tokens-dark.css"; +@import "./theme.css"; /* ── base reset (from the design's inline