Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand All @@ -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.<dotted.path>"` 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

Expand All @@ -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.<key>` 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,33 @@ 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": {
"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 + 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`). `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.<dotted.path>"`, 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
wire stays byte-identical to the old shape. And `secrets` is the only vault-key channel on
Expand Down
8 changes: 8 additions & 0 deletions sdks/python/agenta/sdk/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@
NetworkEgress,
PermissionPolicy,
PiAgentConfig,
RunContext,
RunContextReference,
RunContextTrace,
RunContextWorkflow,
SandboxPermission,
SessionConfig,
ToolCallback,
Expand Down Expand Up @@ -166,6 +170,10 @@
"to_ui_message",
"ui_message_stream",
"TraceContext",
"RunContext",
"RunContextReference",
"RunContextWorkflow",
"RunContextTrace",
"ToolCallback",
"PermissionPolicy",
"SandboxPermission",
Expand Down
3 changes: 2 additions & 1 deletion sdks/python/agenta/sdk/agents/adapters/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need to have a redundant session_id if it's alreday in the run context?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 The AI agent says: Good catch. I removed session_id from run context. It duplicated the top-level sessionId, and the runner owns the live conversation id across turns, so a copy frozen inside run context could only drift out of date (CodeRabbit flagged the same staleness on SandboxAgentSession). create_session keeps the single session_id parameter, which routes the conversation; run context now carries only the trace and the workflow identity. New tip ca4447761e.

) -> Session:
raise NotImplementedError(
Expand Down
6 changes: 6 additions & 0 deletions sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
HarnessAgentConfig,
HarnessType,
Message,
RunContext,
TraceContext,
)
from ..interfaces import Backend, Sandbox, Session
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
)

Expand Down Expand Up @@ -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):
Expand All @@ -165,6 +170,7 @@ async def create_session(
harness=harness,
secrets=secrets,
trace=trace,
run_context=run_context,
session_id=session_id,
)

Expand Down
96 changes: 96 additions & 0 deletions sdks/python/agenta/sdk/agents/dtos.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,98 @@ def to_wire(self) -> Dict[str, Any]:
}


class RunContextReference(BaseModel):
"""One workflow entity inside :class:`RunContextWorkflow` — the artifact, the variant, or
the revision (direct-call tools, Phase 3a).

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."""

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


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 + 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``): they
are the binding NAMESPACE that a catalog entry's ``$ctx.<dotted.path>`` 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

def to_wire(self) -> Dict[str, Any]:
out: Dict[str, Any] = {}
if self.workflow 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:
trace = {
key: value
for key, value in self.trace.model_dump().items()
if value is not None
}
if trace:
out["trace"] = trace
return out


# ---------------------------------------------------------------------------
# Run result
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -801,6 +893,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,
Expand Down
3 changes: 3 additions & 0 deletions sdks/python/agenta/sdk/agents/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
HarnessAgentConfig,
HarnessType,
Message,
RunContext,
SessionConfig,
TraceContext,
)
Expand Down Expand Up @@ -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``."""
Expand Down Expand Up @@ -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,
)

Expand Down
14 changes: 13 additions & 1 deletion sdks/python/agenta/sdk/agents/utils/wire.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
HarnessCapabilities,
HarnessType,
Message,
RunContext,
TraceContext,
)

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down
Loading
Loading