From 10b1a84795ffda675195a46fb6fc9286cd02b8a5 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 28 Jun 2026 00:15:03 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(agent):=20direct-call=20tools=20Phase?= =?UTF-8?q?=203a=20=E2=80=94=20run-context=20delivery=20+=20bind?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the per-turn runContext to the /run request and have the runner fill a tool's call.context bindings from it server-side, hidden from the model. This is the run-context delivery mechanism for self-targeting direct-call tools (own trace / own variant); the platform-op catalog that emits call.context is Phase 3b. Wire: runContext on AgentRunRequest (protocol.ts) mirrored in wire.py / wire_models.py, golden run_request.pi_core.json + both wire-contract tests. Inner keys are the snake_case $ctx. binding namespace. Service: app.py fills runContext per turn via run_context() (tracing.py) from the run's own trace + variant identity; threaded run_context through SessionConfig -> Environment/Backend.create_session -> SandboxAgentSession -> request_to_wire. Runner: assembleBody (tools/direct.ts) resolves each $ctx. against runContext and deep-sets it LAST so a bound field wins over the model args and the static body and is model-invisible; missing keys are skipped; deep-set is prototype-pollution-safe. Threaded runContext through relay.ts startToolRelay -> executeRelayedTool. Claude-Session: https://claude.ai/code/session_01GYo3UEfvsZpncagqb28Mbc --- .../cross-service/runner-to-tool-callback.md | 33 ++-- .../cross-service/service-to-agent-runner.md | 17 +- sdks/python/agenta/sdk/agents/__init__.py | 6 + .../agenta/sdk/agents/adapters/local.py | 3 +- .../sdk/agents/adapters/sandbox_agent.py | 6 + sdks/python/agenta/sdk/agents/dtos.py | 73 ++++++++ sdks/python/agenta/sdk/agents/interfaces.py | 3 + sdks/python/agenta/sdk/agents/utils/wire.py | 14 +- sdks/python/agenta/sdk/agents/wire_models.py | 39 ++++ .../oss/tests/pytest/unit/agents/conftest.py | 2 + .../agents/golden/run_request.pi_core.json | 13 ++ .../pytest/unit/agents/test_wire_contract.py | 63 +++++++ services/agent/src/protocol.ts | 36 ++++ services/agent/src/tools/direct.ts | 59 +++++-- services/agent/src/tools/relay.ts | 14 +- services/agent/tests/unit/tool-direct.test.ts | 166 ++++++++++++++++-- .../agent/tests/unit/wire-contract.test.ts | 10 ++ services/oss/src/agent/app.py | 5 +- services/oss/src/agent/tracing.py | 81 ++++++++- .../oss/tests/pytest/unit/agent/conftest.py | 10 +- 20 files changed, 610 insertions(+), 43 deletions(-) diff --git a/docs/design/agent-workflows/interfaces/cross-service/runner-to-tool-callback.md b/docs/design/agent-workflows/interfaces/cross-service/runner-to-tool-callback.md index 6609013d3e..3c99f8708a 100644 --- a/docs/design/agent-workflows/interfaces/cross-service/runner-to-tool-callback.md +++ b/docs/design/agent-workflows/interfaces/cross-service/runner-to-tool-callback.md @@ -67,7 +67,7 @@ The same `POST /tools/call` serves three kinds of callback tool, routed by the ` The runner is unchanged for all three: it relays a `callback` spec with whatever `call_ref` the resolver put on it. Only the router's prefix dispatch is aware of the grammars. -## Direct-call descriptor (`call`, declared not wired) +## Direct-call descriptor (`call`) and run-context binding (`runContext`) A resolved callback spec can carry an optional `call` descriptor instead of a `call_ref` (`ResolvedToolSpec.call` in `protocol.ts`; `CallbackToolSpec.call` in the SDK `tools/models.py`; @@ -77,11 +77,20 @@ with `path` an absolute path from the Agenta origin (derived from `toolCallback. than posting back through `/tools/call`. Shape: `{ method: "GET"|"POST", path, body?, context?, args_into? }`. A spec carries `call` XOR `call_ref`. -**Status (direct-call tools, Phase 1):** plumbing only. The field rides the wire and round-trips -on both sides, but no resolver emits it and no runner dispatch reads it yet, so live behavior is -unchanged (gateway and reference tools still route through `/tools/call`). The body-merge rules -and SSRF guardrails land with the dispatch branch in a later phase. Full spec: -`docs/design/agent-workflows/projects/direct-call-tools/interfaces.md`. +The runner assembles the request body (`tools/direct.ts` `assembleBody`) in three layers, later +wins: the model's args (at `args_into`, else the root) → the static `body` → the `context` +binding. `context` maps a body path to a `"$ctx."` token, which the runner resolves +against the per-turn `runContext` blob on the `/run` request (`service-to-agent-runner.md`) and +deep-sets LAST — so a self-targeting tool's own trace/variant is filled server-side and the model +can never set or override a bound field. A token that does not resolve is skipped (the field stays +unset); deep-set is prototype-pollution-safe. + +**Status (direct-call tools):** Phase 1 added the `call` field (plumbing), Phase 2 added the +runner dispatch branch (host-direct via the relay path, with the SSRF guardrails), and Phase 3a +added the `runContext` wire field + the `call.context` binding in `assembleBody`. Live behavior is +still unchanged because **no resolver emits `call` or `call.context` yet** — gateway and reference +tools still route through `/tools/call`. The platform-op catalog that emits them is Phase 3b. Full +spec: `docs/design/agent-workflows/projects/direct-call-tools/`. ## Owned by @@ -98,10 +107,14 @@ and SSRF guardrails land with the dispatch branch in a later phase. Full spec: the `__`/`.` normalization are a paired contract across runner and router. The router dispatches by prefix: `workflow.` → `_call_workflow_tool`, `tools.agenta.` → `_call_agenta_tool`, else the 5-segment Composio parse. Keep the SDK resolvers and the router parser in agreement. -- **The `call` descriptor (direct path).** A callback spec carries `call` XOR `call_ref`; the - descriptor (`method`/`path`/`body`/`context`/`args_into`) must stay mirrored across - `protocol.ts`, the SDK `CallbackToolSpec`, `wire_models.py`, and the golden fixtures. Phase 1 is - plumbing only — nothing emits or dispatches it yet. +- **The `call` descriptor (direct path) and `runContext` binding.** A callback spec carries + `call` XOR `call_ref`; the descriptor (`method`/`path`/`body`/`context`/`args_into`) must stay + mirrored across `protocol.ts`, the SDK `CallbackToolSpec`, `wire_models.py`, and the golden + fixtures. The `call.context` binding reads the per-turn `runContext` blob (also mirrored across + `protocol.ts` / `wire_models.py` / `wire.py` / the goldens); its inner keys are the snake_case + `$ctx.` namespace, not camelCase. The runner now dispatches `call` and fills `call.context` + (`tools/direct.ts` `assembleBody`), but no resolver EMITS `call`/`context` yet (the platform-op + catalog is Phase 3b), so live behavior is unchanged. - **Tool result content.** `call.data.content` is a JSON string already; do not double-encode it on the way out. - **Argument normalization.** Keep accepting both string and object arguments. diff --git a/docs/design/agent-workflows/interfaces/cross-service/service-to-agent-runner.md b/docs/design/agent-workflows/interfaces/cross-service/service-to-agent-runner.md index e92d4a9407..75cf5f14ae 100644 --- a/docs/design/agent-workflows/interfaces/cross-service/service-to-agent-runner.md +++ b/docs/design/agent-workflows/interfaces/cross-service/service-to-agent-runner.md @@ -64,10 +64,25 @@ group by job: "harnessFiles": [ { "path": ".claude/settings.json", "content": "..." } ], // tracing (see service-and-runner-trace-export.md) - "trace": { "traceparent": "...", "endpoint": "...", "authorization": "...", "captureContent": true } + "trace": { "traceparent": "...", "endpoint": "...", "authorization": "...", "captureContent": true }, + + // run context — the run's own identity, refreshed per turn (direct-call tools, Phase 3a) + "runContext": { // omitted when the run has no own identity to bind + "workflow": { "variant_id": "...", "variant_name": "...", "revision_id": "...", "version": "..." }, + "trace": { "trace_id": "...", "span_id": "..." }, + "session_id": "..." + } } ``` +`runContext` is the run's own context (its trace + variant identity), filled by the service in +`app.py` from `run_context()` (`tracing.py`) and refreshed each turn. It is consumed ONLY by a +tool's `call.context` binding at dispatch: the runner fills the bound request fields from this blob +server-side, hidden from the model (see `runner-to-tool-callback.md`). The inner keys are +deliberately snake_case — they are the binding namespace a `call.context` value (`"$ctx."`) +addresses, not the wire's usual camelCase. Omitted when there is no identity to bind, so a run that +needs no binding stays byte-identical. + Two splits matter for back-compat. `provider` and `connection` appear only when the model arrives as a structured `model_ref`; a plain string like `"gpt-5.5"` leaves them off so the wire stays byte-identical to the old shape. And `secrets` is the only vault-key channel on diff --git a/sdks/python/agenta/sdk/agents/__init__.py b/sdks/python/agenta/sdk/agents/__init__.py index e6ee91c39d..d8446453c5 100644 --- a/sdks/python/agenta/sdk/agents/__init__.py +++ b/sdks/python/agenta/sdk/agents/__init__.py @@ -68,6 +68,9 @@ NetworkEgress, PermissionPolicy, PiAgentConfig, + RunContext, + RunContextTrace, + RunContextWorkflow, SandboxPermission, SessionConfig, ToolCallback, @@ -166,6 +169,9 @@ "to_ui_message", "ui_message_stream", "TraceContext", + "RunContext", + "RunContextWorkflow", + "RunContextTrace", "ToolCallback", "PermissionPolicy", "SandboxPermission", diff --git a/sdks/python/agenta/sdk/agents/adapters/local.py b/sdks/python/agenta/sdk/agents/adapters/local.py index d0c304c793..0883304578 100644 --- a/sdks/python/agenta/sdk/agents/adapters/local.py +++ b/sdks/python/agenta/sdk/agents/adapters/local.py @@ -22,7 +22,7 @@ from typing import Mapping, Optional -from ..dtos import HarnessAgentConfig, HarnessType, TraceContext +from ..dtos import HarnessAgentConfig, HarnessType, RunContext, TraceContext from ..interfaces import Backend, Sandbox, Session @@ -45,6 +45,7 @@ async def create_session( harness: HarnessType, secrets: Optional[Mapping[str, str]] = None, trace: Optional[TraceContext] = None, + run_context: Optional[RunContext] = None, session_id: Optional[str] = None, ) -> Session: raise NotImplementedError( diff --git a/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py b/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py index 9bf62a8d87..8dd1dee2ad 100644 --- a/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py +++ b/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py @@ -23,6 +23,7 @@ HarnessAgentConfig, HarnessType, Message, + RunContext, TraceContext, ) from ..interfaces import Backend, Sandbox, Session @@ -65,6 +66,7 @@ def __init__( harness: HarnessType, secrets: Optional[Mapping[str, str]], trace: Optional[TraceContext], + run_context: Optional[RunContext], session_id: Optional[str], ) -> None: self._backend = backend @@ -73,6 +75,7 @@ def __init__( self._harness = harness self._secrets = dict(secrets or {}) self._trace = trace + self._run_context = run_context self._session_id = session_id @property @@ -88,6 +91,7 @@ def _wire_payload(self, messages: Sequence[Message]) -> Dict[str, Any]: messages=messages, secrets=self._secrets, trace=self._trace, + run_context=self._run_context, session_id=self._session_id, ) @@ -152,6 +156,7 @@ async def create_session( harness: HarnessType, secrets: Optional[Mapping[str, str]] = None, trace: Optional[TraceContext] = None, + run_context: Optional[RunContext] = None, session_id: Optional[str] = None, ) -> SandboxAgentSession: if not isinstance(sandbox, SandboxAgentSandbox): @@ -165,6 +170,7 @@ async def create_session( harness=harness, secrets=secrets, trace=trace, + run_context=run_context, session_id=session_id, ) diff --git a/sdks/python/agenta/sdk/agents/dtos.py b/sdks/python/agenta/sdk/agents/dtos.py index a8c1cf37fe..6f8d50c946 100644 --- a/sdks/python/agenta/sdk/agents/dtos.py +++ b/sdks/python/agenta/sdk/agents/dtos.py @@ -376,6 +376,75 @@ def to_wire(self) -> Dict[str, Any]: } +class RunContextWorkflow(BaseModel): + """The running workflow/variant's own identity (direct-call tools, Phase 3a). + + Part of the per-turn :class:`RunContext` blob. A self-targeting platform tool binds one of + these into its request body server-side (e.g. ``$ctx.workflow.variant_id`` for "update + myself"), so the model supplies only the payload and cannot retarget a different variant. All + fields optional and best-effort: the service fills what it holds and omits the rest.""" + + artifact_id: Optional[str] = None + variant_id: Optional[str] = None + variant_name: Optional[str] = None + revision_id: Optional[str] = None + version: Optional[str] = None + is_draft: Optional[bool] = None + latest_revision_id: Optional[str] = None + + +class RunContextTrace(BaseModel): + """The current run's own trace identity (direct-call tools, Phase 3a). + + A tool that acts on the run's own trace (e.g. "annotate my trace") binds + ``$ctx.trace.trace_id`` into its request body server-side.""" + + trace_id: Optional[str] = None + span_id: Optional[str] = None + + +class RunContext(BaseModel): + """The run's own context, delivered on ``/run`` and refreshed per turn (direct-call tools, + Phase 3a; see ``projects/direct-call-tools/run-context.md``). + + The service computes this from the invocation's own trace + variant identity and sends it on + the ``/run`` request. It is consumed ONLY by a tool's ``call.context`` binding: the runner + fills bound request fields from this blob at dispatch, server-side and hidden from the model. + The model never reads run context directly. + + The inner keys are deliberately snake_case (``workflow.variant_id``, ``trace.trace_id``, + ``session_id``): they are the binding NAMESPACE that a catalog entry's ``$ctx.`` + token addresses, so they match those tokens exactly rather than the wire's camelCase + convention. ``to_wire`` emits only the sub-objects/fields that are set, so a run with no + identity yields an empty blob (and the serializer omits the key entirely).""" + + workflow: Optional[RunContextWorkflow] = None + trace: Optional[RunContextTrace] = None + session_id: Optional[str] = None + + def to_wire(self) -> Dict[str, Any]: + out: Dict[str, Any] = {} + if self.workflow is not None: + workflow = { + key: value + for key, value in self.workflow.model_dump().items() + if value is not None + } + if workflow: + out["workflow"] = workflow + if self.trace is not None: + trace = { + key: value + for key, value in self.trace.model_dump().items() + if value is not None + } + if trace: + out["trace"] = trace + if self.session_id is not None: + out["session_id"] = self.session_id + return out + + # --------------------------------------------------------------------------- # Run result # --------------------------------------------------------------------------- @@ -801,6 +870,10 @@ class SessionConfig(BaseModel): resolved_connection: Optional[ResolvedConnection] = None permission_policy: PermissionPolicy = "auto" trace: Optional[TraceContext] = None + # The run's own context (trace + variant identity), refreshed per turn and consumed only by a + # tool's ``call.context`` binding at dispatch (direct-call tools, Phase 3a). Omitted from the + # 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 builtin_names: List[str] = Field( default_factory=list, diff --git a/sdks/python/agenta/sdk/agents/interfaces.py b/sdks/python/agenta/sdk/agents/interfaces.py index 3dcaccd93c..2351199361 100644 --- a/sdks/python/agenta/sdk/agents/interfaces.py +++ b/sdks/python/agenta/sdk/agents/interfaces.py @@ -27,6 +27,7 @@ HarnessAgentConfig, HarnessType, Message, + RunContext, SessionConfig, TraceContext, ) @@ -126,6 +127,7 @@ async def create_session( harness: HarnessType, secrets: Optional[Mapping[str, str]] = None, trace: Optional[TraceContext] = None, + run_context: Optional[RunContext] = None, session_id: Optional[str] = None, ) -> Session: """Open a session in ``sandbox`` for an already-harness-shaped ``config``.""" @@ -190,6 +192,7 @@ async def create_session( harness=harness, secrets=session_config.secrets, trace=session_config.trace, + run_context=session_config.run_context, session_id=session_config.session_id, ) diff --git a/sdks/python/agenta/sdk/agents/utils/wire.py b/sdks/python/agenta/sdk/agents/utils/wire.py index c78f570bc1..ea8913a25b 100644 --- a/sdks/python/agenta/sdk/agents/utils/wire.py +++ b/sdks/python/agenta/sdk/agents/utils/wire.py @@ -29,6 +29,7 @@ HarnessCapabilities, HarnessType, Message, + RunContext, TraceContext, ) @@ -73,6 +74,7 @@ def request_to_wire( messages: Sequence[Message], secrets: Optional[Dict[str, str]] = None, trace: Optional[TraceContext] = None, + run_context: Optional[RunContext] = None, session_id: Optional[str] = None, ) -> Dict[str, Any]: """Serialize one turn into the ``/run`` request JSON. @@ -101,8 +103,13 @@ def request_to_wire( unless the config produced any files. This is where the per-harness translation happens in Python (e.g. the claude config renders ``.claude/settings.json``); the runner is a dumb writer that drops each entry into the cwd with no harness knowledge. + + ``run_context`` is the run's own context (trace + variant identity), refreshed per turn. When + set it rides as ``runContext`` and is consumed only by a tool's ``call.context`` binding at + dispatch (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. """ - return { + payload: Dict[str, Any] = { "harness": harness.value, "sandbox": sandbox, "sessionId": session_id, @@ -120,6 +127,11 @@ def request_to_wire( **config.wire_resolved_connection(), **config.wire_harness_files(), } + if run_context is not None: + run_context_wire = run_context.to_wire() + if run_context_wire: + payload["runContext"] = run_context_wire + return payload def result_from_wire(data: Dict[str, Any]) -> AgentResult: diff --git a/sdks/python/agenta/sdk/agents/wire_models.py b/sdks/python/agenta/sdk/agents/wire_models.py index 99ce668fba..9a92b52da5 100644 --- a/sdks/python/agenta/sdk/agents/wire_models.py +++ b/sdks/python/agenta/sdk/agents/wire_models.py @@ -122,6 +122,42 @@ class WireToolCallback(_WireModel): authorization: Optional[str] = None +class WireRunContextWorkflow(_WireModel): + """The running workflow/variant identity inside ``runContext`` (mirrors + ``RunContextWorkflow``). The keys stay snake_case on purpose — see ``WireRunContext``.""" + + artifact_id: Optional[str] = None + variant_id: Optional[str] = None + variant_name: Optional[str] = None + revision_id: Optional[str] = None + version: Optional[str] = None + is_draft: Optional[bool] = None + latest_revision_id: Optional[str] = None + + +class WireRunContextTrace(_WireModel): + """The run's own trace identity inside ``runContext`` (mirrors ``RunContextTrace``).""" + + trace_id: Optional[str] = None + span_id: Optional[str] = None + + +class WireRunContext(_WireModel): + """The run's own context, delivered on ``/run`` and refreshed per turn (direct-call tools, + Phase 3a; mirrors ``RunContext.to_wire``). + + Consumed only by a tool's ``call.context`` binding at dispatch, server-side and hidden from + the model. Unlike the rest of the wire, the INNER keys are snake_case + (``workflow.variant_id`` / ``trace.trace_id`` / ``session_id``): they are the binding + NAMESPACE a catalog entry's ``$ctx.`` token addresses, so they must match those + tokens exactly rather than follow the camelCase wire convention. The top-level field is still + the camelCase ``runContext`` on the request.""" + + workflow: Optional[WireRunContextWorkflow] = None + trace: Optional[WireRunContextTrace] = None + session_id: Optional[str] = None + + class WireRenderHint(_WireModel): """How a tool's result should be rendered by a client.""" @@ -312,6 +348,9 @@ class WireRunRequest(_WireModel): # Secrets injected as harness env (provider keys); never written to the agent filesystem. secrets: Optional[Dict[str, str]] = None trace: Optional[WireTraceContext] = None + # The run's own context (trace + variant identity), refreshed per turn; consumed only by a + # tool's ``call.context`` binding at dispatch (direct-call tools, Phase 3a). Omitted when unset. + run_context: Optional[WireRunContext] = Field(default=None, alias="runContext") # Tools + skills. tools: Optional[List[str]] = None custom_tools: Optional[List[WireResolvedToolSpec]] = Field( diff --git a/sdks/python/oss/tests/pytest/unit/agents/conftest.py b/sdks/python/oss/tests/pytest/unit/agents/conftest.py index a434fdacc5..95124a37a5 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/conftest.py +++ b/sdks/python/oss/tests/pytest/unit/agents/conftest.py @@ -142,6 +142,7 @@ async def create_session( harness, secrets=None, trace=None, + run_context=None, session_id=None, ) -> FakeSession: self.created_sessions.append( @@ -151,6 +152,7 @@ async def create_session( "harness": harness, "secrets": secrets, "trace": trace, + "run_context": run_context, "session_id": session_id, } ) 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 aeafc4923b..a8c596bb53 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 @@ -15,6 +15,19 @@ "authorization": "Access tok-123", "captureContent": true }, + "runContext": { + "workflow": { + "variant_id": "var_abc", + "variant_name": "weather-agent", + "revision_id": "rev_abc123", + "version": "3" + }, + "trace": { + "trace_id": "0af7651916cd43dd8448eb211c80319c", + "span_id": "b7ad6b7169203331" + }, + "session_id": "sess-1" + }, "tools": ["read", "write"], "customTools": [ { 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 cc97ccd86a..a9e86cf5f0 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 @@ -27,6 +27,9 @@ Message, PiAgentConfig, ResolvedConnection, + RunContext, + RunContextTrace, + RunContextWorkflow, SandboxPermission, SkillConfig, ToolCallback, @@ -55,6 +58,7 @@ "messages", "secrets", "trace", + "runContext", "tools", "customTools", "mcpServers", @@ -131,6 +135,22 @@ def _pi_payload(): authorization="Access tok-123", capture_content=True, ), + # The run's own context (trace + variant identity), refreshed per turn and consumed only by + # a tool's `call.context` binding at dispatch (direct-call tools, Phase 3a). `to_wire` drops + # the unset workflow fields (artifact_id / is_draft / latest_revision_id). + run_context=RunContext( + workflow=RunContextWorkflow( + variant_id="var_abc", + variant_name="weather-agent", + revision_id="rev_abc123", + version="3", + ), + trace=RunContextTrace( + trace_id="0af7651916cd43dd8448eb211c80319c", + span_id="b7ad6b7169203331", + ), + session_id="sess-1", + ), session_id="sess-1", ) @@ -232,6 +252,22 @@ def test_request_to_wire_pi_matches_golden(golden): "body": {"references": {"workflow_revision": {"id": "rev_abc123"}}}, "args_into": "data.inputs", } + # The run's own context rides as `runContext` (direct-call tools, Phase 3a): the workflow + + # trace identity plus the session id, with snake_case inner keys (the `$ctx.` binding + # namespace) and the unset workflow fields dropped by `to_wire`. + assert payload["runContext"] == { + "workflow": { + "variant_id": "var_abc", + "variant_name": "weather-agent", + "revision_id": "rev_abc123", + "version": "3", + }, + "trace": { + "trace_id": "0af7651916cd43dd8448eb211c80319c", + "span_id": "b7ad6b7169203331", + }, + "session_id": "sess-1", + } # The declared sandbox boundary rides the wire as nested camelCase `sandboxPermission`; # the unset `filesystem` is dropped (declared, not enforced) so it never appears. assert payload["sandboxPermission"] == { @@ -242,9 +278,36 @@ def test_request_to_wire_pi_matches_golden(golden): assert "harnessFiles" not in payload +def test_request_to_wire_omits_run_context_when_none(): + # No run context passed -> no `runContext` key (a run that needs no `call.context` binding stays + # byte-identical to before, the same discipline skills/mcpServers/sandboxPermission use). + payload = request_to_wire( + harness=HarnessType.PI, + sandbox="local", + config=PiAgentConfig(), + messages=[Message(role="user", content="hi")], + ) + assert "runContext" not in payload + + +def test_request_to_wire_omits_run_context_when_empty(): + # An entirely-empty run context (no identity to bind) serializes to {} and is dropped, so it + # never rides the wire as a noise `"runContext": {}` key. + payload = request_to_wire( + harness=HarnessType.PI, + sandbox="local", + config=PiAgentConfig(), + messages=[Message(role="user", content="hi")], + run_context=RunContext(), + ) + assert "runContext" not in payload + + def test_request_to_wire_claude_matches_golden(golden): payload = _claude_payload() assert payload == golden("run_request.claude.json") + # The claude payload threads no run context, so `runContext` is absent (the golden has none). + assert "runContext" not in payload # No explicit author permission + read_only=True -> derived `allow` rides the wire. assert payload["customTools"][0]["permission"] == "allow" # Claude-specific invariants the golden encodes, asserted explicitly so a failure reads clearly. diff --git a/services/agent/src/protocol.ts b/services/agent/src/protocol.ts index e1b957a74a..4603f4dcb5 100644 --- a/services/agent/src/protocol.ts +++ b/services/agent/src/protocol.ts @@ -108,6 +108,35 @@ export interface ToolCallbackContext { authorization?: string; } +/** + * The run's own context, delivered on `/run` and refreshed per turn (direct-call tools, Phase 3a; + * see `projects/direct-call-tools/run-context.md`). The service computes it from the invocation's + * own trace + variant identity. It is consumed ONLY by a tool's `call.context` binding: the runner + * fills bound request fields from this blob at dispatch, server-side and hidden from the model. The + * model never reads run context directly. + * + * The keys are deliberately snake_case (`workflow.variant_id`, `trace.trace_id`, `session_id`): + * they are the binding NAMESPACE a `call.context` value (`"$ctx."`) addresses, so they + * match those tokens exactly rather than the rest of the wire's camelCase. Every field is optional + * and best-effort — the service fills what it holds and omits the rest. + */ +export interface RunContext { + workflow?: { + artifact_id?: string; + variant_id?: string; + variant_name?: string; + revision_id?: string; + version?: string; + is_draft?: boolean; + latest_revision_id?: string; + }; + trace?: { + trace_id?: string; + span_id?: string; + }; + session_id?: string; +} + /** * One bundled file laid beside SKILL.md by relative `path`. `content` is inline UTF-8 text; * `executable` requests a `chmod +x` that the runner honors only when the skill's @@ -378,6 +407,13 @@ export interface AgentRunRequest { harnessFiles?: Array<{ path: string; content: string }>; /** Tracing: thread the Agenta trace context across the boundary. */ trace?: TraceContext; + /** + * The run's own context (trace + variant identity), refreshed per turn (direct-call tools, + * Phase 3a). Consumed only by a tool's `call.context` binding at dispatch — the runner fills the + * bound request fields from this blob server-side, hidden from the model (see `RunContext` and + * `tools/direct.ts` `assembleBody`). Omitted when the run has no own identity to bind. + */ + runContext?: RunContext; } export interface AgentRunResult { diff --git a/services/agent/src/tools/direct.ts b/services/agent/src/tools/direct.ts index 581ba1a17f..42012e6335 100644 --- a/services/agent/src/tools/direct.ts +++ b/services/agent/src/tools/direct.ts @@ -9,8 +9,8 @@ * * This module owns the three pieces of a direct call so both dispatch paths share one * implementation: - * - `assembleBody` — merge the model's args with the server-fixed `body` (and, in Phase 3, the - * run-context `context` binding) per the body-assembly rules in the design. + * - `assembleBody` — merge the model's args with the server-fixed `body` and the run-context + * `context` binding (Phase 3a) per the body-assembly rules in the design. * - `directCallUrl` — the SSRF guard: validate the method + path and bind the origin to the run's * own Agenta, so the descriptor (untrusted input) can never reach a non-Agenta host. * - `callDirect` — the actual HTTP round-trip, reusing the run's caller credential. @@ -20,12 +20,15 @@ * symmetric `tools/dispatch.ts` `runResolvedTool` host-direct branch is deferred until the * gateway-refactor lane lands (see the PR notes); the in-sandbox child never makes the call. */ -import type { ResolvedToolSpec } from "../protocol.ts"; +import type { ResolvedToolSpec, RunContext } from "../protocol.ts"; import { TOOL_CALL_TIMEOUT_MS } from "./callback.ts"; /** The resolved `call` descriptor (see `ResolvedToolSpec.call`). */ export type DirectCall = NonNullable; +/** The prefix every `call.context` value carries: `"$ctx."` (see `RunContext`). */ +const CTX_TOKEN_PREFIX = "$ctx."; + /** Methods a direct call may use. The descriptor is untrusted, so this is an allowlist. */ const DIRECT_CALL_METHODS = new Set(["GET", "POST"]); @@ -89,6 +92,32 @@ export function deepMerge( return out; } +/** + * Resolve a `call.context` token (`"$ctx."`) against the run's `runContext` blob. + * + * The descriptor is untrusted, so a malformed token (one that does not start with `$ctx.`) is + * skipped rather than trusted: it returns `undefined`. A path that does not resolve in the blob + * (no `runContext`, a missing sub-object, or a missing key) also returns `undefined`. Only a + * non-`undefined` resolved value is bound — `null` is a real value and binds, `undefined` does not. + */ +export function resolveCtxToken( + runContext: RunContext | undefined, + token: string, +): unknown { + if (typeof token !== "string" || !token.startsWith(CTX_TOKEN_PREFIX)) { + return undefined; + } + if (!runContext) return undefined; + const path = token.slice(CTX_TOKEN_PREFIX.length); + if (!path) return undefined; + let cursor: unknown = runContext; + for (const part of path.split(".")) { + if (!part || !isPlainObject(cursor)) return undefined; + cursor = cursor[part]; + } + return cursor; +} + /** * Build the request body for a direct call from the model's `params` and the descriptor. * @@ -99,13 +128,16 @@ export function deepMerge( * 2. `call.body` — static server-fixed fields baked at resolve time (e.g. a reference's * `references.workflow_revision.id`). These OVERLAY the model's args, so the model can never * retarget or override a fixed field. - * 3. `call.context` — the run-context binding ($ctx. from the run's `runContext`), filled - * LAST so a bound field always wins. THIS IS A PHASE 3 SEAM: `runContext` is not wired yet, - * so Phase 2 does not apply it (and nothing emits `context` yet). See the TODO below. + * 3. `call.context` — the run-context binding (`{ bodyPath: "$ctx." }`), filled LAST so a + * bound field always wins over both the model's args and the static `body`. Each token resolves + * against the run's `runContext` (delivered on `/run`); a token that does not resolve is left + * unset (the field is simply absent), and the model never sees or sets a bound field. This is + * how a self-targeting tool gets its own trace/variant server-side. */ export function assembleBody( call: DirectCall, params: unknown, + runContext?: RunContext, ): Record { // 1. Model args, at args_into (deep-set) or the root. let body: Record = {}; @@ -117,11 +149,16 @@ export function assembleBody( } // 2. Server-fixed fields win over the model's args. if (call.body) body = deepMerge(body, call.body); - // 3. Run-context binding (`call.context`) is Phase 3. It depends on the `runContext` payload on - // `/run`, which is not wired yet, so it is intentionally NOT applied here and no resolver - // emits it. Filling it last (context wins) is the documented merge rule. - // TODO(Phase 3): for each [bodyPath, "$ctx."] in call.context, resolve against - // the run's runContext blob and deepSet(body, bodyPath, value) — context overrides all. + // 3. Run-context binding wins over everything (filled LAST). For each [bodyPath, token] in + // call.context, resolve the token against runContext and deep-set it; a token that does not + // resolve is skipped so a missing run-context value never clobbers the body with `undefined`. + // deepSet is prototype-pollution-safe and rejects unsafe path segments. + if (call.context) { + for (const [bodyPath, token] of Object.entries(call.context)) { + const value = resolveCtxToken(runContext, token); + if (value !== undefined) deepSet(body, bodyPath, value); + } + } return body; } diff --git a/services/agent/src/tools/relay.ts b/services/agent/src/tools/relay.ts index 97ec8d0365..3410eff84a 100644 --- a/services/agent/src/tools/relay.ts +++ b/services/agent/src/tools/relay.ts @@ -20,7 +20,11 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from import { callAgentaTool } from "./callback.ts"; import { runCodeTool } from "./code.ts"; import { assembleBody, callDirect, directCallUrl } from "./direct.ts"; -import type { ResolvedToolSpec, ToolCallbackContext } from "../protocol.ts"; +import type { + ResolvedToolSpec, + RunContext, + ToolCallbackContext, +} from "../protocol.ts"; import type { PermissionPolicy } from "../responder.ts"; export const RELAY_REQ_SUFFIX = ".req.json"; @@ -121,6 +125,7 @@ async function executeRelayedTool( req: RelayRequest, callback: ToolCallbackContext | undefined, policy: PermissionPolicy, + runContext: RunContext | undefined, ): Promise { // Layer 3 enforcement (S3b): gate the call on the spec's permission before it runs. // `deny` returns a refusal string (not a throw) so the harness folds it into the tool @@ -145,10 +150,11 @@ async function executeRelayedTool( // Direct-call tools (reference / platform): the host makes the call directly so the sandbox // child still sends only name + args. The origin is bound to the run's own callback endpoint // and the run's authorization is reused (see tools/direct.ts). A spec carries `call` XOR - // `callRef`, so this is checked before the gateway fallback. + // `callRef`, so this is checked before the gateway fallback. `runContext` fills the + // `call.context` bindings server-side (direct-call tools, Phase 3a), hidden from the model. if (spec.call) { const url = directCallUrl(callback.endpoint, spec.call); - const body = assembleBody(spec.call, req.args); + const body = assembleBody(spec.call, req.args, runContext); return callDirect(spec.call.method, url, callback.authorization, body); } // Gateway (Composio): POST back through Agenta's /tools/call so the secret stays server-side. @@ -173,6 +179,7 @@ export function startToolRelay( specs: ResolvedToolSpec[], callback: ToolCallbackContext | undefined, policy: PermissionPolicy, + runContext?: RunContext, ): { stop: () => Promise } { let active = true; const seen = new Set(); @@ -192,6 +199,7 @@ export function startToolRelay( { ...req, toolCallId: req.toolCallId ?? id }, callback, policy, + runContext, ); res = { ok: true, text }; } catch (err) { diff --git a/services/agent/tests/unit/tool-direct.test.ts b/services/agent/tests/unit/tool-direct.test.ts index d3f97142a0..2f48a0d97c 100644 --- a/services/agent/tests/unit/tool-direct.test.ts +++ b/services/agent/tests/unit/tool-direct.test.ts @@ -26,6 +26,7 @@ import { deepMerge, deepSet, directCallUrl, + resolveCtxToken, type DirectCall, } from "../../src/tools/direct.ts"; import { @@ -33,7 +34,15 @@ import { startToolRelay, type RelayResponse, } from "../../src/tools/relay.ts"; -import type { ResolvedToolSpec } from "../../src/protocol.ts"; +import type { ResolvedToolSpec, RunContext } from "../../src/protocol.ts"; + +// A fake run context (direct-call tools, Phase 3a). The keys are the snake_case binding namespace +// a `call.context` value (`"$ctx."`) addresses. +const RUN_CONTEXT: RunContext = { + workflow: { variant_id: "own-variant", revision_id: "rev_self" }, + trace: { trace_id: "trace-self", span_id: "span-self" }, + session_id: "sess-1", +}; const ENDPOINT = "https://agenta.example/api/tools/call"; @@ -127,17 +136,6 @@ describe("assembleBody", () => { assert.deepEqual(assembleBody(call, undefined), {}); }); - it("does NOT apply context (Phase 3 seam): a $ctx binding is ignored for now", () => { - const call: DirectCall = { - method: "POST", - path: "/api/x", - context: { "trace.trace_id": "$ctx.trace.trace_id" }, - }; - const body = assembleBody(call, { a: 1 }); - // The bound field is NOT set: context binding depends on runContext (Phase 3). - assert.deepEqual(body, { a: 1 }); - }); - it("is prototype-pollution-safe via args_into", () => { const call: DirectCall = { method: "POST", @@ -161,6 +159,111 @@ describe("assembleBody", () => { }); }); +// --------------------------------------------------------------------------- +// assembleBody — run-context binding (call.context, direct-call tools Phase 3a) +// --------------------------------------------------------------------------- + +describe("assembleBody context binding", () => { + it("binds a $ctx value from the run context, deep-set at the mapped path", () => { + const call: DirectCall = { + method: "POST", + path: "/api/annotations/", + context: { "references.trace.id": "$ctx.trace.trace_id" }, + }; + const body = assembleBody(call, { note: "hi" }, RUN_CONTEXT); + assert.deepEqual(body, { + note: "hi", + references: { trace: { id: "trace-self" } }, + }); + }); + + it("handles a missing run-context key safely (the field stays unset)", () => { + const call: DirectCall = { + method: "POST", + path: "/api/x", + context: { latest: "$ctx.workflow.latest_revision_id" }, // not in RUN_CONTEXT + }; + const body = assembleBody(call, { a: 1 }, RUN_CONTEXT); + assert.deepEqual(body, { a: 1 }); + assert.ok(!("latest" in body)); + }); + + it("handles an absent run context safely (no binding applied)", () => { + const call: DirectCall = { + method: "POST", + path: "/api/x", + context: { "trace.trace_id": "$ctx.trace.trace_id" }, + }; + // No runContext argument at all (the Phase-2 call shape): the binding is simply skipped. + assert.deepEqual(assembleBody(call, { a: 1 }), { a: 1 }); + }); + + it("lets a bound field win over a colliding model arg (the model cannot override it)", () => { + const call: DirectCall = { + method: "POST", + path: "/api/workflows/revisions/commit", + context: { workflow_variant_id: "$ctx.workflow.variant_id" }, + }; + const body = assembleBody( + call, + { workflow_variant_id: "someone-elses", parameters: { temperature: 0.2 } }, + RUN_CONTEXT, + ); + // Bound to the run's OWN variant, not the model's attempt. + assert.equal(body.workflow_variant_id, "own-variant"); + assert.deepEqual(body.parameters, { temperature: 0.2 }); + }); + + it("lets a bound field win over a static body field (context is filled last)", () => { + const call: DirectCall = { + method: "POST", + path: "/api/x", + body: { trace_id: "from-body" }, + context: { trace_id: "$ctx.trace.trace_id" }, + }; + const body = assembleBody(call, {}, RUN_CONTEXT); + assert.equal(body.trace_id, "trace-self"); + }); + + it("skips a malformed context token (one without the $ctx. prefix)", () => { + const call: DirectCall = { + method: "POST", + path: "/api/x", + context: { trace_id: "trace.trace_id" }, // missing the $ctx. prefix -> untrusted, skipped + }; + const body = assembleBody(call, { a: 1 }, RUN_CONTEXT); + assert.deepEqual(body, { a: 1 }); + }); + + it("is prototype-pollution-safe on the bound body path", () => { + const call: DirectCall = { + method: "POST", + path: "/api/x", + context: { "__proto__.polluted": "$ctx.trace.trace_id" }, + }; + assert.throws( + () => assembleBody(call, { a: 1 }, RUN_CONTEXT), + /unsafe path segment '__proto__'/, + ); + assert.equal(({} as any).polluted, undefined); + }); +}); + +describe("resolveCtxToken", () => { + it("navigates a dotted path against the run context", () => { + assert.equal( + resolveCtxToken(RUN_CONTEXT, "$ctx.workflow.variant_id"), + "own-variant", + ); + }); + + it("returns undefined for a missing key, a malformed token, or no run context", () => { + assert.equal(resolveCtxToken(RUN_CONTEXT, "$ctx.workflow.missing"), undefined); + assert.equal(resolveCtxToken(RUN_CONTEXT, "workflow.variant_id"), undefined); + assert.equal(resolveCtxToken(undefined, "$ctx.trace.trace_id"), undefined); + }); +}); + // --------------------------------------------------------------------------- // deepSet / deepMerge primitives // --------------------------------------------------------------------------- @@ -312,6 +415,7 @@ async function relayOnce( spec: ResolvedToolSpec, callback: { endpoint: string; authorization?: string }, args: unknown, + runContext?: RunContext, ): Promise { const dir = mkdtempSync(join(tmpdir(), "agenta-direct-relay-")); try { @@ -320,7 +424,14 @@ async function relayOnce( join(dir, `${id}.req.json`), JSON.stringify({ toolName: spec.name, toolCallId: id, args }), ); - const relay = startToolRelay(localRelayHost(), dir, [spec], callback, "auto"); + const relay = startToolRelay( + localRelayHost(), + dir, + [spec], + callback, + "auto", + runContext, + ); const resPath = join(dir, `${id}.res.json`); const deadline = Date.now() + 5000; while (Date.now() < deadline && !existsSync(resPath)) { @@ -355,6 +466,35 @@ describe("startToolRelay direct branch (host makes the call for the sandbox)", ( }); }); + it("binds run context into the relayed direct call, server-side (model never sets it)", async () => { + const calls = stubFetch("ok"); + // A self-targeting platform tool: the model supplies only the payload; the runner binds the + // run's own variant from runContext, and the model's attempt to retarget is overridden. + const selfSpec: ResolvedToolSpec = { + name: "update_self", + kind: "callback", + call: { + method: "POST", + path: "/api/workflows/revisions/commit", + context: { workflow_variant_id: "$ctx.workflow.variant_id" }, + }, + }; + const res = await relayOnce( + selfSpec, + { endpoint: ENDPOINT, authorization: "ApiKey secret" }, + { workflow_variant_id: "someone-elses", parameters: { temperature: 0.2 } }, + RUN_CONTEXT, + ); + + assert.equal(res.ok, true); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "https://agenta.example/api/workflows/revisions/commit"); + assert.deepEqual(JSON.parse(calls[0].init.body as string), { + workflow_variant_id: "own-variant", // bound to the run's own variant, not the model's + parameters: { temperature: 0.2 }, + }); + }); + it("surfaces the SSRF-guard rejection as a relay error", async () => { stubFetch("never"); const badSpec: ResolvedToolSpec = { diff --git a/services/agent/tests/unit/wire-contract.test.ts b/services/agent/tests/unit/wire-contract.test.ts index aef9906d2a..0cd100e203 100644 --- a/services/agent/tests/unit/wire-contract.test.ts +++ b/services/agent/tests/unit/wire-contract.test.ts @@ -45,6 +45,7 @@ const KNOWN_REQUEST_KEYS = [ "messages", "secrets", "trace", + "runContext", "tools", "customTools", "mcpServers", @@ -108,6 +109,14 @@ describe("wire contract: requests (vs Python golden)", () => { assert.deepEqual(direct.call!.body, { references: { workflow_revision: { id: "rev_abc123" } }, }); + // The run's own context (direct-call tools, Phase 3a) reaches the runner as `runContext`, with + // snake_case inner keys (the `$ctx.` binding namespace). The runner fills a tool's + // `call.context` from this blob at dispatch (see tools/direct.ts `assembleBody`); the model + // never reads it. + assert.equal(req.runContext!.workflow!.variant_id, "var_abc"); + assert.equal(req.runContext!.workflow!.revision_id, "rev_abc123"); + assert.equal(req.runContext!.trace!.trace_id, "0af7651916cd43dd8448eb211c80319c"); + assert.equal(req.runContext!.session_id, "sess-1"); // Pi exposes the prompt overrides. assert.equal(req.systemPrompt, "You are Pi."); assert.equal(req.appendSystemPrompt, "Be terse."); @@ -137,6 +146,7 @@ describe("wire contract: requests (vs Python golden)", () => { assert.equal(req.permissionPolicy, "deny"); // Claude gates tool use assert.equal(req.systemPrompt, undefined); // Claude exposes no prompt overrides assert.equal(req.appendSystemPrompt, undefined); + assert.equal(req.runContext, undefined); // no run context threaded on this config assert.equal(req.sandboxPermission, undefined); // no boundary declared on this config // The Claude harness's permission knobs are translated to a rendered file in Python: the // wire carries a generic `harnessFiles` entry the runner writes blind into the cwd. diff --git a/services/oss/src/agent/app.py b/services/oss/src/agent/app.py index 1d1f43f47c..23773ff09d 100644 --- a/services/oss/src/agent/app.py +++ b/services/oss/src/agent/app.py @@ -63,7 +63,7 @@ from oss.src.agent.config import load_config, runner_dir, runner_url from oss.src.agent.schemas import AGENT_SCHEMAS from oss.src.agent.tools import resolve_mcp_servers, resolve_tools -from oss.src.agent.tracing import record_usage, trace_context +from oss.src.agent.tracing import record_usage, run_context, trace_context log = get_module_logger(__name__) @@ -249,6 +249,9 @@ async def _agent( resolved_connection=resolved_connection, permission_policy=agent_config.permission_policy, trace=trace_context(), + # The run's own context (trace + variant identity), refreshed each turn and consumed only + # by a tool's `call.context` binding at dispatch (direct-call tools, Phase 3a). + run_context=run_context(session_id=session_id), session_id=session_id, builtin_names=resolved_tools.builtin_names, tool_specs=resolved_tools.tool_specs, diff --git a/services/oss/src/agent/tracing.py b/services/oss/src/agent/tracing.py index c5a76d51cb..71fe8e5deb 100644 --- a/services/oss/src/agent/tracing.py +++ b/services/oss/src/agent/tracing.py @@ -12,10 +12,16 @@ from opentelemetry import trace as otel_trace import agenta as ag +from agenta.sdk.contexts.tracing import TracingContext from agenta.sdk.engines.tracing.propagation import inject from agenta.sdk.utils.logging import get_module_logger -from agenta.sdk.agents import TraceContext +from agenta.sdk.agents import ( + RunContext, + RunContextTrace, + RunContextWorkflow, + TraceContext, +) log = get_module_logger(__name__) @@ -61,6 +67,79 @@ def trace_context() -> Optional[TraceContext]: return None +def _reference_field( + references: Optional[Dict[str, Any]], key: str, field: str +) -> Optional[str]: + """Pull one field (``id`` / ``slug`` / ``version``) from a reference entry, or ``None``. + + The entry may be a :class:`Reference` model or a plain dict. Any value is stringified so the + run-context blob carries plain strings (UUIDs become their hex form), which is what the + ``$ctx.`` binding deep-sets into a request body.""" + if not references: + return None + entry = references.get(key) + if entry is None: + return None + value = ( + getattr(entry, field, None) if not isinstance(entry, dict) else entry.get(field) + ) + return str(value) if value is not None else None + + +def _run_context_workflow() -> Optional[RunContextWorkflow]: + """The running workflow/variant identity, best-effort, from the resolved tracing references. + + The references land on the tracing context after the resolver hydrates a stored + variant/environment reference (``workflow`` / ``workflow_variant`` / ``workflow_revision``). + A playground run of an unsaved inline config carries no references, so this returns ``None`` + and the binding simply has no value — every field is optional.""" + references = TracingContext.get().references + workflow = RunContextWorkflow( + artifact_id=_reference_field(references, "workflow", "id"), + variant_id=_reference_field(references, "workflow_variant", "id"), + variant_name=_reference_field(references, "workflow_variant", "slug"), + revision_id=_reference_field(references, "workflow_revision", "id"), + version=_reference_field(references, "workflow_revision", "version"), + ) + if workflow.model_dump(exclude_none=True): + return workflow + return None + + +def _run_context_trace() -> Optional[RunContextTrace]: + """The current run's own trace + span ids, read from the active OpenTelemetry span. + + These are the ids a self-targeting tool binds (``$ctx.trace.trace_id`` for "annotate my + trace"). Best-effort: a missing/invalid span context returns ``None``.""" + span_context = otel_trace.get_current_span().get_span_context() + if not span_context or not span_context.is_valid: + return None + return RunContextTrace( + trace_id=otel_trace.format_trace_id(span_context.trace_id), + span_id=otel_trace.format_span_id(span_context.span_id), + ) + + +def run_context(session_id: Optional[str] = None) -> Optional[RunContext]: + """Capture the run's own context for tool ``call.context`` binding (direct-call tools, Phase 3a). + + Assembles the run's own trace + variant identity plus the session id into a :class:`RunContext` + the service sends on ``/run`` (refreshed per turn). It is consumed ONLY by a tool's + ``call.context`` binding at dispatch, server-side and hidden from the model (see + ``projects/direct-call-tools/run-context.md``). Best-effort: any failure (or an entirely empty + context) returns ``None`` so the run proceeds and the ``runContext`` key is simply omitted.""" + try: + workflow = _run_context_workflow() + trace = _run_context_trace() + session = session_id if session_id and session_id.strip() else None + if workflow is None and trace is None and session is None: + return None + return RunContext(workflow=workflow, trace=trace, session_id=session) + except Exception: # pylint: disable=broad-except + log.warning("agent: failed to capture run context", exc_info=True) + return None + + def record_usage(usage: Optional[Dict[str, Any]]) -> None: """Stamp the agent's token/cost totals onto the active ``/invoke`` workflow span. diff --git a/services/oss/tests/pytest/unit/agent/conftest.py b/services/oss/tests/pytest/unit/agent/conftest.py index fa9d3e7842..2874af8900 100644 --- a/services/oss/tests/pytest/unit/agent/conftest.py +++ b/services/oss/tests/pytest/unit/agent/conftest.py @@ -101,7 +101,15 @@ async def create_sandbox(self) -> _FakeSandbox: return _FakeSandbox() async def create_session( - self, sandbox, config, *, harness, secrets=None, trace=None, session_id=None + self, + sandbox, + config, + *, + harness, + secrets=None, + trace=None, + run_context=None, + session_id=None, ) -> _FakeSession: self.created_configs.append(config) self.created_session_ids.append(session_id) From ca4447761e508e2b0dbcb30efcc3a6d8165d844a Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 28 Jun 2026 01:39:11 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(agent):=20run-context=20review=20round?= =?UTF-8?q?=202=20=E2=80=94=20API-aligned=20workflow=20shape,=20drop=20red?= =?UTF-8?q?undant=20session=5Fid,=20harden=20ctx=20bind?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR #4892 review (design-interfaces skill + CodeRabbit): - runContext.workflow now mirrors the platform's three workflow entities (artifact / variant / revision, each an {id, slug, version} reference) plus is_draft, instead of a flat field pile — matches the API's workflow reference structure and groups by semantic role. - Drop runContext.session_id: it duplicated the top-level sessionId (the runner owns the live id across turns) and would only go stale. Resolves the redundancy and the snapshot-staleness review notes at once. - Infer is_draft in the service: a run pinned to a stored revision is not a draft; a playground inline-config run (no revision reference) is. - Drop latest_revision_id (never populated; a Phase-3b commit-semantics concern). - Harden resolveCtxToken: follow only own, safe keys (reject __proto__/constructor/prototype and inherited keys) so an untrusted $ctx token cannot escape the run-context blob. - assembleBody clears a bound path before filling it, so a missing run-context key leaves the field ABSENT rather than letting a model/static value survive (the model-invisible guarantee). - Tests: nested-shape golden + both wire-contract tests; new poison-pill, missing-key-clears, and deepDelete cases; OSS fake records run_context. - Docs: service-to-agent-runner.md run-context shape updated. Claude-Session: https://claude.ai/code/session_01GYo3UEfvsZpncagqb28Mbc --- .../cross-service/service-to-agent-runner.md | 24 ++++-- sdks/python/agenta/sdk/agents/__init__.py | 2 + sdks/python/agenta/sdk/agents/dtos.py | 73 +++++++++++------- sdks/python/agenta/sdk/agents/wire_models.py | 34 +++++---- .../agents/golden/run_request.pi_core.json | 11 ++- .../pytest/unit/agents/test_wire_contract.py | 33 ++++---- services/agent/src/protocol.ts | 34 ++++++--- services/agent/src/tools/direct.ts | 39 +++++++++- services/agent/tests/unit/tool-direct.test.ts | 75 +++++++++++++++++-- .../agent/tests/unit/wire-contract.test.ts | 16 ++-- services/oss/src/agent/app.py | 7 +- services/oss/src/agent/tracing.py | 65 +++++++++++----- .../oss/tests/pytest/unit/agent/conftest.py | 4 + 13 files changed, 297 insertions(+), 120 deletions(-) diff --git a/docs/design/agent-workflows/interfaces/cross-service/service-to-agent-runner.md b/docs/design/agent-workflows/interfaces/cross-service/service-to-agent-runner.md index 75cf5f14ae..6c2eb9de6c 100644 --- a/docs/design/agent-workflows/interfaces/cross-service/service-to-agent-runner.md +++ b/docs/design/agent-workflows/interfaces/cross-service/service-to-agent-runner.md @@ -68,20 +68,28 @@ group by job: // run context — the run's own identity, refreshed per turn (direct-call tools, Phase 3a) "runContext": { // omitted when the run has no own identity to bind - "workflow": { "variant_id": "...", "variant_name": "...", "revision_id": "...", "version": "..." }, - "trace": { "trace_id": "...", "span_id": "..." }, - "session_id": "..." + "workflow": { + "artifact": { "id": "...", "slug": "..." }, // the workflow + "variant": { "id": "...", "slug": "..." }, // the variant + "revision": { "id": "...", "slug": "...", "version": "..." }, + "is_draft": false // committed revision vs playground draft + }, + "trace": { "trace_id": "...", "span_id": "..." } } } ``` -`runContext` is the run's own context (its trace + variant identity), filled by the service in +`runContext` is the run's own context (its trace + workflow identity), filled by the service in `app.py` from `run_context()` (`tracing.py`) and refreshed each turn. It is consumed ONLY by a tool's `call.context` binding at dispatch: the runner fills the bound request fields from this blob -server-side, hidden from the model (see `runner-to-tool-callback.md`). The inner keys are -deliberately snake_case — they are the binding namespace a `call.context` value (`"$ctx."`) -addresses, not the wire's usual camelCase. Omitted when there is no identity to bind, so a run that -needs no binding stays byte-identical. +server-side, hidden from the model (see `runner-to-tool-callback.md`). `workflow` mirrors the +platform's three workflow entities — `artifact` / `variant` / `revision`, each an `{id, slug, +version}` reference — and `is_draft` says whether the run targets a committed revision or a +playground draft. The conversation id is NOT carried here; it rides the top-level `sessionId`. The +inner keys are deliberately snake_case — they are the binding namespace a `call.context` value +(`"$ctx."`, e.g. `"$ctx.workflow.variant.id"`) addresses, not the wire's usual +camelCase. Omitted when there is no identity to bind, so a run that needs no binding stays +byte-identical. Two splits matter for back-compat. `provider` and `connection` appear only when the model arrives as a structured `model_ref`; a plain string like `"gpt-5.5"` leaves them off so the diff --git a/sdks/python/agenta/sdk/agents/__init__.py b/sdks/python/agenta/sdk/agents/__init__.py index d8446453c5..34c1847ecd 100644 --- a/sdks/python/agenta/sdk/agents/__init__.py +++ b/sdks/python/agenta/sdk/agents/__init__.py @@ -69,6 +69,7 @@ PermissionPolicy, PiAgentConfig, RunContext, + RunContextReference, RunContextTrace, RunContextWorkflow, SandboxPermission, @@ -170,6 +171,7 @@ "ui_message_stream", "TraceContext", "RunContext", + "RunContextReference", "RunContextWorkflow", "RunContextTrace", "ToolCallback", diff --git a/sdks/python/agenta/sdk/agents/dtos.py b/sdks/python/agenta/sdk/agents/dtos.py index 6f8d50c946..a6d2c35c34 100644 --- a/sdks/python/agenta/sdk/agents/dtos.py +++ b/sdks/python/agenta/sdk/agents/dtos.py @@ -376,21 +376,37 @@ def to_wire(self) -> Dict[str, Any]: } -class RunContextWorkflow(BaseModel): - """The running workflow/variant's own identity (direct-call tools, Phase 3a). +class RunContextReference(BaseModel): + """One workflow entity inside :class:`RunContextWorkflow` — the artifact, the variant, or + the revision (direct-call tools, Phase 3a). - Part of the per-turn :class:`RunContext` blob. A self-targeting platform tool binds one of - these into its request body server-side (e.g. ``$ctx.workflow.variant_id`` for "update - myself"), so the model supplies only the payload and cannot retarget a different variant. All - fields optional and best-effort: the service fills what it holds and omits the rest.""" + Mirrors the platform's canonical workflow reference shape (``{id, slug, version}``, the API's + ``Reference``) so the run-context identity reads the same way the rest of the platform names a + workflow entity. ``version`` is meaningful only for a revision; it stays unset on the artifact + and the variant. All fields optional and best-effort.""" - artifact_id: Optional[str] = None - variant_id: Optional[str] = None - variant_name: Optional[str] = None - revision_id: Optional[str] = None + id: Optional[str] = None + slug: Optional[str] = None version: Optional[str] = None + + +class RunContextWorkflow(BaseModel): + """The running workflow's own identity (direct-call tools, Phase 3a). + + Part of the per-turn :class:`RunContext` blob, grouped into the same three entities the + platform uses for a workflow — the ``artifact`` (the workflow), the ``variant``, and the + ``revision`` — each an ``{id, slug, version}`` :class:`RunContextReference`. A self-targeting + platform tool binds one of these into its request body server-side (e.g. + ``$ctx.workflow.variant.id`` for "update myself"), so the model supplies only the payload and + cannot retarget a different variant. ``is_draft`` says whether the run targets a committed + revision (``False``) or an uncommitted playground draft (``True``); it is inferred from whether + a stored revision was referenced. All fields optional and best-effort: the service fills what + it holds and omits the rest.""" + + artifact: Optional[RunContextReference] = None + variant: Optional[RunContextReference] = None + revision: Optional[RunContextReference] = None is_draft: Optional[bool] = None - latest_revision_id: Optional[str] = None class RunContextTrace(BaseModel): @@ -407,29 +423,38 @@ class RunContext(BaseModel): """The run's own context, delivered on ``/run`` and refreshed per turn (direct-call tools, Phase 3a; see ``projects/direct-call-tools/run-context.md``). - The service computes this from the invocation's own trace + variant identity and sends it on + The service computes this from the invocation's own trace + workflow identity and sends it on the ``/run`` request. It is consumed ONLY by a tool's ``call.context`` binding: the runner fills bound request fields from this blob at dispatch, server-side and hidden from the model. The model never reads run context directly. - The inner keys are deliberately snake_case (``workflow.variant_id``, ``trace.trace_id``, - ``session_id``): they are the binding NAMESPACE that a catalog entry's ``$ctx.`` - token addresses, so they match those tokens exactly rather than the wire's camelCase - convention. ``to_wire`` emits only the sub-objects/fields that are set, so a run with no - identity yields an empty blob (and the serializer omits the key entirely).""" + The inner keys are deliberately snake_case (``workflow.variant.id``, ``trace.trace_id``): they + are the binding NAMESPACE that a catalog entry's ``$ctx.`` token addresses, so + they match those tokens exactly rather than the wire's camelCase convention. The conversation + id is NOT carried here — it rides the top-level ``sessionId`` field, and the runner owns the + live id across turns; duplicating it in run context would only let it go stale. ``to_wire`` + emits only the sub-objects/fields that are set, so a run with no identity yields an empty blob + (and the serializer omits the key entirely).""" workflow: Optional[RunContextWorkflow] = None trace: Optional[RunContextTrace] = None - session_id: Optional[str] = None def to_wire(self) -> Dict[str, Any]: out: Dict[str, Any] = {} if self.workflow is not None: - workflow = { - key: value - for key, value in self.workflow.model_dump().items() - if value is not None - } + workflow: Dict[str, Any] = {} + for entity in ("artifact", "variant", "revision"): + reference = getattr(self.workflow, entity) + if reference is not None: + fields = { + key: value + for key, value in reference.model_dump().items() + if value is not None + } + if fields: + workflow[entity] = fields + if self.workflow.is_draft is not None: + workflow["is_draft"] = self.workflow.is_draft if workflow: out["workflow"] = workflow if self.trace is not None: @@ -440,8 +465,6 @@ def to_wire(self) -> Dict[str, Any]: } if trace: out["trace"] = trace - if self.session_id is not None: - out["session_id"] = self.session_id return out diff --git a/sdks/python/agenta/sdk/agents/wire_models.py b/sdks/python/agenta/sdk/agents/wire_models.py index 9a92b52da5..d1c5870c2b 100644 --- a/sdks/python/agenta/sdk/agents/wire_models.py +++ b/sdks/python/agenta/sdk/agents/wire_models.py @@ -122,17 +122,25 @@ class WireToolCallback(_WireModel): authorization: Optional[str] = None -class WireRunContextWorkflow(_WireModel): - """The running workflow/variant identity inside ``runContext`` (mirrors - ``RunContextWorkflow``). The keys stay snake_case on purpose — see ``WireRunContext``.""" +class WireRunContextReference(_WireModel): + """One workflow entity (artifact / variant / revision) inside ``runContext.workflow`` + (mirrors ``RunContextReference``), the platform's ``{id, slug, version}`` reference shape. + The keys stay snake_case on purpose — see ``WireRunContext``.""" - artifact_id: Optional[str] = None - variant_id: Optional[str] = None - variant_name: Optional[str] = None - revision_id: Optional[str] = None + id: Optional[str] = None + slug: Optional[str] = None version: Optional[str] = None + + +class WireRunContextWorkflow(_WireModel): + """The running workflow identity inside ``runContext`` (mirrors ``RunContextWorkflow``), + grouped into the platform's three workflow entities. The keys stay snake_case on purpose — + see ``WireRunContext``.""" + + artifact: Optional[WireRunContextReference] = None + variant: Optional[WireRunContextReference] = None + revision: Optional[WireRunContextReference] = None is_draft: Optional[bool] = None - latest_revision_id: Optional[str] = None class WireRunContextTrace(_WireModel): @@ -148,14 +156,14 @@ class WireRunContext(_WireModel): Consumed only by a tool's ``call.context`` binding at dispatch, server-side and hidden from the model. Unlike the rest of the wire, the INNER keys are snake_case - (``workflow.variant_id`` / ``trace.trace_id`` / ``session_id``): they are the binding - NAMESPACE a catalog entry's ``$ctx.`` token addresses, so they must match those - tokens exactly rather than follow the camelCase wire convention. The top-level field is still - the camelCase ``runContext`` on the request.""" + (``workflow.variant.id`` / ``trace.trace_id``): they are the binding NAMESPACE a catalog + entry's ``$ctx.`` token addresses, so they must match those tokens exactly rather + than follow the camelCase wire convention. The conversation id is NOT carried here — it rides + the top-level camelCase ``sessionId`` field. The top-level field is still the camelCase + ``runContext`` on the request.""" workflow: Optional[WireRunContextWorkflow] = None trace: Optional[WireRunContextTrace] = None - session_id: Optional[str] = None class WireRenderHint(_WireModel): 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 a8c596bb53..0935702a6e 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 @@ -17,16 +17,15 @@ }, "runContext": { "workflow": { - "variant_id": "var_abc", - "variant_name": "weather-agent", - "revision_id": "rev_abc123", - "version": "3" + "artifact": {"id": "wf_abc"}, + "variant": {"id": "var_abc", "slug": "weather-agent"}, + "revision": {"id": "rev_abc123", "version": "3"}, + "is_draft": false }, "trace": { "trace_id": "0af7651916cd43dd8448eb211c80319c", "span_id": "b7ad6b7169203331" - }, - "session_id": "sess-1" + } }, "tools": ["read", "write"], "customTools": [ 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 a9e86cf5f0..bd6803d8c8 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 @@ -28,6 +28,7 @@ PiAgentConfig, ResolvedConnection, RunContext, + RunContextReference, RunContextTrace, RunContextWorkflow, SandboxPermission, @@ -135,21 +136,22 @@ def _pi_payload(): authorization="Access tok-123", capture_content=True, ), - # The run's own context (trace + variant identity), refreshed per turn and consumed only by - # a tool's `call.context` binding at dispatch (direct-call tools, Phase 3a). `to_wire` drops - # the unset workflow fields (artifact_id / is_draft / latest_revision_id). + # The run's own context (trace + workflow identity), refreshed per turn and consumed only by + # a tool's `call.context` binding at dispatch (direct-call tools, Phase 3a). The workflow is + # grouped into the platform's three entities (artifact / variant / revision); `to_wire` + # drops the unset reference fields. The conversation id rides the top-level `session_id`, + # not run context. run_context=RunContext( workflow=RunContextWorkflow( - variant_id="var_abc", - variant_name="weather-agent", - revision_id="rev_abc123", - version="3", + artifact=RunContextReference(id="wf_abc"), + variant=RunContextReference(id="var_abc", slug="weather-agent"), + revision=RunContextReference(id="rev_abc123", version="3"), + is_draft=False, ), trace=RunContextTrace( trace_id="0af7651916cd43dd8448eb211c80319c", span_id="b7ad6b7169203331", ), - session_id="sess-1", ), session_id="sess-1", ) @@ -253,21 +255,22 @@ def test_request_to_wire_pi_matches_golden(golden): "args_into": "data.inputs", } # The run's own context rides as `runContext` (direct-call tools, Phase 3a): the workflow + - # trace identity plus the session id, with snake_case inner keys (the `$ctx.` binding - # namespace) and the unset workflow fields dropped by `to_wire`. + # trace identity, with snake_case inner keys (the `$ctx.` binding namespace), the workflow + # grouped into artifact / variant / revision references, and the unset reference fields dropped + # by `to_wire`. The conversation id is NOT here — it rides the top-level `sessionId`. assert payload["runContext"] == { "workflow": { - "variant_id": "var_abc", - "variant_name": "weather-agent", - "revision_id": "rev_abc123", - "version": "3", + "artifact": {"id": "wf_abc"}, + "variant": {"id": "var_abc", "slug": "weather-agent"}, + "revision": {"id": "rev_abc123", "version": "3"}, + "is_draft": False, }, "trace": { "trace_id": "0af7651916cd43dd8448eb211c80319c", "span_id": "b7ad6b7169203331", }, - "session_id": "sess-1", } + assert "session_id" not in payload["runContext"] # The declared sandbox boundary rides the wire as nested camelCase `sandboxPermission`; # the unset `filesystem` is dropped (declared, not enforced) so it never appears. assert payload["sandboxPermission"] == { diff --git a/services/agent/src/protocol.ts b/services/agent/src/protocol.ts index 4603f4dcb5..c936cf3487 100644 --- a/services/agent/src/protocol.ts +++ b/services/agent/src/protocol.ts @@ -108,33 +108,43 @@ export interface ToolCallbackContext { authorization?: string; } +/** One workflow entity inside `RunContext.workflow`: the platform's `{id, slug, version}` + * reference shape (the API's `Reference`). `version` is meaningful only on the revision. */ +export interface RunContextReference { + id?: string; + slug?: string; + version?: string; +} + /** * The run's own context, delivered on `/run` and refreshed per turn (direct-call tools, Phase 3a; * see `projects/direct-call-tools/run-context.md`). The service computes it from the invocation's - * own trace + variant identity. It is consumed ONLY by a tool's `call.context` binding: the runner + * own trace + workflow identity. It is consumed ONLY by a tool's `call.context` binding: the runner * fills bound request fields from this blob at dispatch, server-side and hidden from the model. The * model never reads run context directly. * - * The keys are deliberately snake_case (`workflow.variant_id`, `trace.trace_id`, `session_id`): - * they are the binding NAMESPACE a `call.context` value (`"$ctx."`) addresses, so they - * match those tokens exactly rather than the rest of the wire's camelCase. Every field is optional - * and best-effort — the service fills what it holds and omits the rest. + * `workflow` mirrors the platform's three workflow entities — the `artifact` (the workflow), the + * `variant`, and the `revision` — so the run's identity reads the same way the rest of the platform + * names a workflow; `is_draft` says whether the run targets a committed revision (`false`) or an + * uncommitted playground draft (`true`). The conversation id is NOT carried here — it rides the + * top-level `sessionId` field, and the runner owns the live id across turns. + * + * The inner keys are deliberately snake_case (`workflow.variant.id`, `trace.trace_id`): they are + * the binding NAMESPACE a `call.context` value (`"$ctx."`) addresses, so they match + * those tokens exactly rather than the rest of the wire's camelCase. Every field is optional and + * best-effort — the service fills what it holds and omits the rest. */ export interface RunContext { workflow?: { - artifact_id?: string; - variant_id?: string; - variant_name?: string; - revision_id?: string; - version?: string; + artifact?: RunContextReference; + variant?: RunContextReference; + revision?: RunContextReference; is_draft?: boolean; - latest_revision_id?: string; }; trace?: { trace_id?: string; span_id?: string; }; - session_id?: string; } /** diff --git a/services/agent/src/tools/direct.ts b/services/agent/src/tools/direct.ts index 42012e6335..a1ccbab12e 100644 --- a/services/agent/src/tools/direct.ts +++ b/services/agent/src/tools/direct.ts @@ -70,6 +70,28 @@ export function deepSet( cursor[parts[parts.length - 1]] = value; } +/** + * Delete the value at a dotted `path` in `target`, if present. Each segment is validated the same + * way `deepSet` does (no empty segments, no prototype-polluting keys), so this can never reach + * through the prototype chain. A path whose parent is missing or not a plain object is a no-op. + */ +export function deepDelete(target: Record, path: string): void { + const parts = path.split("."); + for (const part of parts) { + if (!part) throw new Error(`invalid empty segment in path '${path}'`); + if (UNSAFE_KEYS.has(part)) { + throw new Error(`unsafe path segment '${part}' in '${path}'`); + } + } + let cursor: Record = target; + for (let i = 0; i < parts.length - 1; i++) { + const next = cursor[parts[i]]; + if (!isPlainObject(next)) return; + cursor = next; + } + delete cursor[parts[parts.length - 1]]; +} + /** * Recursively merge `overlay` onto `base`. `overlay` WINS on every conflict (so server-fixed * fields override the model's args); two plain objects at the same key merge, anything else @@ -99,6 +121,10 @@ export function deepMerge( * skipped rather than trusted: it returns `undefined`. A path that does not resolve in the blob * (no `runContext`, a missing sub-object, or a missing key) also returns `undefined`. Only a * non-`undefined` resolved value is bound — `null` is a real value and binds, `undefined` does not. + * + * Traversal follows ONLY own, safe keys: an unsafe segment (`__proto__`/`constructor`/`prototype`) + * or a key inherited from the prototype chain returns `undefined`, so a crafted token can never + * resolve a value outside the run-context blob. */ export function resolveCtxToken( runContext: RunContext | undefined, @@ -112,7 +138,9 @@ export function resolveCtxToken( if (!path) return undefined; let cursor: unknown = runContext; for (const part of path.split(".")) { - if (!part || !isPlainObject(cursor)) return undefined; + if (!part || UNSAFE_KEYS.has(part)) return undefined; + if (!isPlainObject(cursor)) return undefined; + if (!Object.prototype.hasOwnProperty.call(cursor, part)) return undefined; cursor = cursor[part]; } return cursor; @@ -150,11 +178,14 @@ export function assembleBody( // 2. Server-fixed fields win over the model's args. if (call.body) body = deepMerge(body, call.body); // 3. Run-context binding wins over everything (filled LAST). For each [bodyPath, token] in - // call.context, resolve the token against runContext and deep-set it; a token that does not - // resolve is skipped so a missing run-context value never clobbers the body with `undefined`. - // deepSet is prototype-pollution-safe and rejects unsafe path segments. + // call.context, the field is owned by run context alone: first clear whatever the model's args + // or the static `body` put at that path, then deep-set the resolved value. A token that does + // not resolve leaves the field ABSENT (the cleared state), so a missing run-context value can + // never let a model-supplied value survive in a bound field — the model-invisible guarantee. + // deepDelete / deepSet are prototype-pollution-safe and reject unsafe path segments. if (call.context) { for (const [bodyPath, token] of Object.entries(call.context)) { + deepDelete(body, bodyPath); const value = resolveCtxToken(runContext, token); if (value !== undefined) deepSet(body, bodyPath, value); } diff --git a/services/agent/tests/unit/tool-direct.test.ts b/services/agent/tests/unit/tool-direct.test.ts index 2f48a0d97c..fadce276f7 100644 --- a/services/agent/tests/unit/tool-direct.test.ts +++ b/services/agent/tests/unit/tool-direct.test.ts @@ -23,6 +23,7 @@ import { join } from "node:path"; import { assembleBody, + deepDelete, deepMerge, deepSet, directCallUrl, @@ -39,9 +40,12 @@ import type { ResolvedToolSpec, RunContext } from "../../src/protocol.ts"; // A fake run context (direct-call tools, Phase 3a). The keys are the snake_case binding namespace // a `call.context` value (`"$ctx."`) addresses. const RUN_CONTEXT: RunContext = { - workflow: { variant_id: "own-variant", revision_id: "rev_self" }, + workflow: { + variant: { id: "own-variant" }, + revision: { id: "rev_self" }, + is_draft: false, + }, trace: { trace_id: "trace-self", span_id: "span-self" }, - session_id: "sess-1", }; const ENDPOINT = "https://agenta.example/api/tools/call"; @@ -181,13 +185,41 @@ describe("assembleBody context binding", () => { const call: DirectCall = { method: "POST", path: "/api/x", - context: { latest: "$ctx.workflow.latest_revision_id" }, // not in RUN_CONTEXT + context: { latest: "$ctx.workflow.revision.missing" }, // not in RUN_CONTEXT }; const body = assembleBody(call, { a: 1 }, RUN_CONTEXT); assert.deepEqual(body, { a: 1 }); assert.ok(!("latest" in body)); }); + it("clears a colliding model arg when the bound key is missing (model-invisible)", () => { + const call: DirectCall = { + method: "POST", + path: "/api/x", + // The bound field is owned by run context; a missing key must leave it ABSENT, never let the + // model's value survive. + context: { workflow_variant_id: "$ctx.workflow.revision.missing" }, + }; + const body = assembleBody( + call, + { workflow_variant_id: "someone-elses", keep: 1 }, + RUN_CONTEXT, + ); + assert.ok(!("workflow_variant_id" in body)); + assert.equal(body.keep, 1); + }); + + it("clears a colliding static body field when the bound key is missing", () => { + const call: DirectCall = { + method: "POST", + path: "/api/x", + body: { trace_id: "from-body" }, + context: { trace_id: "$ctx.trace.missing" }, + }; + const body = assembleBody(call, {}, RUN_CONTEXT); + assert.ok(!("trace_id" in body)); + }); + it("handles an absent run context safely (no binding applied)", () => { const call: DirectCall = { method: "POST", @@ -202,7 +234,7 @@ describe("assembleBody context binding", () => { const call: DirectCall = { method: "POST", path: "/api/workflows/revisions/commit", - context: { workflow_variant_id: "$ctx.workflow.variant_id" }, + context: { workflow_variant_id: "$ctx.workflow.variant.id" }, }; const body = assembleBody( call, @@ -252,16 +284,34 @@ describe("assembleBody context binding", () => { describe("resolveCtxToken", () => { it("navigates a dotted path against the run context", () => { assert.equal( - resolveCtxToken(RUN_CONTEXT, "$ctx.workflow.variant_id"), + resolveCtxToken(RUN_CONTEXT, "$ctx.workflow.variant.id"), "own-variant", ); + assert.equal(resolveCtxToken(RUN_CONTEXT, "$ctx.workflow.is_draft"), false); }); it("returns undefined for a missing key, a malformed token, or no run context", () => { - assert.equal(resolveCtxToken(RUN_CONTEXT, "$ctx.workflow.missing"), undefined); - assert.equal(resolveCtxToken(RUN_CONTEXT, "workflow.variant_id"), undefined); + assert.equal( + resolveCtxToken(RUN_CONTEXT, "$ctx.workflow.variant.missing"), + undefined, + ); + assert.equal(resolveCtxToken(RUN_CONTEXT, "workflow.variant.id"), undefined); assert.equal(resolveCtxToken(undefined, "$ctx.trace.trace_id"), undefined); }); + + it("rejects unsafe / inherited segments in the token (prototype-safe at the source)", () => { + // The token is untrusted: it must never walk the prototype chain out of the run-context blob, + // even though `__proto__`/`constructor` resolve on any object. + assert.equal(resolveCtxToken(RUN_CONTEXT, "$ctx.workflow.__proto__"), undefined); + assert.equal( + resolveCtxToken(RUN_CONTEXT, "$ctx.__proto__.polluted"), + undefined, + ); + assert.equal( + resolveCtxToken(RUN_CONTEXT, "$ctx.constructor.name"), + undefined, + ); + }); }); // --------------------------------------------------------------------------- @@ -286,6 +336,15 @@ describe("deepSet / deepMerge", () => { assert.deepEqual(out, { a: { x: 1, y: 2 }, keep: 9 }); assert.deepEqual(base, { a: { x: 1 }, keep: 1 }, "base is untouched"); }); + + it("deepDelete removes a nested leaf and no-ops a missing parent, proto-safely", () => { + const target: Record = { a: { b: 1, c: 2 }, keep: 3 }; + deepDelete(target, "a.b"); + assert.deepEqual(target, { a: { c: 2 }, keep: 3 }); + deepDelete(target, "x.y.z"); // missing parent -> no-op, no throw + assert.deepEqual(target, { a: { c: 2 }, keep: 3 }); + assert.throws(() => deepDelete(target, "__proto__.polluted"), /unsafe path segment/); + }); }); // --------------------------------------------------------------------------- @@ -476,7 +535,7 @@ describe("startToolRelay direct branch (host makes the call for the sandbox)", ( call: { method: "POST", path: "/api/workflows/revisions/commit", - context: { workflow_variant_id: "$ctx.workflow.variant_id" }, + context: { workflow_variant_id: "$ctx.workflow.variant.id" }, }, }; const res = await relayOnce( diff --git a/services/agent/tests/unit/wire-contract.test.ts b/services/agent/tests/unit/wire-contract.test.ts index 0cd100e203..46d9993910 100644 --- a/services/agent/tests/unit/wire-contract.test.ts +++ b/services/agent/tests/unit/wire-contract.test.ts @@ -110,13 +110,17 @@ describe("wire contract: requests (vs Python golden)", () => { references: { workflow_revision: { id: "rev_abc123" } }, }); // The run's own context (direct-call tools, Phase 3a) reaches the runner as `runContext`, with - // snake_case inner keys (the `$ctx.` binding namespace). The runner fills a tool's - // `call.context` from this blob at dispatch (see tools/direct.ts `assembleBody`); the model - // never reads it. - assert.equal(req.runContext!.workflow!.variant_id, "var_abc"); - assert.equal(req.runContext!.workflow!.revision_id, "rev_abc123"); + // snake_case inner keys (the `$ctx.` binding namespace) and the workflow grouped into the + // platform's artifact / variant / revision entities. The runner fills a tool's `call.context` + // from this blob at dispatch (see tools/direct.ts `assembleBody`); the model never reads it. + assert.equal(req.runContext!.workflow!.variant!.id, "var_abc"); + assert.equal(req.runContext!.workflow!.variant!.slug, "weather-agent"); + assert.equal(req.runContext!.workflow!.revision!.id, "rev_abc123"); + assert.equal(req.runContext!.workflow!.is_draft, false); assert.equal(req.runContext!.trace!.trace_id, "0af7651916cd43dd8448eb211c80319c"); - assert.equal(req.runContext!.session_id, "sess-1"); + // The conversation id is NOT duplicated in run context; it rides the top-level `sessionId`. + assert.equal((req.runContext as Record).session_id, undefined); + assert.equal(req.sessionId, "sess-1"); // Pi exposes the prompt overrides. assert.equal(req.systemPrompt, "You are Pi."); assert.equal(req.appendSystemPrompt, "Be terse."); diff --git a/services/oss/src/agent/app.py b/services/oss/src/agent/app.py index 23773ff09d..22c4ae2346 100644 --- a/services/oss/src/agent/app.py +++ b/services/oss/src/agent/app.py @@ -249,9 +249,10 @@ async def _agent( resolved_connection=resolved_connection, permission_policy=agent_config.permission_policy, trace=trace_context(), - # The run's own context (trace + variant identity), refreshed each turn and consumed only - # by a tool's `call.context` binding at dispatch (direct-call tools, Phase 3a). - run_context=run_context(session_id=session_id), + # The run's own context (trace + workflow identity), refreshed each turn and consumed only + # by a tool's `call.context` binding at dispatch (direct-call tools, Phase 3a). The + # conversation id is threaded separately as `session_id` below, not duplicated in here. + run_context=run_context(), session_id=session_id, builtin_names=resolved_tools.builtin_names, tool_specs=resolved_tools.tool_specs, diff --git a/services/oss/src/agent/tracing.py b/services/oss/src/agent/tracing.py index 71fe8e5deb..c0841eb6c8 100644 --- a/services/oss/src/agent/tracing.py +++ b/services/oss/src/agent/tracing.py @@ -18,6 +18,7 @@ from agenta.sdk.agents import ( RunContext, + RunContextReference, RunContextTrace, RunContextWorkflow, TraceContext, @@ -86,24 +87,48 @@ def _reference_field( return str(value) if value is not None else None +def _run_context_reference( + references: Optional[Dict[str, Any]], key: str, *, with_version: bool = False +) -> Optional[RunContextReference]: + """Build one ``{id, slug, version}`` reference for a workflow entity, or ``None`` when empty. + + ``with_version`` is set only for the revision; the artifact and the variant carry no version + in the tracing references.""" + reference = RunContextReference( + id=_reference_field(references, key, "id"), + slug=_reference_field(references, key, "slug"), + version=_reference_field(references, key, "version") if with_version else None, + ) + if reference.model_dump(exclude_none=True): + return reference + return None + + def _run_context_workflow() -> Optional[RunContextWorkflow]: - """The running workflow/variant identity, best-effort, from the resolved tracing references. + """The running workflow identity, best-effort, from the resolved tracing references. The references land on the tracing context after the resolver hydrates a stored - variant/environment reference (``workflow`` / ``workflow_variant`` / ``workflow_revision``). - A playground run of an unsaved inline config carries no references, so this returns ``None`` - and the binding simply has no value — every field is optional.""" + variant/environment reference, grouped into the platform's three workflow entities — the + artifact (``workflow``), the variant (``workflow_variant``), and the revision + (``workflow_revision``). A playground run of an unsaved inline config carries no revision + reference, so ``is_draft`` is ``True``; a run pinned to a stored revision is not a draft. A run + with no workflow identity at all returns ``None`` and the binding simply has no value — every + field is optional.""" references = TracingContext.get().references + revision = _run_context_reference( + references, "workflow_revision", with_version=True + ) workflow = RunContextWorkflow( - artifact_id=_reference_field(references, "workflow", "id"), - variant_id=_reference_field(references, "workflow_variant", "id"), - variant_name=_reference_field(references, "workflow_variant", "slug"), - revision_id=_reference_field(references, "workflow_revision", "id"), - version=_reference_field(references, "workflow_revision", "version"), + artifact=_run_context_reference(references, "workflow"), + variant=_run_context_reference(references, "workflow_variant"), + revision=revision, ) - if workflow.model_dump(exclude_none=True): - return workflow - return None + if not workflow.model_dump(exclude_none=True): + return None + # A run is a draft when it carries some workflow identity but no committed revision (the + # playground inline-config case); a run pinned to a stored revision is not a draft. + workflow.is_draft = revision is None + return workflow def _run_context_trace() -> Optional[RunContextTrace]: @@ -120,21 +145,21 @@ def _run_context_trace() -> Optional[RunContextTrace]: ) -def run_context(session_id: Optional[str] = None) -> Optional[RunContext]: +def run_context() -> Optional[RunContext]: """Capture the run's own context for tool ``call.context`` binding (direct-call tools, Phase 3a). - Assembles the run's own trace + variant identity plus the session id into a :class:`RunContext` - the service sends on ``/run`` (refreshed per turn). It is consumed ONLY by a tool's - ``call.context`` binding at dispatch, server-side and hidden from the model (see - ``projects/direct-call-tools/run-context.md``). Best-effort: any failure (or an entirely empty + Assembles the run's own trace + workflow identity into a :class:`RunContext` the service sends + on ``/run`` (refreshed per turn). It is consumed ONLY by a tool's ``call.context`` binding at + dispatch, server-side and hidden from the model (see + ``projects/direct-call-tools/run-context.md``). The conversation id is not part of this blob — + it rides the top-level ``sessionId`` field. Best-effort: any failure (or an entirely empty context) returns ``None`` so the run proceeds and the ``runContext`` key is simply omitted.""" try: workflow = _run_context_workflow() trace = _run_context_trace() - session = session_id if session_id and session_id.strip() else None - if workflow is None and trace is None and session is None: + if workflow is None and trace is None: return None - return RunContext(workflow=workflow, trace=trace, session_id=session) + return RunContext(workflow=workflow, trace=trace) except Exception: # pylint: disable=broad-except log.warning("agent: failed to capture run context", exc_info=True) return None diff --git a/services/oss/tests/pytest/unit/agent/conftest.py b/services/oss/tests/pytest/unit/agent/conftest.py index 2874af8900..fb7f0db2af 100644 --- a/services/oss/tests/pytest/unit/agent/conftest.py +++ b/services/oss/tests/pytest/unit/agent/conftest.py @@ -90,6 +90,9 @@ def __init__( # This is the credential channel; a Slice 3 test asserts exactly one connection's env # reaches the boundary (or nothing, for a runtime_provided / unconfigured run). self.created_secrets: list[Optional[Mapping[str, str]]] = [] + # The run context threaded into each session (direct-call tools, Phase 3a), in call order, + # so a test can assert the service-side run-context population reaches the boundary. + self.created_run_contexts: list = [] async def setup(self) -> None: self.setup_calls += 1 @@ -114,6 +117,7 @@ async def create_session( self.created_configs.append(config) self.created_session_ids.append(session_id) self.created_secrets.append(secrets) + self.created_run_contexts.append(run_context) return _FakeSession(self._result)