From f76426071602a39b92328f757c1623414ed040a8 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 29 Jul 2026 00:42:01 +0300 Subject: [PATCH 1/8] fix(sdk): hydrate references when the caller sent no config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A references-only /invoke (no data.parameters, no data.revision) never hydrated its references, so the run silently used the service's registered default configuration instead of the referenced revision's. This is the mobile resume path; desktop was unaffected because it always sends inline data.parameters. The hydration gate consulted the revision returned by resolve_revision, which falls back to RunningContext.revision — pre-seeded by the decorator with the registered default config. That revision is always populated for the agent builtin, so the gate read "already configured" and skipped hydration. seed_empty_parameters_from_configuration also ran before the gate, so it could never have observed empty parameters anyway. Decide hydration purely from caller-supplied config (data.parameters or data.revision), and seed the registered default only after a hydration attempt has been made and come back empty, so it stays a fallback rather than a pre-emption. --- .../sdk/middlewares/running/resolver.py | 36 ++- .../pytest/utils/test_resolver_middleware.py | 230 ++++++++++++++++++ 2 files changed, 258 insertions(+), 8 deletions(-) diff --git a/sdks/python/agenta/sdk/middlewares/running/resolver.py b/sdks/python/agenta/sdk/middlewares/running/resolver.py index f13f94b33d..64ebf860f4 100644 --- a/sdks/python/agenta/sdk/middlewares/running/resolver.py +++ b/sdks/python/agenta/sdk/middlewares/running/resolver.py @@ -546,6 +546,24 @@ async def resolve_embeds( return parameters +def _caller_supplied_configuration(request: WorkflowInvokeRequest) -> bool: + """Whether the CALLER sent its own configuration on the invoke request. + + Only `data.parameters` (an inline config, e.g. the playground running an unsaved + draft) and `data.revision` (a fully materialised revision) count. Deliberately does + NOT consider the revision returned by `resolve_revision`: that falls back to + `RunningContext.revision`, which the decorator pre-seeds with the service's + REGISTERED DEFAULT configuration. Treating the default as caller intent makes a + references-only invoke look "already configured", so its references are never + hydrated and it silently runs the service default instead of the referenced + revision's configuration. + """ + if not request.data: + return False + + return bool(request.data.parameters or request.data.revision) + + class ResolverMiddleware: """Middleware that resolves workflow components before execution. @@ -568,15 +586,13 @@ async def __call__( call_next: Callable[[WorkflowInvokeRequest], Any], ): ctx = RunningContext.get() - revision = seed_empty_parameters_from_configuration( - await resolve_revision(request=request) - ) + revision = await resolve_revision(request=request) - request_has_parameters = bool(request.data and request.data.parameters) + # Hydration intent is decided purely by what the CALLER sent. `revision` cannot + # take part in this decision: it falls back to the decorator's registered default + # configuration, which would make every references-only invoke look configured. needs_reference_hydration = bool( - request.references - and not request_has_parameters - and (revision is None or not revision.parameters) + request.references and not _caller_supplied_configuration(request) ) # Resolve references (env/workflow/application refs → revision) when needed @@ -593,7 +609,11 @@ async def __call__( _merge_tracing_references(retrieval_references) _merge_tracing_selector(retrieval_selector) revision = hydrated_revision or existing_revision - revision = seed_empty_parameters_from_configuration(revision) + + # Seed from the URI's registered default configuration LAST, so the default only + # fills a revision that is still unconfigured — after a hydration attempt has been + # made and come back empty (or failed). + revision = seed_empty_parameters_from_configuration(revision) if not request.data: request.data = WorkflowRequestData() diff --git a/sdks/python/oss/tests/pytest/utils/test_resolver_middleware.py b/sdks/python/oss/tests/pytest/utils/test_resolver_middleware.py index d8b7d7e173..5c18325e6f 100644 --- a/sdks/python/oss/tests/pytest/utils/test_resolver_middleware.py +++ b/sdks/python/oss/tests/pytest/utils/test_resolver_middleware.py @@ -585,6 +585,236 @@ async def test_direct_lookup_leaves_selector_unset(self): TracingContext.reset(token) +class TestResolverMiddlewareHydrationIntent: + """Tests for WHEN reference hydration fires. + + A service process pre-seeds `RunningContext.revision` with the decorator's REGISTERED + DEFAULT configuration, so `resolve_revision` always returns a fully-configured + revision. Hydration intent must therefore be read off the CALLER's request only: + references + no inline `data.parameters` + no `data.revision` means hydrate. + Reading it off the resolved revision made every references-only invoke (the mobile + resume shape) silently run the service default instead of the referenced revision. + """ + + # The registered default the decorator pre-seeds onto RunningContext.revision. + DEFAULT_REVISION = { + "data": { + "uri": "test://uri", + "parameters": {"model": "registered-default-model"}, + } + } + + @staticmethod + def _running_ctx_with_default(): + from agenta.sdk.contexts.running import RunningContext + + return RunningContext( + credentials="test-creds", + revision=TestResolverMiddlewareHydrationIntent.DEFAULT_REVISION, + ) + + async def _run(self, request, *, hydrated=None): + """Run the middleware with a decorator-seeded RunningContext. + + `hydrated` is what `resolve_references_with_info` returns as the revision; + None models a hydration failure (the resolver swallows API errors). + """ + from agenta.sdk.contexts.running import running_context_manager + from agenta.sdk.middlewares.running.resolver import ResolverMiddleware + + with ( + patch( + "agenta.sdk.middlewares.running.resolver.resolve_references_with_info", + new_callable=AsyncMock, + return_value=(hydrated, {}, None), + ) as mock_resolve_references, + patch( + "agenta.sdk.middlewares.running.resolver.resolve_handler", + new_callable=AsyncMock, + return_value=MagicMock(), + ), + patch( + "agenta.sdk.middlewares.running.resolver.resolve_embeds", + new_callable=AsyncMock, + ), + running_context_manager(self._running_ctx_with_default()), + tracing_context_manager(TracingContext()), + ): + await ResolverMiddleware()(request, AsyncMock(return_value="result")) + return mock_resolve_references + + @pytest.mark.asyncio + async def test_references_only_hydrates_over_registered_default(self): + """ + The mobile resume shape: references, no data.parameters, no data.revision. + Hydration MUST fire and the referenced revision's config MUST win over the + registered default sitting on RunningContext. + """ + from agenta.sdk.models.workflows import ( + WorkflowInvokeRequest, + WorkflowRequestData, + WorkflowRevisionData, + ) + + request = WorkflowInvokeRequest( + credentials="test-creds", + references={ + "workflow": {"slug": "my-agent"}, + "workflow_revision": {"id": "019faa90-c0b6-7310-9ab1-a31268c2163e"}, + }, + data=WorkflowRequestData(inputs={"messages": []}), + ) + referenced_params = {"model": "anthropic/claude-haiku-4-5"} + + mock_resolve_references = await self._run( + request, + hydrated=WorkflowRevisionData( + uri="test://uri", + parameters=referenced_params, + ), + ) + + mock_resolve_references.assert_called_once() + assert request.data.parameters == referenced_params + + @pytest.mark.asyncio + async def test_inline_parameters_skip_hydration(self): + """Desktop parity: an inline config is caller intent, so references are not + hydrated and the inline parameters drive the run untouched.""" + from agenta.sdk.models.workflows import ( + WorkflowInvokeRequest, + WorkflowRequestData, + ) + + inline_params = {"model": "caller-supplied-model"} + request = WorkflowInvokeRequest( + credentials="test-creds", + references={"workflow": {"slug": "my-agent"}}, + data=WorkflowRequestData(parameters=inline_params), + ) + + mock_resolve_references = await self._run(request) + + mock_resolve_references.assert_not_called() + assert request.data.parameters == inline_params + + @pytest.mark.asyncio + async def test_data_revision_skips_hydration(self): + """A caller-supplied `data.revision` is a materialised config: no hydration.""" + from agenta.sdk.models.workflows import ( + WorkflowInvokeRequest, + WorkflowRequestData, + ) + + revision_params = {"model": "caller-supplied-revision-model"} + request = WorkflowInvokeRequest( + credentials="test-creds", + references={"workflow": {"slug": "my-agent"}}, + data=WorkflowRequestData( + revision={"data": {"uri": "test://uri", "parameters": revision_params}}, + ), + ) + + mock_resolve_references = await self._run(request) + + mock_resolve_references.assert_not_called() + assert request.data.parameters == revision_params + + @pytest.mark.asyncio + async def test_hydration_failure_falls_back_to_registered_default(self): + """ + When hydration is attempted but yields nothing (API error — the resolver + swallows it and returns None), the run must not crash: it falls back to the + revision already on the context, seeded with the registered default config. + """ + from agenta.sdk.models.workflows import ( + WorkflowInvokeRequest, + WorkflowRequestData, + ) + + request = WorkflowInvokeRequest( + credentials="test-creds", + references={"workflow": {"slug": "my-agent"}}, + data=WorkflowRequestData(inputs={"messages": []}), + ) + + mock_resolve_references = await self._run(request, hydrated=None) + + mock_resolve_references.assert_called_once() + assert request.data.parameters == self.DEFAULT_REVISION["data"]["parameters"] + + @pytest.mark.asyncio + async def test_no_references_never_hydrates(self): + """No references at all: nothing to hydrate from, registered default applies.""" + from agenta.sdk.models.workflows import ( + WorkflowInvokeRequest, + WorkflowRequestData, + ) + + request = WorkflowInvokeRequest( + credentials="test-creds", + data=WorkflowRequestData(inputs={"messages": []}), + ) + + mock_resolve_references = await self._run(request) + + mock_resolve_references.assert_not_called() + assert request.data.parameters == self.DEFAULT_REVISION["data"]["parameters"] + + +class TestCallerSuppliedConfiguration: + """Unit tests for the caller-intent predicate behind hydration.""" + + @staticmethod + def _request(**data_kwargs): + from agenta.sdk.models.workflows import ( + WorkflowInvokeRequest, + WorkflowRequestData, + ) + + return WorkflowInvokeRequest( + data=WorkflowRequestData(**data_kwargs) if data_kwargs else None, + ) + + def test_no_data_is_not_caller_supplied(self): + from agenta.sdk.middlewares.running.resolver import ( + _caller_supplied_configuration, + ) + + assert _caller_supplied_configuration(self._request()) is False + + def test_inputs_only_is_not_caller_supplied(self): + from agenta.sdk.middlewares.running.resolver import ( + _caller_supplied_configuration, + ) + + request = self._request(inputs={"messages": []}) + assert _caller_supplied_configuration(request) is False + + def test_empty_parameters_are_not_caller_supplied(self): + from agenta.sdk.middlewares.running.resolver import ( + _caller_supplied_configuration, + ) + + assert _caller_supplied_configuration(self._request(parameters={})) is False + + def test_parameters_are_caller_supplied(self): + from agenta.sdk.middlewares.running.resolver import ( + _caller_supplied_configuration, + ) + + request = self._request(parameters={"model": "gpt-4"}) + assert _caller_supplied_configuration(request) is True + + def test_revision_is_caller_supplied(self): + from agenta.sdk.middlewares.running.resolver import ( + _caller_supplied_configuration, + ) + + request = self._request(revision={"data": {"uri": "test://uri"}}) + assert _caller_supplied_configuration(request) is True + + class TestResolverReferenceValidation: @pytest.mark.asyncio async def test_rejects_competing_application_and_evaluator_refs(self): From 32a494fb7dfe5b4bdc5bde962f23b6db81d77ec5 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 29 Jul 2026 09:45:23 +0300 Subject: [PATCH 2/8] docs(mobile): plan for stamping the effective turn config on HITL gates Grounded in two live experiments on the EE dev stack: - A dirty-config run's session_interactions row carries only workflow + workflow_variant references (no workflow_revision), so a references-only resume hydrates the variant HEAD, not the draft the turn ran under. A committed run's revision reference is pinned and is therefore already immune to later commits. - A warm approval resume keeps the sandbox's acquire-time model and secrets while re-reading the permission map from the incoming (hydrated, committed) request -- a split brain where the approval UI enforced one policy and the resumed turn enforces another. Cold replay runs the committed config end to end against a draft transcript. Recommends stamping the effective parameters on the interaction row at gate creation (SDK emits, runner echoes, API replays inline), which needs no migration, with a detect-and-defer fallback for pre-change rows. --- .../plans/2026-07-29-effective-turn-config.md | 302 ++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 docs/design/agenta-mobile/plans/2026-07-29-effective-turn-config.md diff --git a/docs/design/agenta-mobile/plans/2026-07-29-effective-turn-config.md b/docs/design/agenta-mobile/plans/2026-07-29-effective-turn-config.md new file mode 100644 index 0000000000..85e9da6c0f --- /dev/null +++ b/docs/design/agenta-mobile/plans/2026-07-29-effective-turn-config.md @@ -0,0 +1,302 @@ +# Effective turn config on HITL resume — design & plan + +**Status:** PLANNED · **Date:** 2026-07-29 · **Branch:** `feat/agenta-mobile-wave-1` +**Goal:** when an approval is answered from a client that cannot reproduce the turn's config +(mobile, the M2 detached dispatcher), the resumed run must continue under **the config the +gated turn was actually running**, not under whatever the referenced variant's HEAD revision +happens to say — most importantly for **tool permissions**, where the gap means the approval +UI enforced a policy the turn was not running under. + +Everything in §1 was executed live against the EE dev stack (ephemeral accounts/projects, +harness in the scratchpad); log lines and DB rows are quoted verbatim. + +--- + +## 1. Grounded findings + +### 1.0 The shape of the gap (code) + +- Hydration is decided **purely by what the caller sent**: + `sdks/python/agenta/sdk/middlewares/running/resolver.py:594-596` + + ```python + needs_reference_hydration = bool( + request.references and not _caller_supplied_configuration(request) + ) + ``` + + with `_caller_supplied_configuration` (`resolver.py:549-564`) true iff `data.parameters` or + `data.revision` is non-empty. So *any* inline `data.parameters` suppresses hydration; a + references-only body always hydrates. + +- Desktop always sends `data.parameters` inline (draft-aware) and **withholds the revision + reference when dirty** — `web/packages/agenta-playground/src/state/execution/agentRequest.ts:353-372`: + + ```ts + const isCommittedRevisionRun = + !isDirty && typeof fullReferences?.application_revision?.id === "string" + ``` + +- Mobile answers approvals with a **references-only** invoke — + `web/packages/agenta-chat/src/transport/agentResumeRequest.ts:1-11` documents "the body carries + NO `data.parameters`" as a load-bearing invariant. The M2 dispatcher does the same + (`api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py:259-300`, builds + `WorkflowServiceRequest(references=…, data=WorkflowServiceRequestData(inputs=…))`). + +- The interaction row's `references` come from **the same builder** as the turn row's: + `services/runner/src/sessions/interactions.ts:26-41` `buildWorkflowReferences(request.runContext?.workflow)`, + called at `run-turn.ts:228` (turn row) and `run-turn.ts:464` (interaction row). `runContext.workflow` + is derived from the tracing references in `sdks/python/agenta/sdk/agents/tracing.py:136-166`. + `is_draft` **is computed there** (`workflow.is_draft = revision is None`) but `buildWorkflowReferences` + drops it — the durable row keeps no draft-ness marker. + +- The effective `parameters` blob exists **only in the Python SDK process** — parked at + `resolver.py:669` (`TracingContext.get().parameters`) and flattened into wire fields by + `sdks/python/agenta/sdk/agents/utils/wire.py:128-160`. There is **no `parameters` field on the + `/run` wire at all** (`grep -n parameters services/runner/src/protocol.ts` → nothing). The runner, + which writes the interaction row, therefore cannot reach it today. + +### 1.1 EXPERIMENT 1 — dirty-run reference shape (answered) + +Harness: `scratchpad/effective_config_harness.py e1` + `scratchpad/e2_respond.py`. Ephemeral +project, agent revision committed with `anthropic/claude-haiku-4-5`; arm A = committed run +(references incl. revision, no inline parameters), arm B = the desktop's dirty shape (inline +draft `data.parameters` with `anthropic/claude-sonnet-4-5` + draft instructions, revision +reference withheld exactly as `agentRequest.ts` does). + +**`session_turns.references` (arm A, committed):** + +```json +[{"id":"019fac87-f619-…","slug":"harness-agent-0eb7e167e4"}, + {"id":"019fac87-f621-…","slug":"harness-agent-0eb7e167e4.default"}, + {"id":"019fac87-f63c-…","slug":"49f7faf7cf4b","version":"1"}] +``` + +**`session_turns.references` (arm B, dirty):** + +```json +[{"id":"019fac87-f619-…","slug":"harness-agent-0eb7e167e4"}, + {"id":"019fac87-f621-…","slug":"harness-agent-0eb7e167e4.default"}] +``` + +**`session_interactions.data` for a real parked gate on a dirty run** (project +`019fac90-33c1-…`, token `8c07647e-…`), verbatim: + +```json +{ + "request": { "args": { "command": "echo \"hello\" > notes.md" }, "tool": "Bash" }, + "references": { + "workflow": { "id": "019fac90-3438-…", "slug": "harness-agent-7e487deeaf" }, + "workflow_variant":{ "id": "019fac90-343f-…", "slug": "harness-agent-7e487deeaf.default" } + } +} +``` + +> **Answer 1.** A dirty run's interaction row carries `workflow` + `workflow_variant` and **no +> `workflow_revision`**; a committed run's carries all three (with `version`). The row keeps no +> parameters and no draft flag. Mobile's actionable filter only requires `data.references` to be +> non-empty (`web/mobile/src/features/chat/useApprovalActions.ts:106-120`), so the dirty row +> **passes the filter and is resumed** — it is not detectably different to today's client. + +**Why that is worse than "just the wrong revision":** a revision reference is **pinned**, a +variant-only reference resolves to **HEAD**. Measured on `POST /workflows/revisions/retrieve` +after committing a v2 over v1: + +``` +PINNED revision ref (v1) -> v1 model=anthropic/claude-haiku-4-5 instr={'agents_md': 'V1 INSTRUCTIONS'} +VARIANT-only ref -> v2 model=anthropic/claude-sonnet-4-5 instr={'agents_md': 'V2 INSTRUCTIONS'} +``` + +So a **committed** run's parked gate is already immune to later commits (the revision id pins +it). Only the **dirty** run is exposed, and it is exposed twice: the resume gets (a) not the +draft, and (b) whatever HEAD is at answer time. + +### 1.2 EXPERIMENT 2 — what a warm resume actually executes with (answered) + +Setup (`scratchpad/e2_respond.py`, cross-provider so the credential resolution is observable): +committed revision = `openai/gpt-4o-mini` with **no OpenAI secret in the project**; inline draft = +`anthropic/claude-sonnet-4-5` with an Anthropic secret. Turn 1 runs dirty and parks a Bash gate; +the gate is then answered through `POST /sessions/interactions/{id}/respond` (the M2 dispatcher, +references-only). + +Runner (`agenta-ee-dev-runner-1`), session `48268312-…`, verbatim: + +``` +[keepalive] miss key=019fac94-…:48268312-…; cold +[sandbox-agent] resolved model=anthropic/claude-sonnet-4-5 provider=anthropic deployment=direct connection= secretKeys=[ANTHROPIC_API_KEY] +[sandbox-agent] [HITL] pi-gate id=61707b08-… {"gate":"pi-builtin","toolCallId":"toolu_01XHeuqPtdscoHcGSxo9vbdL","toolName":"Bash","executor":"harness","readOnlyHint":false} +[sandbox-agent] [HITL] gate toolName="Bash" permission=ask outcome=pendingApproval +[keepalive] park key=019fac94-…:48268312-… ttl=1800000ms state=awaiting_approval poolSize=1 +[keepalive] resume key=019fac94-…:48268312-… gates=1 answered=1 carried=0 approve=1 reject=0 tool=Bash +[sandbox-agent] [HITL] resume state: decisions=["Bash#{\"command\":\"echo \\\"hello\\\" > notes.md\"}"] +[sandbox-agent] [HITL] pi-gate id=a879c4ec-… {"gate":"pi-builtin","toolCallId":"toolu_01WwK99fUUEUAEAyXhpPW87h","toolName":"Bash",…} +[keepalive] park key=019fac94-…:48268312-… ttl=1800000ms state=awaiting_approval (re-park) poolSize=1 +``` + +Services (`agenta-ee-dev-services-1`), same second as the resume, verbatim: + +``` +2026-07-29T06:34:40.842Z [WARN.] agent: no connection resolved for provider 'openai' (mode=agenta); +running with no injected credential (harness login / self-managed) [agenta.sdk.agents.handler] +``` + +> **Answer 2a (mismatch confirmed).** The resume is **warm** (`resume key=…`, no `cold`, no new +> `resolved model=` line): the parked sandbox keeps the model and secrets baked at acquire time — +> `anthropic/claude-sonnet-4-5` + `ANTHROPIC_API_KEY`. Meanwhile the SDK, hydrating the *committed* +> revision from the references-only resume, resolved **provider `openai`** and produced an empty +> credential set. **Sandbox model X (`sonnet`, Anthropic) vs the resume's resolved credential for +> provider Y (`openai`, none).** The run continued regardless and parked a second gate. + +Second arm, same shape but committed `runner.permissions.default = "deny"` vs draft +`"allow_reads"` (project `019fac91-9e0f-…`, session `14c72363-…`), verbatim: + +``` +[sandbox-agent] [HITL] gate toolName="Bash" permission=ask outcome=pendingApproval <- turn 1, DRAFT policy +[keepalive] resume key=019fac91-…:14c72363-… gates=1 answered=1 carried=0 approve=1 reject=0 tool=Bash +[sandbox-agent] [HITL] resume state: decisions=["Bash#{\"command\":\"echo \\\"hello\\\" > notes.md\"}"] +[sandbox-agent] [HITL] gate toolName="Bash" permission=deny outcome=deny <- resume, COMMITTED policy +``` + +> **Answer 2b (the security-relevant half).** The **permission map is re-read from the incoming +> request on every turn** (`services/runner/src/engines/sandbox_agent/run-turn.ts:428` +> `permissionsFromRequest(request)`), so on a resume it is the **committed** policy, while model, +> system prompt, MCP servers, tool specs and injected secrets stay at their **acquire-time (draft)** +> values (`environment.ts:596-603, 879-895, 1025`). The resumed turn is a genuine split brain: +> draft engine, committed policy. The `awaiting_approval` branch **deliberately skips** the +> config-fingerprint and credential-epoch checks that the idle-continuation branch enforces +> (`server.ts:678-739` vs `server.ts:594-621`), so nothing detects the divergence. + +> **Answer 2c (cold replay).** When the parked sandbox is gone (TTL 30 min, evicted, or the +> resume trips `approval-mismatch (history)` — observed verbatim as +> `[keepalive] approval-mismatch (history) key=…; evict + cold`), the run cold-starts from +> `buildRunPlan(request)`, i.e. **entirely from the hydrated committed config**. In the +> cross-provider arm the cold resume resolved `provider 'openai'` with no credential. So cold is +> not "less wrong", it is *differently* wrong: 100% committed config replaying a draft transcript. + +**Not answered experimentally (stated, not guessed):** whether a *Claude*-harness resume also +diverges in its in-sandbox `.claude/settings.json` (rendered from `harnessFiles` at acquire — +`run-plan.ts:159-165`). Code says yes; the dev stack's local Claude harness needs a mounted +subscription, so it was not exercised. Treat as an additional, un-measured divergence surface. + +### 1.3 Config blob size and sensitivity (measured) + +Over all `workflow_revisions` in the dev DB with a non-empty `data.parameters` (n = 326): +`avg 761 B`, `p90 1 365 B`, `max 20 410 B`. The large ones are entirely +**tool JSON-Schema** — e.g. one 14 KB `parameters.agent` = 11 gateway tool specs plus a 345-byte +`instructions`, with `llm: {"model":"opus","provider":"anthropic"}`. Connections are stored as +**references** (`{"mode":"agenta"}` / `{"mode":"agenta","slug":…}`), never raw keys; secrets are +resolved per-request from the project vault (`sdks/python/agenta/sdk/agents/platform/connections.py`), +so a `parameters` blob carries **no credentials today**. + +### 1.4 Storage facts that constrain the options + +| Store | Column | Type | Migration needed to add config? | +|---|---|---|---| +| `agenta_ee_core.session_interactions` | `data` | **`json`** (schemaless) | **No** — but `SessionInteractionData` is a closed Pydantic model with default `extra="ignore"` (`api/oss/src/core/sessions/interactions/dtos.py:24-28`), so an unknown key is **silently dropped** on both write and read. A DTO field is required. | +| `agenta_ee_core.session_turns` | — | no data/params column | **Yes** — a real Alembic migration. | +| `agenta_ee_tracing.records` | `attributes` | `jsonb`, **GIN-indexed** (`ix_records_payload_gin`) | No — `record_type` is free-form. But every stamped blob enters the GIN index. | + +The EE dev stack shares one Postgres volume across worktrees, so a `session_turns` migration is +a **shared-state change**: it lands for every worktree at once and must be forward-only. + +--- + +## 2. Options + +### A. Stamp the effective config on the interaction row (at gate creation) + +The runner adds `parameters` to the `data` it already POSTs at +`services/runner/src/engines/sandbox_agent/run-turn.ts:456-478`; mobile and the dispatcher resume +with those parameters **inline**, which suppresses hydration entirely +(`resolver.py:594`) and reproduces the turn exactly. + +The runner does not have the blob today (§1.0), so one of: + +- **A1 (recommended).** SDK adds one opaque field to the `/run` wire + (`sdks/python/agenta/sdk/agents/utils/wire.py:128-160`), runner types it in `protocol.ts` and + echoes it into the interaction `data`. Runner stays dumb; the SDK — the only component that + knows the effective config — stays authoritative. +- **A2 (rejected).** Runner posts the row without parameters; the SDK, which sees the + `interaction_request` event stream, PATCHes the row afterwards. Two writes, a race against the + client answering a fast gate, and a second failure mode on a row that must exist. + +**Cost:** ~1 KB typical / 20 KB worst case per gated turn, in a schemaless `json` column with no +index. No migration. **Cold vs warm:** both fixed — inline parameters make the resume's +`configFingerprint` equal the parked session's *and* make a cold replay rebuild the same plan. +**Back-compat:** rows written before the change have no `data.parameters`; clients fall back to +today's references-only path (§3, T6/T7 pin this). +**New persistence:** for a *dirty* run this durably stores a config that exists nowhere else +today (draft instructions + tool schemas). That is the one honest privacy delta — see T8. + +### B. Per-turn config snapshot (every turn, not just gated ones) + +Stamp the effective parameters for **every** turn so any client can reconstruct any turn. + +- On `session_turns`: **needs a migration** (no column exists) — and on the shared EE dev volume. +- On the records log: **no migration** (new `record_type`, e.g. `config`), but every blob lands in + the `ix_records_payload_gin` index, and records are the hot ingest path + (`POST /sessions/records/ingest` → Redis stream → worker), so this is the write-amplifying option. + At ~1 KB × every turn of every session it is a real, ongoing cost for a benefit only the + approval path uses today. + +Its two extra claims do not survive §1.1: a *committed* run's gate is already pinned by its +revision reference, so "someone commits a new revision while the session is parked" **only bites +dirty runs** — exactly what A already fixes. The genuinely new capability is "mobile sends a live +message on a session whose last turn was dirty", which is **not** in the current mobile scope +(mobile answers approvals; it does not compose fresh turns against a draft). + +### C. Detect-and-surface only + +No config plumbing. Mobile inspects `data.references`; if `workflow_revision` is absent, the row +is a dirty-run gate → show "answer on desktop" instead of approve/deny. + +Cheap and honest, and §1.1 proves it is **implementable today with zero backend change** (the +reference shape is already the discriminator). But it makes the most common developer flow — edit +config, run, walk away, approve from the phone — permanently unanswerable from mobile, and it does +nothing for the M2 dispatcher (a server-side path with no UI to defer to). It also silently +depends on `buildWorkflowReferences` never starting to emit a revision ref for draft runs. + +### Recommendation — **A1, with C's detector as the fallback branch** + +A1 is the only option that makes the resumed run *actually correct* rather than *refused*, it is +the smallest change that closes the tool-permission hole, it needs no migration, and its blob is +bounded and already-persisted-shaped. Ship C's discriminator too, but as the **fallback for legacy +rows** (`data.parameters` absent **and** `workflow_revision` absent → warn/defer), not as the +primary answer. Defer B until mobile composes fresh turns; when that day comes, B rides the +records log (no migration), not a `session_turns` column. + +### Where the fix belongs + +**The SDK owns the value; the runner owns the row; the API owns the replay.** The SDK is the only +component that has the hydrated `parameters` (§1.0), so it must emit it. The runner already writes +the row at exactly the right moment and must not learn what a config *means* — it echoes an opaque +blob. The API changes are two lines of DTO plus one line in the dispatcher. No new endpoint. + +--- + +## 3. Task list + +| # | Area | Task | +|---|---|---| +| T1 | SDK | `sdks/python/agenta/sdk/agents/utils/wire.py` — add an optional `effectiveParameters` to the `/run` payload, **emitted only when `session_id` is set** so non-session runs stay byte-identical to the golden wire contract. Source it from the handler's already-resolved `parameters` (`agents/handler.py:249-320`) or `TracingContext.get().parameters` (`resolver.py:669`). | +| T2 | SDK tests | Extend the golden wire fixtures (`sdks/python/oss/tests/pytest/unit/agents/golden`) with a session run that carries `effectiveParameters` **and** a non-session run that still does not. Add a unit test that the emitted blob equals the post-hydration `data.parameters` for both a references-only invoke and an inline-parameters invoke. | +| T3 | Runner | `services/runner/src/protocol.ts` — `effectiveParameters?: Record` on `AgentRunRequest` (opaque; **not** part of `configFingerprint`, `session-identity.ts:145-176`, so it cannot itself trip an eviction). `run-turn.ts:456-478` `recordPendingInteraction` — include it as `parameters` in the interaction `data` alongside `request`/`references`. | +| T4 | Runner tests | New `services/runner/tests/unit/interactions-parameters.test.ts`: (a) the POSTed body carries `data.parameters` when the request has `effectiveParameters`; (b) the key is **absent** (not `null`/`{}`) when it does not; (c) `configFingerprint` is unchanged by the new field. | +| T5 | API | `api/oss/src/core/sessions/interactions/dtos.py:24-28` — add `parameters: Optional[Dict[str, Any]] = None` to `SessionInteractionData` (required: the model defaults to `extra="ignore"`, so without this the runner's key is dropped on ingest **and** on read-back in `mappings.py`). **No migration** — `data` is a schemaless `json` column. | +| T6 | API | `api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py:259-300` — when `interaction.data.parameters` is present, set it on `WorkflowServiceRequestData(parameters=…)` (the field already exists: `sdks/python/agenta/sdk/models/workflows.py:226-238`); when absent, keep today's references-only body verbatim. | +| T7 | API tests | Extend `api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py` with both branches (parameters present → inline + references still sent; absent → byte-identical to today), and add a round-trip test through create → fetch → query proving the DTO carries `parameters` (guards the `extra="ignore"` trap). | +| T8 | API / SDK | **Redaction + cap before stamping.** Audit `parameters.agent.mcps[]` and `tools[]` for any `headers`/`authorization`-shaped value (§1.3 found none in the dev corpus, but the schema permits `mcps` headers); strip them, and hard-cap the stamped blob (suggest 64 KB — 3× the measured max) with a log line on truncation rather than a silent drop. | +| T9 | Web (package) | `web/packages/agenta-chat/src/transport/agentResumeRequest.ts` — optional `parameters`; emit `data.parameters` **only when non-empty**. Update the module docstring: the invariant becomes "no `parameters` key unless we are deliberately replaying a stamped effective config". Update the existing invariant unit test to pin both directions. | +| T10 | Mobile | `web/mobile/src/features/chat/useApprovalActions.ts:101-120` — read `row.data.parameters` and pass it to `buildAgentResumeRequest`. Keep the existing references-only path when absent. Replace the current hard failure at :116-120 with the C-style fallback: no parameters **and** no `workflow_revision` → "this approval was made against unsaved config — answer on desktop". | +| T11 | Entities | `web/packages/agenta-entities/src/session/api/api.ts` — surface `data.parameters` on the interaction row type returned by `queryInteractions`/`fetchInteraction` (currently typed without it). | +| T12 | Harness | Extend `scratchpad/effective_config_harness.py` with an assertion arm: park a gate on a dirty run whose draft `runner.permissions.default` differs from the committed revision's, answer it, and assert the runner logs `permission=` on the resumed gate (today it logs the committed value — see §1.2 Answer 2b). This is the regression the whole plan exists to prevent. | +| T13 | Docs | Note in `docs/design/agenta-mobile/plans/2026-07-27-mobile-approvals-steering.md` §1.3 that the interaction row now carries the effective config, and that a pre-change row is answerable but degrades to hydration. | + +**Sequencing:** T1→T3→T5 must land together (the blob is dropped at any missing link) but can be +one stacked series: SDK lane → runner lane → API lane → web lane. T9-T11 are independent of the +backend lanes only in the sense that they no-op until the backend stamps; land them last. + +**Explicit non-goals:** changing the `awaiting_approval` branch's deliberate skip of the +config-fingerprint check (`server.ts:682-695`) — with A1 the resume's fingerprint matches by +construction, so the skip stops mattering; and reconfiguring a warm sandbox mid-session, which is +out of scope for every option here. From e2ddea4d89d6c1dac401664efaeceac53cb97c13 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 29 Jul 2026 12:55:59 +0300 Subject: [PATCH 3/8] feat(sdk): stamp the effective turn config on the /run wire A HITL gate answered from a client that cannot reproduce the turn's config (mobile, the M2 dispatcher) resumes references-only, so the SDK hydrates the referenced variant's HEAD revision instead of what the gated turn was running - wrong model, wrong instructions, and wrong tool permissions on a dirty run. The SDK is the only component holding the post-hydration config, so it emits it: the handler carries its resolved data.parameters onto SessionConfig, and request_to_wire puts it on the wire as the opaque effectiveParameters. Emitted only for a session run (nothing else can park a gate), so an ad-hoc run's payload stays byte-identical to the golden contract. Redacted (an MCP connection's static headers are the one place the schema permits a raw credential; the vault-key refs survive) and capped at 64 KB, dropped whole with a log line rather than truncated into invalid JSON. The runner echoes the blob onto the interaction row; the answering client replays it as data.parameters, which suppresses hydration and reproduces the turn. --- .../agenta/sdk/agents/adapters/local.py | 3 +- .../sdk/agents/adapters/sandbox_agent.py | 5 + sdks/python/agenta/sdk/agents/dtos.py | 5 + sdks/python/agenta/sdk/agents/handler.py | 5 + sdks/python/agenta/sdk/agents/interfaces.py | 4 +- .../sdk/agents/utils/effective_config.py | 131 +++++++++++++++ sdks/python/agenta/sdk/agents/utils/wire.py | 16 ++ sdks/python/agenta/sdk/agents/wire_models.py | 6 + .../agents/_fake_runner_backend.py | 5 + .../oss/tests/pytest/unit/agents/conftest.py | 2 + .../agents/golden/run_request.pi_core.json | 24 ++- .../agents/test_agent_composition_seam.py | 28 ++++ .../unit/agents/test_redaction_scope.py | 2 + .../pytest/unit/agents/test_wire_contract.py | 156 ++++++++++++++++++ ...test_batch_fold_stream_contract_routing.py | 1 + ...nvoke_real_handlers_negotiation_routing.py | 1 + .../oss/tests/pytest/unit/agent/conftest.py | 3 + 17 files changed, 394 insertions(+), 3 deletions(-) create mode 100644 sdks/python/agenta/sdk/agents/utils/effective_config.py diff --git a/sdks/python/agenta/sdk/agents/adapters/local.py b/sdks/python/agenta/sdk/agents/adapters/local.py index 4f2dfe761d..b0a991ccdb 100644 --- a/sdks/python/agenta/sdk/agents/adapters/local.py +++ b/sdks/python/agenta/sdk/agents/adapters/local.py @@ -20,7 +20,7 @@ from __future__ import annotations -from typing import Mapping, Optional +from typing import Any, Dict, Mapping, Optional from ..dtos import HarnessAgentTemplate, HarnessKind, RunContext, TraceContext from ..interfaces import Backend, Sandbox, Session @@ -47,6 +47,7 @@ async def create_session( trace: Optional[TraceContext] = None, run_context: Optional[RunContext] = None, session_id: Optional[str] = None, + effective_parameters: Optional[Dict[str, Any]] = None, ) -> Session: raise NotImplementedError( "LocalBackend is not implemented yet (Phase 3: Pi via bundled JS, " diff --git a/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py b/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py index 8456d6e770..be3aac23e6 100644 --- a/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py +++ b/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py @@ -67,6 +67,7 @@ def __init__( trace: Optional[TraceContext], run_context: Optional[RunContext], session_id: Optional[str], + effective_parameters: Optional[Dict[str, Any]] = None, ) -> None: self._backend = backend self._sandbox = sandbox @@ -75,6 +76,7 @@ def __init__( self._trace = trace self._run_context = run_context self._session_id = session_id + self._effective_parameters = effective_parameters @property def id(self) -> Optional[str]: @@ -90,6 +92,7 @@ def _wire_payload(self, messages: Sequence[Message]) -> Dict[str, Any]: trace=self._trace, run_context=self._run_context, session_id=self._session_id, + effective_parameters=self._effective_parameters, ) def _absorb_result(self, result: AgentResult) -> None: @@ -162,6 +165,7 @@ async def create_session( trace: Optional[TraceContext] = None, run_context: Optional[RunContext] = None, session_id: Optional[str] = None, + effective_parameters: Optional[Dict[str, Any]] = None, ) -> SandboxAgentSession: if not isinstance(sandbox, SandboxAgentSandbox): raise TypeError( @@ -175,6 +179,7 @@ async def create_session( trace=trace, run_context=run_context, session_id=session_id, + effective_parameters=effective_parameters, ) async def _deliver_result(self, payload: Dict[str, Any]) -> Dict[str, Any]: diff --git a/sdks/python/agenta/sdk/agents/dtos.py b/sdks/python/agenta/sdk/agents/dtos.py index c1b57b7841..e3518f10b7 100644 --- a/sdks/python/agenta/sdk/agents/dtos.py +++ b/sdks/python/agenta/sdk/agents/dtos.py @@ -1110,6 +1110,11 @@ 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 + # 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 + # by a harness, so it changes nothing about how the turn executes. + effective_parameters: Optional[Dict[str, Any]] = Field(default=None, repr=False) tool_specs: List[ToolSpec] = Field( default_factory=list, validation_alias=AliasChoices("tool_specs", "custom_tools"), diff --git a/sdks/python/agenta/sdk/agents/handler.py b/sdks/python/agenta/sdk/agents/handler.py index 40a7993c81..615cdabd21 100644 --- a/sdks/python/agenta/sdk/agents/handler.py +++ b/sdks/python/agenta/sdk/agents/handler.py @@ -296,6 +296,11 @@ async def _agent( trace=comp.trace_context(), run_context=rc, session_id=session_id, + # 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 + # under. Carried, redacted and capped at the wire (`utils/effective_config.py`). + effective_parameters=parameters, tool_specs=resolved_tools.tool_specs, tool_callback=resolved_tools.tool_callback, mcp_servers=resolved_mcp, diff --git a/sdks/python/agenta/sdk/agents/interfaces.py b/sdks/python/agenta/sdk/agents/interfaces.py index 804ed1ed6d..6f4c555433 100644 --- a/sdks/python/agenta/sdk/agents/interfaces.py +++ b/sdks/python/agenta/sdk/agents/interfaces.py @@ -19,7 +19,7 @@ import asyncio from abc import ABC, abstractmethod -from typing import ClassVar, FrozenSet, Mapping, Optional, Sequence +from typing import Any, ClassVar, Dict, FrozenSet, Mapping, Optional, Sequence from .dtos import ( AgentResult, @@ -129,6 +129,7 @@ async def create_session( trace: Optional[TraceContext] = None, run_context: Optional[RunContext] = None, session_id: Optional[str] = None, + effective_parameters: Optional[Dict[str, Any]] = None, ) -> Session: """Open a session in ``sandbox`` for an already-harness-shaped ``config``.""" @@ -198,6 +199,7 @@ async def create_session( trace=session_config.trace, run_context=session_config.run_context, session_id=session_config.session_id, + effective_parameters=session_config.effective_parameters, ) diff --git a/sdks/python/agenta/sdk/agents/utils/effective_config.py b/sdks/python/agenta/sdk/agents/utils/effective_config.py new file mode 100644 index 0000000000..3911bd4069 --- /dev/null +++ b/sdks/python/agenta/sdk/agents/utils/effective_config.py @@ -0,0 +1,131 @@ +"""The effective turn config stamped onto the ``/run`` wire (``effectiveParameters``). + +**Why it exists.** When a HITL gate is answered from a client that cannot reproduce the +turn's config — mobile, or the server-side M2 dispatcher — the resume arrives +references-only, so the SDK hydrates the referenced variant's HEAD revision instead of what +the gated turn was actually running (see +``docs/design/agenta-mobile/plans/2026-07-29-effective-turn-config.md``). The runner echoes +this blob onto the durable interaction row, and the answering client replays it inline as +``data.parameters``, which suppresses hydration +(``middlewares/running/resolver.py`` ``_caller_supplied_configuration``) and reproduces the +turn — tool permissions included. + +Two guards run before a config becomes durable: + +- **Redaction.** The blob is author intent, and the one place the schema permits a raw + credential VALUE is an MCP server's static ``connection.headers`` + (``agents/mcp/models.py`` ``MCPConnection``). Those are dropped. The secret REFS + (``connection.credentials``, which hold vault key NAMES, never values) survive, so a + replayed run re-resolves the same credentials from the project vault. The cost is + deliberate: an author who inlines a static header into an MCP connection loses that header + on a replayed resume rather than having it persisted in a second place. +- **Size cap.** Measured over the dev corpus (n=326 revisions with parameters): avg 761 B, + p90 1.4 KB, max 20 KB — the large ones are entirely tool JSON-Schema. Anything over + :data:`MAX_STAMPED_BYTES` is dropped WHOLE with a warning (a truncated blob would be + invalid JSON, and a silently truncated config is worse than none); the resume then degrades + to today's references-only hydration. +""" + +from __future__ import annotations + +import json +from typing import Any, Dict, Optional + +from agenta.sdk.utils.logging import get_module_logger + +log = get_module_logger(__name__) + +# 3x the largest config measured in the dev corpus. Over this the blob is not stamped at all. +MAX_STAMPED_BYTES = 64 * 1024 + +# Keys that can hold a raw credential VALUE on a tool/MCP entry or its connection descriptor. +# `credentials` is deliberately NOT here: it holds vault key names, which the replay needs. +_CREDENTIAL_KEYS = frozenset({"headers", "authorization"}) + +# Only these lists carry connection descriptors; the rest of the config is inert authored text +# and JSON-Schema, and blanket key-stripping there would mangle a tool's input schema +# (a schema property may legitimately be named "headers"). +_CONNECTION_BEARING_LISTS = ("mcps", "tools") +_NESTED_CONNECTION_KEYS = ("connection", "call") + + +def _redact_entry(entry: Any, stripped: list) -> Any: + if not isinstance(entry, dict): + return entry + cleaned = dict(entry) + for key in _CREDENTIAL_KEYS: + if cleaned.pop(key, None) is not None: + stripped.append(key) + for nested_key in _NESTED_CONNECTION_KEYS: + nested = cleaned.get(nested_key) + if not isinstance(nested, dict): + continue + nested_clean = dict(nested) + for key in _CREDENTIAL_KEYS: + if nested_clean.pop(key, None) is not None: + stripped.append(f"{nested_key}.{key}") + cleaned[nested_key] = nested_clean + return cleaned + + +def redact_effective_parameters(parameters: Dict[str, Any]) -> Dict[str, Any]: + """Copy ``parameters`` with credential-shaped values dropped from ``agent.mcps``/``tools``. + + Returns the input unchanged (a shallow copy) when there is nothing to redact. + """ + agent = parameters.get("agent") + if not isinstance(agent, dict): + return parameters + + stripped: list = [] + agent_clean = dict(agent) + for list_key in _CONNECTION_BEARING_LISTS: + entries = agent_clean.get(list_key) + if not isinstance(entries, list): + continue + agent_clean[list_key] = [_redact_entry(entry, stripped) for entry in entries] + + if not stripped: + return parameters + + log.warning( + "agent: stripped %d credential-shaped field(s) from the stamped effective config: %s", + len(stripped), + sorted(set(stripped)), + ) + cleaned = dict(parameters) + cleaned["agent"] = agent_clean + return cleaned + + +def stamp_effective_parameters( + parameters: Optional[Dict[str, Any]], +) -> Optional[Dict[str, Any]]: + """The blob to put on the wire, or ``None`` when there is nothing safe to stamp. + + ``None`` for an empty/non-dict config, for one that does not serialize, and for one over + :data:`MAX_STAMPED_BYTES` — each logged, never silent. + """ + if not isinstance(parameters, dict) or not parameters: + return None + + redacted = redact_effective_parameters(parameters) + + try: + size = len(json.dumps(redacted).encode("utf-8")) + except (TypeError, ValueError) as e: + log.warning( + "agent: effective config is not JSON-serializable; not stamped (%s)", e + ) + return None + + if size > MAX_STAMPED_BYTES: + log.warning( + "agent: effective config is %d B, over the %d B stamp cap; not stamped " + "(a resume against this turn falls back to reference hydration)", + size, + MAX_STAMPED_BYTES, + ) + return None + + return redacted diff --git a/sdks/python/agenta/sdk/agents/utils/wire.py b/sdks/python/agenta/sdk/agents/utils/wire.py index 3ece69488b..efa988724c 100644 --- a/sdks/python/agenta/sdk/agents/utils/wire.py +++ b/sdks/python/agenta/sdk/agents/utils/wire.py @@ -23,6 +23,7 @@ from agenta.sdk.utils.logging import get_module_logger from agenta.sdk.redaction.context import get_active_redactor +from .effective_config import stamp_effective_parameters from ..permission_rules import PermissionRule from ..errors import AgentRunFailed from ..dtos import ( @@ -91,6 +92,7 @@ def request_to_wire( session_id: Optional[str] = None, turn_id: Optional[str] = None, project_id: Optional[str] = None, + effective_parameters: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """Serialize one turn into the ``/run`` request JSON. @@ -119,6 +121,16 @@ def request_to_wire( set it rides as ``runContext`` and is consumed by tool context bindings at dispatch (``call.context`` on direct-call specs and ``contextBindings`` on callRef specs) (direct-call tools, Phase 3a). Omitted when unset (and when its ``to_wire`` is empty), so a run that needs no binding stays byte-identical to before. + + ``effective_parameters`` is the POST-HYDRATION config this turn actually runs (the handler's + resolved ``data.parameters``). It rides as the opaque ``effectiveParameters`` ONLY on a + session run — a non-session run has no interaction row to stamp it onto, so its payload stays + byte-identical to the golden contract. The runner echoes it onto the durable interaction row + of any HITL gate this turn parks, so a client that answers the gate without being able to + reproduce the config (mobile, the M2 dispatcher) can replay the exact turn instead of + hydrating the referenced variant's HEAD. Redacted and size-capped by + ``effective_config.stamp_effective_parameters``, which returns ``None`` (key omitted) when + there is nothing safe to stamp. """ payload: Dict[str, Any] = { "harness": harness.value, @@ -152,6 +164,10 @@ def request_to_wire( payload["turnId"] = turn_id if project_id is not None: payload["projectId"] = project_id + if session_id: + stamped = stamp_effective_parameters(effective_parameters) + if stamped: + payload["effectiveParameters"] = stamped return payload diff --git a/sdks/python/agenta/sdk/agents/wire_models.py b/sdks/python/agenta/sdk/agents/wire_models.py index 1c74d9b4d2..de39df43b7 100644 --- a/sdks/python/agenta/sdk/agents/wire_models.py +++ b/sdks/python/agenta/sdk/agents/wire_models.py @@ -525,6 +525,12 @@ class WireRunRequest(_WireModel): harness_files: Optional[List[WireHarnessFile]] = Field( default=None, alias="harnessFiles" ) + # The post-hydration config this turn runs, opaque to the runner: it echoes the blob onto + # the interaction row of any HITL gate the turn parks, so the answering client can replay + # the exact config instead of re-hydrating references. Session runs only. + effective_parameters: Optional[Dict[str, Any]] = Field( + default=None, alias="effectiveParameters" + ) # --------------------------------------------------------------------------- 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 d5974be435..3d4a0b454c 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 @@ -57,6 +57,7 @@ def __init__( trace: Optional[TraceContext], run_context: Optional[RunContext], session_id: Optional[str], + effective_parameters: Optional[Dict[str, Any]] = None, ) -> None: self._backend = backend self._config = config @@ -64,6 +65,7 @@ def __init__( self._trace = trace self._run_context = run_context self._session_id = session_id + self._effective_parameters = effective_parameters @property def id(self) -> Optional[str]: @@ -79,6 +81,7 @@ def _wire_payload(self, messages: Sequence[Message]) -> Dict[str, Any]: trace=self._trace, run_context=self._run_context, session_id=self._session_id, + effective_parameters=self._effective_parameters, ) def _absorb_result(self, result: AgentResult) -> None: @@ -154,6 +157,7 @@ async def create_session( trace: Optional[TraceContext] = None, run_context: Optional[RunContext] = None, session_id: Optional[str] = None, + effective_parameters: Optional[Dict[str, Any]] = None, ) -> FakeRunnerSession: return FakeRunnerSession( self, @@ -162,6 +166,7 @@ async def create_session( trace=trace, run_context=run_context, session_id=session_id, + effective_parameters=effective_parameters, ) async def _deliver_result(self, payload: Dict[str, Any]) -> Dict[str, Any]: diff --git a/sdks/python/oss/tests/pytest/unit/agents/conftest.py b/sdks/python/oss/tests/pytest/unit/agents/conftest.py index e5375b6d05..467ae4c9ed 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, + effective_parameters=None, ) -> FakeSession: self.created_sessions.append( { @@ -155,6 +156,7 @@ async def create_session( "trace": trace, "run_context": run_context, "session_id": session_id, + "effective_parameters": effective_parameters, } ) session = FakeSession( diff --git a/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.pi_core.json b/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.pi_core.json index 1363f84b52..875874e3f9 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.pi_core.json +++ b/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.pi_core.json @@ -31,7 +31,15 @@ } } }, - "tools": ["read", "bash", "edit", "write", "grep", "find", "ls"], + "tools": [ + "read", + "bash", + "edit", + "write", + "grep", + "find", + "ls" + ], "customTools": [ { "name": "get_user", @@ -146,5 +154,19 @@ "trace_id": "0af7651916cd43dd8448eb211c80319c", "span_id": "b7ad6b7169203331" } + }, + "effectiveParameters": { + "agent": { + "instructions": "You are a helpful assistant.", + "llm": { + "model": "openai-codex/gpt-5.5", + "provider": "openai" + }, + "runner": { + "permissions": { + "default": "allow_reads" + } + } + } } } 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 ab956aca25..54657f9fb0 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 @@ -83,6 +83,7 @@ class _FakeBackend(Backend): def __init__(self, *, output: str = "hi") -> None: self._output = output self.created_run_contexts: List[Any] = [] + self.created_effective_parameters: List[Any] = [] async def create_sandbox(self) -> _FakeSandbox: return _FakeSandbox() @@ -97,8 +98,10 @@ async def create_session( trace=None, run_context=None, session_id=None, + effective_parameters=None, ) -> _FakeSession: self.created_run_contexts.append(run_context) + self.created_effective_parameters.append(effective_parameters) return _FakeSession(AgentResult(output=self._output, events=[], usage={})) @@ -168,6 +171,31 @@ async def test_absent_run_kind_leaves_composition_run_context_untouched(): assert ctx.to_wire() == {"trace": {"trace_id": "trace-1"}} +async def test_handler_carries_the_effective_config_onto_the_session(): + """The config the handler RAN with reaches the session, verbatim. + + The normalizer hands the handler ``request.data.parameters`` after the resolver has + hydrated references (or kept the caller's inline draft), so this is the config a HITL gate + parked by this turn must be resumable under (effective-turn-config plan, T1). The wire + gates emission on ``session_id``; the handler always carries it. + """ + backend = _FakeBackend() + comp = AgentComposition( + select_backend=lambda template: backend, + resolve_connection=_no_connection, + ) + handler = make_agent_handler(comp) + params = _params(model={"model": "anthropic/claude-sonnet-4-5"}) + + await handler( + request=_request(), + messages=[{"role": "user", "content": "hi"}], + parameters=params, + ) + + assert backend.created_effective_parameters[0] == params + + # --------------------------------------------------------------------------- # # Drift 1 + 2: capability and fail-closed resolution policy are the SEAM DEFAULT now # (previously a bare fallback in handler.py with neither). 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 5e54765f81..80f9d9b2be 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,8 @@ async def create_session( trace=None, run_context=None, session_id=None, + # Interface parity only; these tests assert on the redaction scope, not the wire. + effective_parameters=None, ) -> _FakeSession: self.captured_redactors.append(get_active_redactor()) return _FakeSession(AgentResult(output=self._output, events=[], usage={})) 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 a95d50fadb..e0aea69879 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,7 @@ ToolResolver, TraceContext, ) +from agenta.sdk.agents.utils.effective_config import MAX_STAMPED_BYTES from agenta.sdk.agents.utils.wire import ( request_to_wire, result_from_wire, @@ -91,6 +92,17 @@ "harnessFiles", "turnId", "projectId", + "effectiveParameters", +} + +# The post-hydration config a session turn runs. Stamped on the wire so the runner can echo it +# onto a parked gate's interaction row (effective-turn-config plan, T1/T3). +_EFFECTIVE_PARAMETERS = { + "agent": { + "instructions": "You are a helpful assistant.", + "llm": {"model": "openai-codex/gpt-5.5", "provider": "openai"}, + "runner": {"permissions": {"default": "allow_reads"}}, + } } _CUSTOM_TOOL = { @@ -190,6 +202,7 @@ def _pi_payload(): ), ), session_id="sess-1", + effective_parameters=dict(_EFFECTIVE_PARAMETERS), ) @@ -232,6 +245,10 @@ def _claude_payload(): trace=None, run_context=RunContext(run=RunContextRun(kind="test")), session_id=None, + # Deliberately supplied on a NON-session run: the gate is `session_id`, so this golden + # pins that an ad-hoc run's payload carries no `effectiveParameters` (nothing will ever + # park a gate against it, and the wire stays byte-identical to the pre-change contract). + effective_parameters=dict(_EFFECTIVE_PARAMETERS), ) @@ -534,6 +551,145 @@ def test_request_to_wire_omits_project_id_when_none(): assert "projectId" not in payload +def test_request_to_wire_carries_effective_parameters_on_a_session_run(): + # The post-hydration config the turn runs, stamped so the runner can echo it onto a parked + # gate's interaction row (effective-turn-config plan, T1). + payload = request_to_wire( + harness=HarnessKind.PI, + sandbox="local", + config=PiAgentTemplate(), + messages=[Message(role="user", content="hi")], + session_id="sess-1", + effective_parameters=dict(_EFFECTIVE_PARAMETERS), + ) + assert payload["effectiveParameters"] == _EFFECTIVE_PARAMETERS + assert set(payload) <= KNOWN_REQUEST_KEYS + + +def test_request_to_wire_omits_effective_parameters_without_a_session(): + # A non-session run can never park a gate, so the field is not emitted and the ad-hoc wire + # stays byte-identical to the pre-change contract. + payload = request_to_wire( + harness=HarnessKind.PI, + sandbox="local", + config=PiAgentTemplate(), + messages=[Message(role="user", content="hi")], + session_id=None, + effective_parameters=dict(_EFFECTIVE_PARAMETERS), + ) + assert "effectiveParameters" not in payload + + +def test_request_to_wire_omits_effective_parameters_when_empty(): + # Nothing to stamp -> no key (never a noise `"effectiveParameters": {}`). + for empty in (None, {}): + payload = request_to_wire( + harness=HarnessKind.PI, + sandbox="local", + config=PiAgentTemplate(), + messages=[Message(role="user", content="hi")], + session_id="sess-1", + effective_parameters=empty, + ) + assert "effectiveParameters" not in payload + + +def test_effective_parameters_equal_the_post_hydration_config_either_way(): + """The stamped blob is whatever the handler ran with, on both invoke shapes. + + The resolver decides hydration purely from what the caller sent + (``_caller_supplied_configuration``): a references-only invoke gets the hydrated revision's + parameters, an inline-parameters invoke keeps the caller's. Either way the handler is + handed ONE ``data.parameters`` dict, and that is exactly what reaches the wire — which is + what makes a resume replaying the blob reproduce the turn. + """ + hydrated = {"agent": {"llm": {"model": "anthropic/claude-haiku-4-5"}}} + inline_draft = {"agent": {"llm": {"model": "anthropic/claude-sonnet-4-5"}}} + + for post_hydration in (hydrated, inline_draft): + payload = request_to_wire( + harness=HarnessKind.PI, + sandbox="local", + config=PiAgentTemplate(), + messages=[Message(role="user", content="hi")], + session_id="sess-1", + effective_parameters=post_hydration, + ) + assert payload["effectiveParameters"] == post_hydration + + +def test_effective_parameters_drop_mcp_connection_headers(): + # The one place the config schema permits a raw credential VALUE is an MCP server's static + # `connection.headers`; the vault-key REFS under `credentials` survive so the replayed run + # re-resolves the same secret. + payload = request_to_wire( + harness=HarnessKind.PI, + sandbox="local", + config=PiAgentTemplate(), + messages=[Message(role="user", content="hi")], + session_id="sess-1", + effective_parameters={ + "agent": { + "mcps": [ + { + "name": "notion", + "connection": { + "type": "http", + "url": "https://mcp.example/sse", + "headers": {"authorization": "Bearer sk-live-1234"}, + "credentials": { + "type": "header_secret_refs", + "headers": {"authorization": "NOTION_TOKEN"}, + }, + }, + } + ] + } + }, + ) + connection = payload["effectiveParameters"]["agent"]["mcps"][0]["connection"] + assert "headers" not in connection + assert connection["url"] == "https://mcp.example/sse" + assert connection["credentials"]["headers"] == {"authorization": "NOTION_TOKEN"} + + +def test_effective_parameters_preserve_tool_input_schema_properties(): + # Redaction is scoped to connection descriptors: a tool's JSON-Schema may legitimately + # declare a property named `headers`, and mangling it would change the tool contract the + # replayed run exposes to the model. + tool = { + "name": "http_get", + "inputSchema": { + "type": "object", + "properties": {"headers": {"type": "object"}}, + }, + } + payload = request_to_wire( + harness=HarnessKind.PI, + sandbox="local", + config=PiAgentTemplate(), + messages=[Message(role="user", content="hi")], + session_id="sess-1", + effective_parameters={"agent": {"tools": [tool]}}, + ) + assert payload["effectiveParameters"]["agent"]["tools"][0] == tool + + +def test_effective_parameters_over_the_cap_are_dropped_whole(): + # A truncated config is invalid JSON and a silently-truncated one is worse than none, so an + # oversize blob is not stamped at all (the resume degrades to reference hydration). + oversize = {"agent": {"instructions": "x" * (MAX_STAMPED_BYTES + 1)}} + payload = request_to_wire( + harness=HarnessKind.PI, + sandbox="local", + config=PiAgentTemplate(), + messages=[Message(role="user", content="hi")], + session_id="sess-1", + effective_parameters=oversize, + ) + assert "effectiveParameters" not in payload + + def test_request_to_wire_claude_matches_golden(golden): payload = _claude_payload() assert payload == golden("run_request.claude.json") 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 157a440097..6c8617261d 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 @@ -142,6 +142,7 @@ async def create_session( trace=None, run_context=None, session_id=None, + effective_parameters=None, ) -> _FakeSession: # Fresh session per call: stream and batch requests each get their own iterator. return _FakeSession(AgentResult(output="here you go", events=self._events)) 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 00775f56ab..ccd1cb4184 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 @@ -146,6 +146,7 @@ async def create_session( trace=None, run_context=None, session_id=None, + effective_parameters=None, ) -> _FakeSession: return _FakeSession( AgentResult(output=self._output, events=self._events, usage={"total": 5}) diff --git a/services/oss/tests/pytest/unit/agent/conftest.py b/services/oss/tests/pytest/unit/agent/conftest.py index 7e0e6b9f1b..09fa79482e 100644 --- a/services/oss/tests/pytest/unit/agent/conftest.py +++ b/services/oss/tests/pytest/unit/agent/conftest.py @@ -122,6 +122,9 @@ async def create_session( trace=None, run_context=None, session_id=None, + # 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, ) -> _FakeSession: self.created_configs.append(config) self.created_session_ids.append(session_id) From 3158b4c4455d89b2d4582c56970fd479164ba936 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 29 Jul 2026 13:18:59 +0300 Subject: [PATCH 4/8] feat(runner): echo the effective turn config onto the interaction row The runner writes the durable row at the exact moment a gate parks, so it is where the turn's config has to be recorded - but it had no access to it (there was no parameters field on the /run wire at all). The SDK now stamps effectiveParameters; this echoes it verbatim into the row's data.parameters, alongside data.request and data.references. Opaque by design: the runner never reads inside the blob and derives no behavior from it. It is deliberately NOT in configFingerprint - that hash decides warm resume vs cold replay, and the blob is a projection of fields already hashed, so including it would let a cosmetic serialization change evict every warm session. A turn with no stamped config omits the KEY (not null, not {}): a legacy row keeps its exact shape, and an empty inline config would still suppress hydration server-side and resume a toolless agent. --- .../src/engines/sandbox_agent/run-turn.ts | 17 +- services/runner/src/protocol.ts | 12 ++ services/runner/src/sessions/interactions.ts | 40 ++++- .../unit/interactions-parameters.test.ts | 151 ++++++++++++++++++ .../runner/tests/unit/wire-contract.test.ts | 12 ++ 5 files changed, 219 insertions(+), 13 deletions(-) create mode 100644 services/runner/tests/unit/interactions-parameters.test.ts diff --git a/services/runner/src/engines/sandbox_agent/run-turn.ts b/services/runner/src/engines/sandbox_agent/run-turn.ts index 412b5ba5c8..34a789fc4d 100644 --- a/services/runner/src/engines/sandbox_agent/run-turn.ts +++ b/services/runner/src/engines/sandbox_agent/run-turn.ts @@ -19,6 +19,7 @@ import { extractClientToolOutputs, } from "../../responder.ts"; import { + buildInteractionData, buildWorkflowReferences, createInteraction, resolveInteraction, @@ -587,23 +588,15 @@ export async function runTurn( ): void => { const cred = runCredential(request); if (!cred) return; - const references = buildWorkflowReferences(request.runContext?.workflow); - // Every gate leaves a durable inbox/audit row; workflow references are attribution, not a precondition. + // Every gate leaves a durable inbox/audit row; workflow references are attribution, not a + // precondition. The row also carries the turn's effective config when the SDK stamped one, + // so an out-of-band answer replays THIS turn, not the referenced variant's HEAD. void createInteraction( sessionId, request.turnId ?? "", token, kind, - { - request: { - tool: toolName ?? token, - args: toolArgs, - // The gate id (`token`) and the harness's tool-call id differ; an out-of-band answer - // needs the latter to name the call it is answering. - ...(toolCallId ? { tool_call_id: toolCallId } : {}), - }, - references, - }, + buildInteractionData(request, toolName ?? token, toolArgs, toolCallId), () => cred, ); }; diff --git a/services/runner/src/protocol.ts b/services/runner/src/protocol.ts index ea7ceb60e7..7c029ce978 100644 --- a/services/runner/src/protocol.ts +++ b/services/runner/src/protocol.ts @@ -680,6 +680,18 @@ export interface AgentRunRequest { * the runner can include it in heartbeat and record-ingest calls. Absent otherwise. */ projectId?: string; + /** + * The post-hydration config this turn runs, produced by the SDK (`agents/utils/wire.py`) and + * OPAQUE here: the runner never reads inside it and never derives behavior from it. It is + * echoed verbatim onto the `data.parameters` of every interaction row this turn writes, so a + * client that answers the gate without being able to reproduce the config (mobile, the M2 + * dispatcher) can replay the exact turn instead of hydrating the referenced variant's HEAD. + * + * Deliberately NOT part of `configFingerprint` (`session-identity.ts`): it is a projection of + * fields already in the fingerprint, so hashing it would let a cosmetic config-serialization + * change evict warm sessions. Session runs only; absent otherwise. + */ + effectiveParameters?: Record; /** * The session's `session_streams` row id, captured for free from the alive-watchdog's * heartbeat response (`sessions/alive.ts`) and threaded here before the engine runs. Present diff --git a/services/runner/src/sessions/interactions.ts b/services/runner/src/sessions/interactions.ts index c4e2945f74..884d0de779 100644 --- a/services/runner/src/sessions/interactions.ts +++ b/services/runner/src/sessions/interactions.ts @@ -35,6 +35,14 @@ export type InteractionData = { request?: InteractionRequest; // Optional attribution for out-of-band re-invocation; inbox/audit rows exist without it. references?: Record; + /** + * The effective config the gated turn was running (the SDK's `effectiveParameters`, opaque + * here). A client answering this gate replays it as the invoke's `data.parameters`, which + * suppresses reference hydration server-side and reproduces the turn — most importantly its + * tool permissions. Absent on rows written before this field existed, and on turns whose + * config was too large or unsafe to stamp; those resume via `references` alone, as before. + */ + parameters?: Record; }; /** Build the invoke `references` from the runner's run-context workflow identity. */ @@ -55,10 +63,40 @@ export function buildWorkflowReferences( return Object.keys(refs).length ? refs : undefined; } +/** + * The durable `data` for one gate: what was asked, who to attribute it to, and what config the + * turn ran under. The two attribution fields are omitted (not null, not `{}`) when the request + * carries neither, so a legacy row's shape is exactly what it was before this field existed. + */ +export function buildInteractionData( + request: { + runContext?: { + workflow?: { + artifact?: Reference; + variant?: Reference; + revision?: Reference; + }; + }; + effectiveParameters?: Record; + }, + tool: string, + args: unknown, + toolCallId?: string, +): InteractionData { + const parameters = request.effectiveParameters; + return { + // The gate id (`token`) and the harness's tool-call id differ; an out-of-band answer + // needs the latter to name the call it is answering. + request: { tool, args, ...(toolCallId ? { tool_call_id: toolCallId } : {}) }, + references: buildWorkflowReferences(request.runContext?.workflow), + parameters: + parameters && Object.keys(parameters).length ? parameters : undefined, + }; +} + const INGEST_MAX_RETRIES = 3; const INGEST_RETRY_BASE_MS = 100; - function log(msg: string): void { process.stderr.write(`[sessions/interactions] ${msg}\n`); } diff --git a/services/runner/tests/unit/interactions-parameters.test.ts b/services/runner/tests/unit/interactions-parameters.test.ts new file mode 100644 index 0000000000..5972ed56f7 --- /dev/null +++ b/services/runner/tests/unit/interactions-parameters.test.ts @@ -0,0 +1,151 @@ +/** + * The effective turn config on the interaction row (effective-turn-config plan, T3/T4). + * + * A gate answered out-of-band (mobile, the API's M2 dispatcher) resumes references-only, so the + * SDK re-hydrates the referenced variant's HEAD revision instead of the config the gated turn + * was running. The SDK now stamps that config on the `/run` wire as `effectiveParameters`; the + * runner echoes it — opaquely — onto the row it writes at every pause, and the answering client + * replays it as `data.parameters`. + * + * Three things must hold, and each has bitten before: + * (a) present -> the POSTed body carries `data.parameters` verbatim; + * (b) absent -> the KEY is absent, not `null`/`{}` (a legacy row's shape is unchanged, and + * an empty inline config would still suppress hydration server-side and run a bare agent); + * (c) the new field must NOT move `configFingerprint` — that hash decides warm-resume vs cold + * replay, so a shift there would send every resume cold. + * + * Run: pnpm test (or: pnpm exec vitest run tests/unit/interactions-parameters.test.ts) + */ +import { describe, it, beforeEach, vi } from "vitest"; +import assert from "node:assert/strict"; + +import type { AgentRunRequest } from "../../src/protocol.ts"; + +const postedBodies: Array<{ url: string; body: any }> = []; + +vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => { + postedBodies.push({ + url: url as string, + body: init?.body ? JSON.parse(init.body as string) : undefined, + }); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); +}); + +const { buildInteractionData, createInteraction } = + await import("../../src/sessions/interactions.ts"); +const { configFingerprint } = + await import("../../src/engines/sandbox_agent/session-identity.ts"); +const EFFECTIVE_PARAMETERS = { + agent: { + instructions: "Draft config, not committed anywhere.", + llm: { model: "anthropic/claude-sonnet-4-5" }, + runner: { permissions: { default: "allow_reads" } }, + }, +}; + +function request( + effectiveParameters?: Record, +): AgentRunRequest { + return { + harness: "pi_core", + sandbox: "local", + sessionId: "sess-1", + model: "anthropic/claude-sonnet-4-5", + messages: [{ role: "user", content: "hi" }], + runContext: { + workflow: { + artifact: { id: "wf-1" }, + variant: { id: "var-1", slug: "agent.default" }, + }, + }, + ...(effectiveParameters ? { effectiveParameters } : {}), + } as AgentRunRequest; +} + +beforeEach(() => { + postedBodies.length = 0; +}); + +describe("buildInteractionData", () => { + it("carries the stamped effective config alongside request + references", () => { + const data = buildInteractionData(request(EFFECTIVE_PARAMETERS), "Bash", { + command: "echo hi", + }); + assert.deepEqual(data.request, { + tool: "Bash", + args: { command: "echo hi" }, + }); + assert.deepEqual(data.references, { + workflow: { id: "wf-1" }, + workflow_variant: { id: "var-1", slug: "agent.default" }, + }); + assert.deepEqual(data.parameters, EFFECTIVE_PARAMETERS); + }); + + it("omits parameters entirely when the request carries none", () => { + const data = buildInteractionData(request(), "Bash", { + command: "echo hi", + }); + assert.equal(data.parameters, undefined); + }); + + it("treats an empty stamped config as absent (never an empty inline config)", () => { + const data = buildInteractionData(request({}), "Bash", null); + assert.equal(data.parameters, undefined); + }); +}); + +describe("createInteraction with the effective config", () => { + it("POSTs data.parameters when the turn stamped one", async () => { + await createInteraction( + "sess-1", + "turn-1", + "tok-1", + "user_approval", + buildInteractionData(request(EFFECTIVE_PARAMETERS), "Bash", { + command: "echo hi", + }), + () => "Secret t", + ); + + assert.equal(postedBodies.length, 1); + assert.deepEqual( + postedBodies[0].body.data.parameters, + EFFECTIVE_PARAMETERS, + ); + }); + + it("omits the key from the POSTed JSON when the turn stamped none", async () => { + await createInteraction( + "sess-1", + "turn-1", + "tok-2", + "user_approval", + buildInteractionData(request(), "Bash", { command: "echo hi" }), + () => "Secret t", + ); + + const data = postedBodies[0].body.data; + // Not `null`, not `{}` — the key must not be present at all: the API DTO would persist a + // null and a replaying client would send an empty inline config, suppressing hydration. + assert.equal("parameters" in data, false); + assert.ok(data.request); + }); +}); + +describe("configFingerprint", () => { + it("is unchanged by the effective config (warm resumes must not go cold)", () => { + assert.equal( + configFingerprint(request(EFFECTIVE_PARAMETERS)), + configFingerprint(request()), + ); + }); + + it("still moves when a real config field changes (the guard is not vacuous)", () => { + const changed = { + ...request(EFFECTIVE_PARAMETERS), + model: "openai/gpt-4o-mini", + } as AgentRunRequest; + assert.notEqual(configFingerprint(changed), configFingerprint(request())); + }); +}); diff --git a/services/runner/tests/unit/wire-contract.test.ts b/services/runner/tests/unit/wire-contract.test.ts index f802b52b64..de75a80310 100644 --- a/services/runner/tests/unit/wire-contract.test.ts +++ b/services/runner/tests/unit/wire-contract.test.ts @@ -56,6 +56,7 @@ const KNOWN_REQUEST_KEYS = [ "harnessFiles", "turnId", "projectId", + "effectiveParameters", ] as const; // COMPILE-TIME drift guard: every wire key must be a field of AgentRunRequest. Drop or rename @@ -204,6 +205,15 @@ describe("wire contract: requests (vs Python golden)", () => { assert.equal(req.sandboxPermission!.enforcement, "strict"); // Pi renders no harness config files, so the generic `harnessFiles` is absent. assert.equal(req.harnessFiles, undefined); + // The turn's effective config reaches the runner opaquely; it is echoed onto any gate this + // turn parks so an out-of-band answer replays THIS config (effective-turn-config plan, T3). + assert.deepEqual(req.effectiveParameters, { + agent: { + instructions: "You are a helpful assistant.", + llm: { model: "openai-codex/gpt-5.5", provider: "openai" }, + runner: { permissions: { default: "allow_reads" } }, + }, + }); }); it("claude request: gates tool use, no prompt overrides, null session id", () => { @@ -245,6 +255,8 @@ describe("wire contract: requests (vs Python golden)", () => { assert.equal(skill.disableModelInvocation, true); assert.equal(skill.files![0].path, "scripts/draft.py"); assert.equal(skill.files![0].executable, true); + // A non-session run can never park a gate, so the SDK does not stamp its effective config. + assert.equal(req.effectiveParameters, undefined); // sessionId is null on the wire, so the runner falls back to its ephemeral id. assert.equal( resolveRunSessionId(req, "runner-ephemeral"), From 7c66ba2359ff19ea6dc7dd61c1915ab85fcdaab4 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 29 Jul 2026 13:29:42 +0300 Subject: [PATCH 5/8] feat(api): replay the gated turn's config on an interaction respond Two links in the same chain. The DTO: SessionInteractionData is a closed pydantic model with the default extra="ignore", and the postgres mapping round-trips through it on write and on read even though data is a schemaless json column - so the runner's new data.parameters was being dropped twice with no error anywhere. Declaring the field is the whole fix; no migration. The dispatcher: when the row carries a config, send it inline on the resume. The SDK resolver decides hydration purely from what the caller sent, so inline parameters suppress it and the run continues under the config the gate was raised against instead of the referenced variant's HEAD revision. References still ride along as attribution. A row written before the runner stamped configs has none and produces the byte-identical references-only body this dispatcher has always sent. --- .../src/core/sessions/interactions/dtos.py | 5 + .../sessions/interactions_dispatcher.py | 15 +- .../sessions/test_interactions_dispatcher.py | 192 +++++++++++++++++- 3 files changed, 210 insertions(+), 2 deletions(-) diff --git a/api/oss/src/core/sessions/interactions/dtos.py b/api/oss/src/core/sessions/interactions/dtos.py index 66469d5904..009eb3b4ba 100644 --- a/api/oss/src/core/sessions/interactions/dtos.py +++ b/api/oss/src/core/sessions/interactions/dtos.py @@ -43,6 +43,11 @@ class SessionInteractionData(BaseModel): references: Optional[Dict[str, Reference]] = None selector: Optional[Selector] = None resolution: Optional[Dict[str, Any]] = None + # The effective config the gated turn was running, stamped by the runner. Replaying it as + # the resume's `data.parameters` suppresses reference hydration and reproduces the turn + # (tool permissions included) instead of running the referenced variant's HEAD revision. + # Absent on rows written before this field existed; those resume via `references` alone. + parameters: Optional[Dict[str, Any]] = None class SessionInteractionFlags(BaseModel): diff --git a/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py b/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py index 96c171680c..7706108496 100644 --- a/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py +++ b/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py @@ -11,6 +11,12 @@ Every other interaction kind keeps the original passthrough contract (``data.inputs = answer``). + +The resume also carries the gated turn's own config when the runner stamped one on the row +(``data.parameters``): sending it inline suppresses reference hydration in the SDK resolver, +so the run continues under the config the gate was raised against rather than the referenced +variant's HEAD revision. A row written before that field existed has none, and the body is +byte-identical to the references-only one this dispatcher has always sent. """ from typing import Any, Callable, Dict, List, Optional @@ -356,11 +362,18 @@ async def respond( interaction=interaction, answer=answer, ) + # The effective config the gated turn ran under, when the runner stamped one. Sending it + # INLINE is what makes the resume correct: the resolver decides hydration purely from + # what the caller sent (`_caller_supplied_configuration`), so inline parameters suppress + # it and the run continues under the gated turn's own config instead of the referenced + # variant's HEAD revision. References still ride along (attribution + the fallback for a + # pre-change row, which has no parameters and keeps today's hydrating body verbatim). + parameters = data.parameters if data else None invoke_request = WorkflowServiceRequest( references=references, selector=selector, - data=WorkflowServiceRequestData(inputs=inputs), + data=WorkflowServiceRequestData(inputs=inputs, parameters=parameters), session_id=interaction.session_id, ) diff --git a/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py b/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py index ccdb519691..5cd89e69b4 100644 --- a/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py +++ b/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py @@ -21,6 +21,7 @@ def _make_interaction( with_refs=True, kind=SessionInteractionKind.user_input, request=None, + parameters=None, ): from oss.src.core.sessions.interactions.dtos import ( SessionInteraction, @@ -37,7 +38,12 @@ def _make_interaction( token="tok-abc", kind=kind, status=SessionInteractionStatus.pending, - data=SessionInteractionData(references=refs, selector=None, request=request), + data=SessionInteractionData( + references=refs, + selector=None, + request=request, + parameters=parameters, + ), ) @@ -404,6 +410,190 @@ async def test_approval_answer_without_a_boolean_verdict_passes_through(): assert dispatch_fn.await_args.kwargs["request"].data.inputs == {"approved": "yep"} +# --------------------------------------------------------------------------- +# The effective turn config on the row is replayed inline (effective-turn-config plan, T5-T7) +# --------------------------------------------------------------------------- + +_EFFECTIVE_PARAMETERS = { + "agent": { + "instructions": "Draft config, committed nowhere.", + "llm": {"model": "anthropic/claude-sonnet-4-5"}, + "tools": [{"name": "Bash"}], + "runner": {"permissions": {"default": "allow_reads"}}, + } +} + + +def test_interaction_data_declares_parameters(): + """The DTO must DECLARE the field or it is dropped twice over. + + ``SessionInteractionData`` is a closed pydantic model with the default + ``extra="ignore"``, and the postgres mapping round-trips through it on write + (``model_dump``) and on read (``model_validate``) even though ``data`` is a schemaless + ``json`` column. An undeclared key written by the runner would vanish on ingest and again + on read-back, with no error anywhere. + """ + from oss.src.core.sessions.interactions.dtos import SessionInteractionData + + raw = { + "request": {"tool": "Bash", "args": {"command": "echo hi"}}, + "parameters": _EFFECTIVE_PARAMETERS, + } + parsed = SessionInteractionData.model_validate(raw) + assert parsed.parameters == _EFFECTIVE_PARAMETERS + + dumped = parsed.model_dump(mode="json", exclude_none=True) + assert dumped["parameters"] == _EFFECTIVE_PARAMETERS + assert SessionInteractionData.model_validate(dumped).parameters == ( + _EFFECTIVE_PARAMETERS + ) + + +def test_postgres_mapping_round_trips_the_stamped_config(): + """create -> row -> read-back through the REAL postgres mappings (both are pure). + + This is the ingest path the runner actually hits: the create mapping dumps the DTO into + the ``json`` column and the read mapping validates it back. Either direction silently + drops an undeclared key, which is what makes this the guard for the ``extra="ignore"`` + trap rather than the DTO test above. + """ + from oss.src.core.sessions.interactions.dtos import ( + SessionInteractionCreate, + SessionInteractionData, + SessionInteractionKind as Kind, + ) + from oss.src.dbs.postgres.sessions.interactions.mappings import ( + map_interaction_dbe_to_dto, + map_interaction_dto_to_dbe_create, + ) + + project_id = uuid4() + dbe = map_interaction_dto_to_dbe_create( + project_id=project_id, + user_id=uuid4(), + interaction=SessionInteractionCreate( + project_id=project_id, + session_id="sess-test-1", + turn_id="turn-1", + token="tok-abc", + kind=Kind.user_approval, + data=SessionInteractionData( + request={"tool": "Bash", "args": {"command": "echo hi"}}, + parameters=_EFFECTIVE_PARAMETERS, + ), + ), + ) + assert dbe.data["parameters"] == _EFFECTIVE_PARAMETERS + + dbe.id = uuid4() + dbe.created_at = dbe.updated_at = None + dbe.deleted_at = dbe.updated_by_id = dbe.deleted_by_id = None + read_back = map_interaction_dbe_to_dto(dbe) + assert read_back.data.parameters == _EFFECTIVE_PARAMETERS + # `request` reads back as the typed SessionInteractionRequest, not a bare dict. + assert read_back.data.request.tool == "Bash" + assert read_back.data.request.args == {"command": "echo hi"} + + +def test_interaction_data_omits_parameters_when_unstamped(): + # A legacy row (and any turn whose config was too large or unsafe to stamp) must serialize + # to exactly the shape it had before this field existed. + from oss.src.core.sessions.interactions.dtos import SessionInteractionData + + dumped = SessionInteractionData.model_validate( + {"request": {"tool": "Bash", "args": None}} + ).model_dump(mode="json", exclude_none=True) + assert "parameters" not in dumped + + +async def test_respond_sends_the_stamped_config_inline_with_references(): + """Parameters present -> inline on the invoke, references still sent. + + Inline parameters are exactly what suppresses hydration in the SDK resolver + (``_caller_supplied_configuration``), so the resumed run continues under the gated turn's + own config instead of the referenced variant's HEAD revision. + """ + project_id = uuid4() + interaction = _make_interaction( + kind=SessionInteractionKind.user_approval, + parameters=_EFFECTIVE_PARAMETERS, + ) + dispatch_fn = AsyncMock() + dispatcher = _dispatcher_with( + interaction, _approval_records(project_id), dispatch_fn + ) + + await dispatcher.respond( + project_id=project_id, + user_id=uuid4(), + interaction_id=interaction.id, + answer={"approved": True}, + ) + + request = dispatch_fn.await_args.kwargs["request"] + assert request.data.parameters == _EFFECTIVE_PARAMETERS + # References still ride along: attribution, plus the hydration fallback if the inline + # config is ever dropped upstream. + assert request.references["workflow"].slug == "wf-1" + assert request.session_id == "sess-test-1" + # The composed resume conversation is untouched by the config replay. + assert request.data.inputs["messages"][0] == { + "role": "user", + "content": "run the migration", + } + + +async def test_respond_on_a_pre_change_row_stays_references_only(): + # Backward compatibility: a row written before the runner stamped configs must produce the + # byte-identical body this dispatcher has always sent, so it hydrates as it does today. + project_id = uuid4() + interaction = _make_interaction(kind=SessionInteractionKind.user_approval) + dispatch_fn = AsyncMock() + dispatcher = _dispatcher_with( + interaction, _approval_records(project_id), dispatch_fn + ) + + await dispatcher.respond( + project_id=project_id, + user_id=uuid4(), + interaction_id=interaction.id, + answer={"approved": True}, + ) + + request = dispatch_fn.await_args.kwargs["request"] + assert request.data.parameters is None + assert request.data.model_dump(exclude_none=True).keys() == {"inputs"} + assert request.references["workflow"].slug == "wf-1" + + +async def test_respond_passes_the_stamped_config_on_the_blocking_path_too(): + # The non-detached path (no dispatch_fn) builds the same request object. + interaction = _make_interaction( + kind=SessionInteractionKind.user_approval, + parameters=_EFFECTIVE_PARAMETERS, + ) + + interactions_service = MagicMock() + interactions_service.fetch_interaction = AsyncMock(return_value=interaction) + workflows_service = MagicMock() + workflows_service.invoke_workflow = AsyncMock(return_value=SimpleNamespace()) + + dispatcher = InteractionsDispatcher( + workflows_service=workflows_service, + interactions_service=interactions_service, + ) + + await dispatcher.respond( + project_id=uuid4(), + user_id=uuid4(), + interaction_id=interaction.id, + answer={"approved": True}, + ) + + request = workflows_service.invoke_workflow.await_args.kwargs["request"] + assert request.data.parameters == _EFFECTIVE_PARAMETERS + + async def test_the_stored_call_id_anchors_the_envelope_when_records_are_missing(): """Warm-resume matching is strict on `toolCallId`. With no records to replay, falling straight through to the token misses the parked gate and degrades an answerable turn to a From b331fb00b2fca6e1458e4d8b0ac0b886b3a2ece2 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 29 Jul 2026 14:51:59 +0300 Subject: [PATCH 6/8] docs(mobile): the interaction row now carries the gated turn's config Records what landed on the backend lanes of the effective-turn-config plan and notes in the approvals round-trip that a pre-change row (or one over the stamp cap) is still answerable, degrading to reference hydration. --- .../plans/2026-07-27-mobile-approvals-steering.md | 14 ++++++++++++-- .../plans/2026-07-29-effective-turn-config.md | 12 +++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/docs/design/agenta-mobile/plans/2026-07-27-mobile-approvals-steering.md b/docs/design/agenta-mobile/plans/2026-07-27-mobile-approvals-steering.md index 06269824d8..e92ab92323 100644 --- a/docs/design/agenta-mobile/plans/2026-07-27-mobile-approvals-steering.md +++ b/docs/design/agenta-mobile/plans/2026-07-27-mobile-approvals-steering.md @@ -46,12 +46,22 @@ watchdog heartbeats `POST /sessions/streams/heartbeat` every 30s (`sessions/aliv (`services/runner/src/engines/sandbox_agent/acp-interactions.ts:166-200`) emits stream event `{type:"interaction_request", kind:"user_approval", payload:{toolCallId, toolCall, availableReplies, options}}`, creates a durable **interactions row** (kind `user_approval`, - status `pending`, `data.request={tool,args}` + stored workflow `references` — - `services/runner/src/sessions/interactions.ts:55-93` → `POST /sessions/interactions/`), and + status `pending`, `data.request={tool,args}` + stored workflow `references` + the turn's + **effective config** `data.parameters` — + `services/runner/src/sessions/interactions.ts` `buildInteractionData` → + `POST /sessions/interactions/`), and the turn ends `stopReason:"paused"`. The sandbox **parks warm** in the in-process `SessionPool` (`awaiting_approval`, TTL `approvalTtlMs` = **5 min**, `session-identity.ts:31,34`; `server.ts:427-455`). After TTL: sandbox evicted, the pending row stays actionable for **7 days** (`interactions/dao.py:31`, 209-214). + > **`data.parameters` (effective-turn-config plan, 2026-07-29).** The row now carries the + > post-hydration config the gated turn was RUNNING, stamped by the SDK onto the `/run` wire + > as `effectiveParameters` and echoed here opaquely by the runner. An out-of-band answer + > replays it as the resume's `data.parameters`, which suppresses reference hydration and + > reproduces the turn — without it a references-only resume runs the referenced variant's + > HEAD revision, which for a dirty run means the wrong model, instructions and **tool + > permissions**. A row written before this landed (or one whose config was over the 64 KB + > stamp cap) has no `parameters` and is still answerable — it just degrades to hydration. 2. **Durable visibility (twice over):** the `interaction_request` event is a session record (replayable), and the interactions row is queryable via `POST /sessions/interactions/query` `{query:{session_id?, actionable_only:true}}` — `session_id` is OPTIONAL diff --git a/docs/design/agenta-mobile/plans/2026-07-29-effective-turn-config.md b/docs/design/agenta-mobile/plans/2026-07-29-effective-turn-config.md index 85e9da6c0f..c3cfe162c1 100644 --- a/docs/design/agenta-mobile/plans/2026-07-29-effective-turn-config.md +++ b/docs/design/agenta-mobile/plans/2026-07-29-effective-turn-config.md @@ -1,6 +1,16 @@ # Effective turn config on HITL resume — design & plan -**Status:** PLANNED · **Date:** 2026-07-29 · **Branch:** `feat/agenta-mobile-wave-1` +**Status:** BACKEND LANDED (T1–T8, T13) · client lanes T9–T12 open · +**Date:** 2026-07-29 · **Branch:** `feat/agenta-mobile-wave-1` + +> **Landed:** the SDK stamps `effectiveParameters` on the `/run` wire (session runs only, +> redacted + 64 KB-capped — `sdks/python/agenta/sdk/agents/utils/effective_config.py`); the +> runner echoes it into the interaction row's `data.parameters` +> (`buildInteractionData`, `services/runner/src/sessions/interactions.ts`) and it is +> deliberately excluded from `configFingerprint`; the API declares `parameters` on +> `SessionInteractionData` and the M2 dispatcher sends it inline. **The runner half needs a +> runner restart to go live** (no TypeScript hot-reload); the SDK and API halves reload in +> place and were verified against the live EE dev stack. **Goal:** when an approval is answered from a client that cannot reproduce the turn's config (mobile, the M2 detached dispatcher), the resumed run must continue under **the config the gated turn was actually running**, not under whatever the referenced variant's HEAD revision From bbcf8da288e11a66c7f9cbe0be3bd43d477b3e8f Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 29 Jul 2026 15:29:43 +0300 Subject: [PATCH 7/8] fix(mobile): replay the gated turn's effective config on approval resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lite resume answered a HITL gate with a references-only body, so the SDK hydrated the referenced variant's HEAD revision. For a dirty run that is a different model and — the security-relevant half — a different tool-permission map than the gate was approved under. The runner now stamps the turn's post-hydration config onto the interaction row as data.parameters. Read it off the same row the references come from and send it inline, which suppresses hydration and reproduces the turn exactly. Emit the key ONLY when the stamped config is a non-empty object: an empty {} also suppresses hydration and would run an unconfigured agent. Rows without it (legacy, over-cap, pre-stamping runner) keep today's references-only path. Declare parameters on the interaction zod schema too — objects strip unknown keys by default, so an undeclared field would be silently dropped and the fix would no-op with everything green. --- .../src/features/chat/useApprovalActions.ts | 22 +++++-- .../src/transport/agentResumeRequest.ts | 41 +++++++++--- .../unit/transport/agentResumeRequest.test.ts | 40 +++++++++-- .../src/session/core/schema.ts | 5 ++ .../unit/session-interaction-schema.test.ts | 66 +++++++++++++++++++ 5 files changed, 155 insertions(+), 19 deletions(-) create mode 100644 web/packages/agenta-entities/tests/unit/session-interaction-schema.test.ts diff --git a/web/mobile/src/features/chat/useApprovalActions.ts b/web/mobile/src/features/chat/useApprovalActions.ts index 8296d58905..804ad0ba13 100644 --- a/web/mobile/src/features/chat/useApprovalActions.ts +++ b/web/mobile/src/features/chat/useApprovalActions.ts @@ -42,9 +42,16 @@ const sanitizeReferences = ( return Object.keys(out).length > 0 ? out : null } +/** The gated turn's stamped effective config, or null when the row predates stamping. */ +const sanitizeParameters = (raw: unknown): Record | null => { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null + const params = raw as Record + return Object.keys(params).length > 0 ? params : null +} + /** * Approve/deny pending HITL gates from the phone — the lite resume path (plan M1.3): - * fresh records → stamp `approval-responded` on the tail → ONE references-only invoke POST + * fresh records → stamp `approval-responded` on the tail → ONE resume invoke POST * (`buildAgentResumeRequest`), fire-and-forget. No live SSE consumption: the response is * drained in the background and the tightened records poll repaints the transcript until the * turn settles (`phase` drops back to idle once no gate is pending). @@ -96,8 +103,8 @@ export const useApprovalActions = ({ if (stamped === messages) { throw new Error("This approval is no longer pending — refresh and retry.") } - // The interaction row stores the run's role-keyed workflow references — - // the resolver hydrates config from them server-side (references-only body). + // The interaction row stores the run's role-keyed workflow references and, + // when the runner stamped it, the turn's effective config. const interactions = await queryInteractions({ sessionId, projectId, @@ -112,7 +119,13 @@ export const useApprovalActions = ({ const matched = answeredId ? withRefs.find((row) => row.token === answeredId) : undefined - const references = sanitizeReferences((matched ?? withRefs[0])?.data?.references) + const row = matched ?? withRefs[0] + const references = sanitizeReferences(row?.data?.references) + // Replay the turn's own config when the runner stamped it — references alone + // hydrate the variant's HEAD, which is a different model and, worse, a + // different tool-permission map than the gate was approved under. Rows without + // it (legacy, over-cap, pre-restart runner) fall back to hydration silently. + const parameters = sanitizeParameters(row?.data?.parameters) if (!references) { throw new Error( "This approval carries no workflow reference — answer on desktop.", @@ -132,6 +145,7 @@ export const useApprovalActions = ({ references, sessionId, messages: stamped, + parameters, projectId, applicationId: references.application?.id ?? undefined, }) diff --git a/web/packages/agenta-chat/src/transport/agentResumeRequest.ts b/web/packages/agenta-chat/src/transport/agentResumeRequest.ts index 722212c3d3..07ed7b8435 100644 --- a/web/packages/agenta-chat/src/transport/agentResumeRequest.ts +++ b/web/packages/agenta-chat/src/transport/agentResumeRequest.ts @@ -1,13 +1,19 @@ /** - * Lite agent resume request — the references-only invoke body for answering a HITL approval - * without the hydrated workflow molecule (mobile, or any client that can't run the full + * Lite agent resume request — the invoke body for answering a HITL approval without the + * hydrated workflow molecule (mobile, or any client that can't run the full * `buildAgentRequest` pipeline). * - * Load-bearing invariant: the body carries NO `data.parameters`. The SDK resolver hydrates the - * config server-side ONLY when the request has `references` and no `data.parameters` - * (`sdks/python/agenta/sdk/middlewares/running/resolver.py` `needs_reference_hydration`), so - * emitting a `parameters` key — even empty — would skip hydration and run an unconfigured - * draft. The unit test pins this. + * Load-bearing invariant: the body carries NO `parameters` key unless we are deliberately + * replaying the gated turn's stamped effective config (the interaction row's + * `data.parameters`, written by the runner from the SDK's `effectiveParameters`). The SDK + * resolver hydrates config server-side ONLY when the request has `references` and no + * `data.parameters` (`sdks/python/agenta/sdk/middlewares/running/resolver.py` + * `needs_reference_hydration`), so: + * - non-empty stamped parameters -> emit them; hydration is skipped and the resume runs + * under the exact config the gated turn ran under (including its tool permissions); + * - absent / empty parameters -> emit NO key at all; an empty `{}` would suppress + * hydration and run an unconfigured agent, which is worse than the wrong revision. + * The unit tests pin both directions. */ /** A `{id, slug, version}` platform reference (values may be partial). */ @@ -26,6 +32,9 @@ export interface AgentResumeRequestArgs { sessionId: string /** The full v6 UIMessage history with the approval decision stamped on the tail. */ messages: unknown[] + /** The gated turn's stamped effective config (interaction row `data.parameters`). Sent + * inline to replay that exact config; omit/empty falls back to reference hydration. */ + parameters?: Record | null /** ALWAYS rides the query string — the invoke routing middleware reads it for cookie-auth * permission checks (auth.py). Do not copy desktop's Authorization-gated omission. */ projectId?: string @@ -38,10 +47,19 @@ export interface AgentResumeRequest { requestBody: { session_id: string references: Record | null - data: {inputs: {messages: unknown[]}} + data: {inputs: {messages: unknown[]}; parameters?: Record} } } +/** A stamped config is replayable only if it is a plain object with at least one key. */ +const hasStampedConfig = ( + parameters: Record | null | undefined, +): parameters is Record => + !!parameters && + typeof parameters === "object" && + !Array.isArray(parameters) && + Object.keys(parameters).length > 0 + const withQuery = (url: string, params: Record): string => { const qs = new URLSearchParams() for (const [key, value] of Object.entries(params)) { @@ -51,12 +69,13 @@ const withQuery = (url: string, params: Record): str return suffix ? `${url}${url.includes("?") ? "&" : "?"}${suffix}` : url } -/** Compose the references-only resume invoke request (see module docstring). */ +/** Compose the resume invoke request (see module docstring). */ export const buildAgentResumeRequest = ({ invocationUrl, references, sessionId, messages, + parameters, projectId, applicationId, }: AgentResumeRequestArgs): AgentResumeRequest => ({ @@ -73,6 +92,8 @@ export const buildAgentResumeRequest = ({ requestBody: { session_id: sessionId, references, - data: {inputs: {messages}}, + // Spread, never assign: an explicit `parameters: undefined` still creates the key, + // and `JSON.stringify` dropping it is not enough for a structural caller. + data: {inputs: {messages}, ...(hasStampedConfig(parameters) ? {parameters} : {})}, }, }) diff --git a/web/packages/agenta-chat/tests/unit/transport/agentResumeRequest.test.ts b/web/packages/agenta-chat/tests/unit/transport/agentResumeRequest.test.ts index ab7134a19e..37bbfe19a2 100644 --- a/web/packages/agenta-chat/tests/unit/transport/agentResumeRequest.test.ts +++ b/web/packages/agenta-chat/tests/unit/transport/agentResumeRequest.test.ts @@ -10,11 +10,41 @@ const baseArgs = { } describe("buildAgentResumeRequest", () => { - it("never emits a data.parameters key (references-only server-side hydration)", () => { - const req = buildAgentResumeRequest(baseArgs) - expect("parameters" in req.requestBody.data).toBe(false) - // Belt-and-braces: the serialized wire body must not carry the key either. - expect(JSON.stringify(req.requestBody)).not.toContain('"parameters"') + // The hydration switch: a `parameters` key — even empty — makes the SDK resolver skip + // reference hydration, so it must appear ONLY when we have a real config to replay. + describe("data.parameters (hydration switch)", () => { + it("omits the key entirely when no parameters are supplied", () => { + const req = buildAgentResumeRequest(baseArgs) + expect("parameters" in req.requestBody.data).toBe(false) + // Belt-and-braces: the serialized wire body must not carry the key either. + expect(JSON.stringify(req.requestBody)).not.toContain('"parameters"') + }) + + it.each([ + ["undefined", undefined], + ["null", null], + ["an empty object", {}], + ])( + "omits the key when parameters is %s (an empty {} would run unconfigured)", + (_label, parameters) => { + const req = buildAgentResumeRequest({...baseArgs, parameters}) + expect("parameters" in req.requestBody.data).toBe(false) + expect(JSON.stringify(req.requestBody)).not.toContain('"parameters"') + }, + ) + + it("emits the stamped effective config verbatim when it is non-empty", () => { + const parameters = {agent: {llm: {model: "anthropic/claude-sonnet-4-5"}}} + const req = buildAgentResumeRequest({...baseArgs, parameters}) + expect("parameters" in req.requestBody.data).toBe(true) + expect(req.requestBody.data.parameters).toBe(parameters) + }) + + it("still sends references alongside inline parameters", () => { + const req = buildAgentResumeRequest({...baseArgs, parameters: {agent: {}}}) + expect(req.requestBody.references).toEqual({workflow_revision: {id: "rev-1"}}) + expect(req.requestBody.data.inputs.messages).toBe(baseArgs.messages) + }) }) it("carries the session id, references, and messages under data.inputs", () => { diff --git a/web/packages/agenta-entities/src/session/core/schema.ts b/web/packages/agenta-entities/src/session/core/schema.ts index cc2a0c0c2f..c85439bc3f 100644 --- a/web/packages/agenta-entities/src/session/core/schema.ts +++ b/web/packages/agenta-entities/src/session/core/schema.ts @@ -60,6 +60,11 @@ export const sessionInteractionSchema = z.object({ .object({ request: z.record(z.string(), z.unknown()).nullish(), references: z.record(z.string(), z.unknown()).nullish(), + // The gated turn's stamped effective config; must be declared or zod's default + // strip-unknown-keys would silently drop it and the resume falls back to + // reference hydration (i.e. the wrong config). Rows written before the runner + // started stamping simply have no key. + parameters: z.record(z.string(), z.unknown()).nullish(), selector: z.record(z.string(), z.unknown()).nullish(), resolution: z.record(z.string(), z.unknown()).nullish(), }) diff --git a/web/packages/agenta-entities/tests/unit/session-interaction-schema.test.ts b/web/packages/agenta-entities/tests/unit/session-interaction-schema.test.ts new file mode 100644 index 0000000000..12ed7abbc2 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/session-interaction-schema.test.ts @@ -0,0 +1,66 @@ +/** + * Pins the interaction row's `data.parameters` — the gated turn's stamped effective config. + * zod objects strip unknown keys by default, so an undeclared field validates fine and + * arrives as `undefined`: the resume would silently fall back to reference hydration and run + * the committed config (wrong model, wrong tool permissions) with tsc and tests all green. + * These assert the field survives parsing, and that legacy rows without it still parse. + */ +import {describe, expect, it} from "vitest" + +import { + sessionInteractionSchema, + sessionInteractionsResponseSchema, +} from "../../src/session/core/schema" + +const effectiveParameters = { + agent: { + llm: {model: "anthropic/claude-sonnet-4-5", provider: "anthropic"}, + runner: {kind: "sidecar", permissions: {default: "allow_reads"}}, + }, +} + +const wireInteraction = { + id: "int-1", + session_id: "sess-1", + turn_id: "turn-1", + token: "tok-1", + kind: "user_approval", + status: "pending", + created_at: "2026-07-29T00:00:00Z", + data: { + request: {tool: "Bash", args: {command: "echo hi"}}, + references: {workflow: {id: "wf-1", slug: "agent"}}, + parameters: effectiveParameters, + }, +} + +describe("sessionInteractionSchema", () => { + it("keeps data.parameters (the stamped effective config) verbatim", () => { + const out = sessionInteractionSchema.parse(wireInteraction) + expect(out.data?.parameters).toEqual(effectiveParameters) + }) + + it("keeps request and references alongside parameters", () => { + const out = sessionInteractionSchema.parse(wireInteraction) + expect(out.data?.references).toEqual({workflow: {id: "wf-1", slug: "agent"}}) + expect(out.data?.request).toEqual({tool: "Bash", args: {command: "echo hi"}}) + }) + + it("parses a legacy row that carries no parameters (pre-stamping runner)", () => { + const legacy = { + ...wireInteraction, + data: {references: {workflow: {id: "wf-1"}}}, + } + const out = sessionInteractionSchema.parse(legacy) + expect(out.data?.parameters).toBeUndefined() + expect(out.data?.references).toEqual({workflow: {id: "wf-1"}}) + }) + + it("carries parameters through the query response envelope", () => { + const out = sessionInteractionsResponseSchema.parse({ + count: 1, + interactions: [wireInteraction], + }) + expect(out.interactions?.[0].data?.parameters).toEqual(effectiveParameters) + }) +}) From 3b92ff27369505ee1ce3e557d91dd12ceef6b1cf Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 29 Jul 2026 22:20:33 +0300 Subject: [PATCH 8/8] fix(mobile): answer approvals through the detached respond dispatcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phone built its own /invoke resume from a stamped records replay. That lands as a NEW turn (the keepalive trips approval-mismatch (history) -> evict + cold), so the parked gate is never matched and the interaction row stays pending — the desktop keeps showing "Approval needed to continue" even after the tool ran. Call POST /sessions/interactions/{id}/respond instead: the backend CAS-flips the row to responded and the interactions worker rebuilds the history server-side and replays the gate's stamped effective config, so the resume lands warm. Approve-all fans out one respond per pending gate; a 409 (already answered) settles to idle instead of erroring. respondInteraction now throws instead of swallowing the failure — a mutation's caller has to tell a real failure from an already-answered gate (isInteractionConflict). Drops the mobile-only invoke plumbing: approvalStamp.ts and the invoke bearer header. --- web/mobile/src/features/chat/approvalStamp.ts | 38 ---- .../src/features/chat/approvalTargets.ts | 20 +++ .../src/features/chat/useApprovalActions.ts | 166 +++++------------- web/mobile/src/lib/auth.ts | 19 -- web/mobile/tests/unit/approvalStamp.test.ts | 63 ------- web/mobile/tests/unit/approvalTargets.test.ts | 46 +++++ .../agenta-entities/src/session/api/api.ts | 28 +-- .../agenta-entities/src/session/index.ts | 1 + 8 files changed, 129 insertions(+), 252 deletions(-) delete mode 100644 web/mobile/src/features/chat/approvalStamp.ts create mode 100644 web/mobile/src/features/chat/approvalTargets.ts delete mode 100644 web/mobile/tests/unit/approvalStamp.test.ts create mode 100644 web/mobile/tests/unit/approvalTargets.test.ts diff --git a/web/mobile/src/features/chat/approvalStamp.ts b/web/mobile/src/features/chat/approvalStamp.ts deleted file mode 100644 index 1e560c67e6..0000000000 --- a/web/mobile/src/features/chat/approvalStamp.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type {UIMessage} from "ai" - -/** - * Stamp approval decisions onto the transcript tail — the exact shape - * `transcriptToMessages` produces for a replayed `interaction_response` - * (`state: "approval-responded"`, `approval: {id, approved}`), which the SDK's vercel - * adapter folds into the `{approved, interactionToken}` tool_result envelope the runner's - * decision map reads. Returns the SAME array when nothing matched (caller treats that as - * "gate already gone"). - */ -export const stampApprovalResponses = ( - messages: UIMessage[], - approvalIds: readonly string[], - approved: boolean, -): UIMessage[] => { - if (messages.length === 0) return messages - const tailIndex = messages.length - 1 - const tail = messages[tailIndex] - if (tail.role !== "assistant") return messages - const targets = new Set(approvalIds) - let touched = false - const parts = (tail.parts ?? []).map((part) => { - const p = part as {state?: string; approval?: {id?: string}} - if (p.state === "approval-requested" && p.approval?.id && targets.has(p.approval.id)) { - touched = true - return { - ...part, - state: "approval-responded", - approval: {id: p.approval.id, approved}, - } as typeof part - } - return part - }) - if (!touched) return messages - const next = messages.slice() - next[tailIndex] = {...tail, parts} - return next -} diff --git a/web/mobile/src/features/chat/approvalTargets.ts b/web/mobile/src/features/chat/approvalTargets.ts new file mode 100644 index 0000000000..7568d6d040 --- /dev/null +++ b/web/mobile/src/features/chat/approvalTargets.ts @@ -0,0 +1,20 @@ +import type {SessionInteraction} from "@agenta/entities/session" + +/** Which pending gates a tap answers: one gate (by transcript approval id) or every gate. */ +export type ApprovalTarget = {all: true} | {all?: false; approvalId: string} + +/** + * Pick the interaction rows to respond to. + * + * The transcript's approval id is the row's `token` (both come from the runner's + * `interaction_request` event id), but `/sessions/interactions/{id}/respond` keys on the + * row's `id` — so a row without an `id` is unanswerable and is dropped. + */ +export const selectApprovalTargets = ( + rows: SessionInteraction[] | null | undefined, + target: ApprovalTarget, +): SessionInteraction[] => { + const pending = (rows ?? []).filter((row) => row.kind === "user_approval" && !!row.id) + if (target.all) return pending + return pending.filter((row) => row.token === target.approvalId) +} diff --git a/web/mobile/src/features/chat/useApprovalActions.ts b/web/mobile/src/features/chat/useApprovalActions.ts index 804ad0ba13..64a19c162d 100644 --- a/web/mobile/src/features/chat/useApprovalActions.ts +++ b/web/mobile/src/features/chat/useApprovalActions.ts @@ -1,60 +1,40 @@ import {useCallback, useEffect, useRef, useState} from "react" -import {loadSessionMessages} from "@agenta/chat/assets" -import {getPendingApprovals} from "@agenta/chat/model" import { - buildAgentResumeRequest, - resolveInvocationUrl, - type AgentResumeReference, -} from "@agenta/chat/transport" -import {queryInteractions} from "@agenta/entities/session" + isInteractionConflict, + queryInteractions, + respondInteraction, +} from "@agenta/entities/session" -import {getAuthorizationHeader} from "@/lib/auth" - -import {stampApprovalResponses} from "./approvalStamp" +import {selectApprovalTargets, type ApprovalTarget} from "./approvalTargets" export type ResumePhase = "idle" | "resuming" | "error" +/** Fern's `AgentaApiError` message is transport jargon — show the status instead. */ +const respondErrorText = (error: unknown): string => { + const status = (error as {statusCode?: number} | null)?.statusCode + return status ? `Approval failed (HTTP ${status}).` : "Approval failed." +} + export interface ApprovalActions { phase: ResumePhase errorText: string | null /** Answer one gate. Deny also resumes (the runner needs the denial round-trip). */ respond: (args: {approvalId: string; approved: boolean}) => void - /** Approve every pending gate — all responses ride ONE resume POST. */ + /** Approve every pending gate — one respond call per gate (the endpoint is per-interaction). */ approveAll: () => void } -/** Keep only `{id, slug, version}` string fields of the interaction row's role-keyed refs. */ -const sanitizeReferences = ( - raw: Record | null | undefined, -): Record | null => { - if (!raw) return null - const out: Record = {} - for (const [key, value] of Object.entries(raw)) { - if (!value || typeof value !== "object") continue - const {id, slug, version} = value as Record - const ref: AgentResumeReference = {} - if (typeof id === "string") ref.id = id - if (typeof slug === "string") ref.slug = slug - if (typeof version === "string") ref.version = version - if (Object.keys(ref).length > 0) out[key] = ref - } - return Object.keys(out).length > 0 ? out : null -} - -/** The gated turn's stamped effective config, or null when the row predates stamping. */ -const sanitizeParameters = (raw: unknown): Record | null => { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null - const params = raw as Record - return Object.keys(params).length > 0 ? params : null -} - /** - * Approve/deny pending HITL gates from the phone — the lite resume path (plan M1.3): - * fresh records → stamp `approval-responded` on the tail → ONE resume invoke POST - * (`buildAgentResumeRequest`), fire-and-forget. No live SSE consumption: the response is - * drained in the background and the tightened records poll repaints the transcript until the - * turn settles (`phase` drops back to idle once no gate is pending). + * Approve/deny pending HITL gates from the phone via the DETACHED respond dispatcher: + * `POST /sessions/interactions/{id}/respond`. The backend CAS-flips the row to `responded`, + * then the interactions worker rebuilds the turn's history from the durable records and + * replays the gate's stamped effective config, so the parked run resumes WARM. + * + * Never hand-build an `/invoke` resume here: an invoke carrying stamped messages runs as a + * NEW turn (`approval-mismatch (history)` → evict + cold) and leaves the interaction row + * `pending`, so the gate never clears. Fire-and-forget: no stream is consumed, and the + * records poll repaints the transcript until `pendingCount` drops to 0. */ export const useApprovalActions = ({ sessionId, @@ -77,7 +57,7 @@ export const useApprovalActions = ({ } }, [pendingCount]) - // Failure-path re-arm: if the resume was accepted but the run dies before the gate + // Failure-path re-arm: if the respond was accepted but the run dies before the gate // resolves, the poll never settles us — drop back to idle so the buttons re-arm. useEffect(() => { if (phase !== "resuming") return @@ -86,99 +66,43 @@ export const useApprovalActions = ({ }, [phase]) const submit = useCallback( - async (target: {all: true} | {all?: false; approvalId: string}, approved: boolean) => { + async (target: ApprovalTarget, approved: boolean) => { if (busyRef.current) return busyRef.current = true setPhase("resuming") setErrorText(null) try { - // Never stamp a stale tail — re-read the durable records first. - const messages = (await loadSessionMessages(sessionId)) ?? [] - const pending = getPendingApprovals(messages) - if (pending.length === 0) { - throw new Error("No pending approval found — the turn may have moved on.") - } - const ids = target.all ? pending.map((p) => p.approvalId) : [target.approvalId] - const stamped = stampApprovalResponses(messages, ids, approved) - if (stamped === messages) { - throw new Error("This approval is no longer pending — refresh and retry.") - } - // The interaction row stores the run's role-keyed workflow references and, - // when the runner stamped it, the turn's effective config. - const interactions = await queryInteractions({ + // Never answer a stale gate — re-read the actionable rows (pending + in TTL). + const rows = await queryInteractions({ sessionId, projectId, actionableOnly: true, }) - const withRefs = (interactions ?? []).filter( - (row) => row.data?.references && Object.keys(row.data.references).length > 0, - ) - // Bind to the answered gate's own row when possible — two parked runs on - // different revisions in one session must not resume with the wrong config. - const answeredId = target.all ? undefined : target.approvalId - const matched = answeredId - ? withRefs.find((row) => row.token === answeredId) - : undefined - const row = matched ?? withRefs[0] - const references = sanitizeReferences(row?.data?.references) - // Replay the turn's own config when the runner stamped it — references alone - // hydrate the variant's HEAD, which is a different model and, worse, a - // different tool-permission map than the gate was approved under. Rows without - // it (legacy, over-cap, pre-restart runner) fall back to hydration silently. - const parameters = sanitizeParameters(row?.data?.parameters) - if (!references) { + const targets = selectApprovalTargets(rows, target) + if (targets.length === 0) { throw new Error( - "This approval carries no workflow reference — answer on desktop.", + target.all + ? "No pending approval found — the turn may have moved on." + : "This approval is no longer pending — refresh and retry.", ) } - const invocationUrl = await resolveInvocationUrl({ - projectId, - revisionId: - references.workflow_revision?.id ?? references.application_revision?.id, - workflowId: references.workflow?.id ?? references.application?.id, - }) - if (!invocationUrl) { - throw new Error("Could not resolve the agent's invoke URL.") - } - const request = buildAgentResumeRequest({ - invocationUrl, - references, - sessionId, - messages: stamped, - parameters, - projectId, - applicationId: references.application?.id ?? undefined, - }) - const authHeader = await getAuthorizationHeader() - const response = await fetch(request.invocationUrl, { - method: "POST", - headers: { - ...request.headers, - ...authHeader, - "Content-Type": "application/json", - }, - body: JSON.stringify(request.requestBody), - credentials: "include", - }) - if (!response.ok) { - throw new Error(`Resume failed (HTTP ${response.status}).`) - } - // Fire-and-forget, but NEVER cancel: cancelling the body aborts the request, - // and the agent service treats that disconnect as "stop" — the resumed run - // dies ~200ms in and the gate stays pending (observed live). Drain instead. - void (async () => { - const reader = response.body?.getReader() - if (!reader) return + let answered = 0 + for (const row of targets) { try { - for (;;) { - const {done} = await reader.read() - if (done) return - } - } catch { - // Connection dropped (screen locked, network change) — the run - // continues server-side; records polling picks the result up. + await respondInteraction({ + interactionId: row.id as string, + projectId, + answer: {approved}, + }) + answered += 1 + } catch (err) { + // Someone (desktop, another tab) already answered this gate — benign. + if (!isInteractionConflict(err)) throw new Error(respondErrorText(err)) } - })() + } + // Every target was already answered: nothing is resuming, so re-arm now + // instead of waiting out the 60s timeout. + if (answered === 0) setPhase("idle") } catch (err) { setPhase("error") setErrorText(err instanceof Error ? err.message : "Resume failed.") diff --git a/web/mobile/src/lib/auth.ts b/web/mobile/src/lib/auth.ts index 280d571367..8486519c61 100644 --- a/web/mobile/src/lib/auth.ts +++ b/web/mobile/src/lib/auth.ts @@ -87,25 +87,6 @@ export async function signInWithEmailPassword( } } -/** - * `Authorization` for an invoke, mirroring the desktop's `getJWT()` - * (web/oss/src/services/api.ts). The cookie alone authenticates the invoke, but the SDK - * resolves the model connection by fetching the vault with the caller's Authorization - * header ONLY — without it the run proceeds with no injected credential and the model - * rejects it ("no connection resolved for provider …"). - */ -export async function getAuthorizationHeader(): Promise> { - if (typeof window === "undefined") return {} - ensureAuthInit() - try { - if (!(await Session.doesSessionExist())) return {} - const jwt = await Session.getAccessToken() - return jwt ? {Authorization: `Bearer ${jwt}`} : {} - } catch { - return {} - } -} - /** * Attempt a cookie-based session refresh. Resolves false when there is no * refresh token or the backend rejects it — the caller's signed-out verdict diff --git a/web/mobile/tests/unit/approvalStamp.test.ts b/web/mobile/tests/unit/approvalStamp.test.ts deleted file mode 100644 index 10fcf2299f..0000000000 --- a/web/mobile/tests/unit/approvalStamp.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type {UIMessage} from "ai" -import {describe, expect, it} from "vitest" - -import {stampApprovalResponses} from "../../src/features/chat/approvalStamp" - -const gate = (id: string) => ({ - type: "tool-run_command", - toolCallId: `call-${id}`, - state: "approval-requested", - input: {command: "ls"}, - approval: {id}, -}) - -const transcript = (parts: unknown[]): UIMessage[] => - [ - {id: "u1", role: "user", parts: [{type: "text", text: "go"}]}, - {id: "a1", role: "assistant", parts}, - ] as unknown as UIMessage[] - -describe("stampApprovalResponses", () => { - it("stamps the targeted gate with the transcriptToMessages response shape", () => { - const messages = transcript([gate("appr-1")]) - const next = stampApprovalResponses(messages, ["appr-1"], true) - expect(next).not.toBe(messages) - const part = (next[1].parts as Record[])[0] - expect(part.state).toBe("approval-responded") - expect(part.approval).toEqual({id: "appr-1", approved: true}) - // Untouched fields survive — the SDK keys the envelope by toolCallId + input. - expect(part.toolCallId).toBe("call-appr-1") - expect(part.input).toEqual({command: "ls"}) - }) - - it("stamps every listed gate in one pass (approve-all rides ONE resume)", () => { - const messages = transcript([gate("a"), gate("b")]) - const next = stampApprovalResponses(messages, ["a", "b"], true) - const parts = next[1].parts as Record[] - expect(parts.map((p) => p.state)).toEqual(["approval-responded", "approval-responded"]) - }) - - it("records a deny as approved: false (deny also resumes)", () => { - const next = stampApprovalResponses(transcript([gate("a")]), ["a"], false) - expect((next[1].parts as Record[])[0].approval).toEqual({ - id: "a", - approved: false, - }) - }) - - it("returns the same array when the gate is gone or the tail is not an assistant turn", () => { - const noGate = transcript([{type: "text", text: "done"}]) - expect(stampApprovalResponses(noGate, ["a"], true)).toBe(noGate) - const userTail = [ - {id: "u1", role: "user", parts: [{type: "text", text: "hi"}]}, - ] as unknown as UIMessage[] - expect(stampApprovalResponses(userTail, ["a"], true)).toBe(userTail) - expect(stampApprovalResponses([], ["a"], true)).toEqual([]) - }) - - it("does not mutate the input messages", () => { - const messages = transcript([gate("a")]) - stampApprovalResponses(messages, ["a"], true) - expect((messages[1].parts as Record[])[0].state).toBe("approval-requested") - }) -}) diff --git a/web/mobile/tests/unit/approvalTargets.test.ts b/web/mobile/tests/unit/approvalTargets.test.ts new file mode 100644 index 0000000000..d2732b5afb --- /dev/null +++ b/web/mobile/tests/unit/approvalTargets.test.ts @@ -0,0 +1,46 @@ +import {describe, expect, it} from "vitest" + +import {selectApprovalTargets} from "../../src/features/chat/approvalTargets" + +const row = (overrides: Record = {}) => ({ + id: "int-1", + session_id: "sess-1", + token: "appr-1", + kind: "user_approval", + status: "pending", + ...overrides, +}) + +describe("selectApprovalTargets", () => { + it("matches one gate by the transcript approval id (the row token)", () => { + const rows = [row(), row({id: "int-2", token: "appr-2"})] + expect(selectApprovalTargets(rows, {approvalId: "appr-2"}).map((r) => r.id)).toEqual([ + "int-2", + ]) + }) + + it("returns every pending approval for approve-all", () => { + const rows = [row(), row({id: "int-2", token: "appr-2"})] + expect(selectApprovalTargets(rows, {all: true}).map((r) => r.id)).toEqual([ + "int-1", + "int-2", + ]) + }) + + it("drops non-approval kinds", () => { + const rows = [row({id: "int-3", token: "appr-3", kind: "client_tool"})] + expect(selectApprovalTargets(rows, {all: true})).toEqual([]) + expect(selectApprovalTargets(rows, {approvalId: "appr-3"})).toEqual([]) + }) + + it("drops rows with no id — the respond endpoint keys on the id, not the token", () => { + const rows = [row({id: null})] + expect(selectApprovalTargets(rows, {approvalId: "appr-1"})).toEqual([]) + }) + + it("returns nothing for an unknown approval id or empty input", () => { + expect(selectApprovalTargets([row()], {approvalId: "nope"})).toEqual([]) + expect(selectApprovalTargets(null, {all: true})).toEqual([]) + expect(selectApprovalTargets(undefined, {approvalId: "appr-1"})).toEqual([]) + }) +}) diff --git a/web/packages/agenta-entities/src/session/api/api.ts b/web/packages/agenta-entities/src/session/api/api.ts index 9c8df5ffa2..070b3cde7e 100644 --- a/web/packages/agenta-entities/src/session/api/api.ts +++ b/web/packages/agenta-entities/src/session/api/api.ts @@ -169,13 +169,22 @@ export interface RespondInteractionParams extends InteractionScopedParams { answer: Record } +/** True for the backend's `409 Interaction is no longer pending` (someone already answered). + * Fern stashes the HTTP status on the thrown `AgentaApiError` as `statusCode`. */ +export const isInteractionConflict = (error: unknown): boolean => + (error as {statusCode?: number} | null)?.statusCode === 409 + /** - * Resolve a HITL interaction (approve/deny/input). Returns the updated record, or `null`. + * Resolve a HITL interaction (approve/deny/input) — the detached respond dispatcher. + * + * The backend CAS-flips the row to `responded` and enqueues the resume invoke, which rebuilds + * the turn's history from the durable records and replays the gate's stamped effective config. + * A caller must NOT hand-build an `/invoke` resume instead: that lands as a fresh turn and + * leaves the row `pending`. * - * NOTE (2026-06): per JP, decoupled interactions are deferred/"not a priority" — approvals + - * tool-calls currently flow through MESSAGES (the live `addToolApprovalResponse` + - * `tool_approvals` transport path), which stays. This is the durable replacement, ready but - * not yet wired (runner doesn't auto-create rows; respond doesn't transition status). + * Unlike the read wrappers here this THROWS on failure rather than returning `null` — it is a + * mutation, and the caller has to tell a real failure from an already-answered gate + * (`isInteractionConflict`). Identify the row by its `id`, not its `token`. */ export async function respondInteraction({ interactionId, @@ -186,13 +195,10 @@ export async function respondInteraction({ }: RespondInteractionParams): Promise { if (!projectId || !interactionId) return null - const data = await callFern("[respondInteraction]", () => - getSessionsClient().respondInteraction( - {interaction_id: interactionId, answer}, - projectScopedRequest(projectId, appId, abortSignal), - ), + const data = await getSessionsClient().respondInteraction( + {interaction_id: interactionId, answer}, + projectScopedRequest(projectId, appId, abortSignal), ) - if (!data) return null const validated = safeParseWithLogging( sessionInteractionResponseSchema, diff --git a/web/packages/agenta-entities/src/session/index.ts b/web/packages/agenta-entities/src/session/index.ts index 44aad4e212..6e1a71e85b 100644 --- a/web/packages/agenta-entities/src/session/index.ts +++ b/web/packages/agenta-entities/src/session/index.ts @@ -10,6 +10,7 @@ export { queryInteractions, fetchInteraction, respondInteraction, + isInteractionConflict, querySessionStreams, querySessions, setSessionHeader,