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,6 +67,22 @@ 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)

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`;
mirrored in `wire_models.py` and pinned by the golden `/run` fixtures). When present it tells the
runner to call an Agenta endpoint **directly** — reusing the run's `toolCallback.authorization`,
with `path` an absolute path from the Agenta origin (derived from `toolCallback.endpoint`) — rather
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`.

## Owned by

- `services/agent/src/tools/callback.ts`: the runner caller (sends the envelope, reads `content`).
Expand All @@ -82,6 +98,10 @@ resolver put on it. Only the router's prefix dispatch is aware of the grammars.
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.
- **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 @@ -53,7 +53,7 @@ group by job:

// tools + skills (see runner-to-tool-callback.md, runner-to-mcp-server.md)
"tools": [ "read", "edit" ], // built-in tool names
"customTools": [ /* ResolvedToolSpec[] */ ],
"customTools": [ /* ResolvedToolSpec[]; a callback spec carries `call_ref` (gateway) XOR an optional `call` direct-call descriptor — plumbing only, see runner-to-tool-callback.md */ ],
"toolCallback": { "endpoint": "...", "authorization": "..." }, // required if customTools set
"mcpServers": [ /* McpServerConfig[] */ ],
"skills": [ /* inline skill packages */ ],
Expand Down
2 changes: 2 additions & 0 deletions sdks/python/agenta/sdk/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@
MissingToolSecretError,
ReferenceToolConfig,
ResolvedToolSet,
ToolCall,
ToolConfig,
ToolConfigError,
ToolConfigurationError,
Expand Down Expand Up @@ -180,6 +181,7 @@
"CallbackToolSpec",
"CodeToolSpec",
"ClientToolSpec",
"ToolCall",
"ResolvedToolSet",
"GatewayToolResolution",
"ToolResolver",
Expand Down
2 changes: 2 additions & 0 deletions sdks/python/agenta/sdk/agents/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
MissingSecretPolicy,
ReferenceToolConfig,
ResolvedToolSet,
ToolCall,
ToolCallback,
ToolConfig,
ToolConfigBase,
Expand All @@ -49,6 +50,7 @@
"CallbackToolSpec",
"CodeToolSpec",
"ClientToolSpec",
"ToolCall",
"ToolCallback",
"ResolvedToolSet",
"GatewayToolResolution",
Expand Down
58 changes: 56 additions & 2 deletions sdks/python/agenta/sdk/agents/tools/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,12 +280,66 @@ def to_wire(self) -> Dict[str, Any]:
return wire


class ToolCall(BaseModel):

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: This is the call descriptor model on a resolved callback spec (call XOR call_ref). It is additive Phase 1 plumbing; nothing emits or dispatches it yet. Worth checking the shape against interfaces.md §1.

"""The direct-call descriptor on a resolved callback tool (direct-call tools, Phase 1).

When a resolved :class:`CallbackToolSpec` carries ``call`` the runner dispatches the tool by
calling this Agenta endpoint DIRECTLY (reusing the run's single ``toolCallback.authorization``)
instead of routing through the shared ``/tools/call`` gateway. A spec carries ``call`` (direct)
XOR ``call_ref`` (gateway), never both.

- ``method`` is restricted to ``GET`` / ``POST`` (the runner is a constrained dispatcher,
never an arbitrary HTTP client).
- ``path`` is an absolute path from the Agenta ORIGIN; the runner derives that origin from the
run's ``toolCallback.endpoint``, so a tool can never reach a non-Agenta host.
- ``body`` holds static, server-fixed fields baked at resolve time (e.g. a reference tool's
resolved ``workflow_revision`` id).
- ``context`` maps a dotted body path to a ``"$ctx.<run-context-key>"`` token the runner fills
from the run's context at dispatch (e.g. a self-targeting variant/trace id).
- ``args_into`` is the dotted path where the model's arguments are placed (absent = the body
root).

Plumbing only in this phase: the field rides the wire and round-trips, but no resolver emits it
and no dispatch reads it yet (see the direct-call-tools project plan, Phase 1). The body-merge
rules (args -> ``body`` -> ``context``, context last) and SSRF guardrails land in later phases.
"""

model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True)

method: Literal["GET", "POST"]
path: str = Field(min_length=1)
body: Optional[Dict[str, Any]] = None
context: Optional[Dict[str, str]] = None
args_into: Optional[str] = None


class CallbackToolSpec(ToolSpecBase):
kind: Literal["callback"] = "callback"
call_ref: str = Field(
# Gateway target (the slug the runner sends back to ``/tools/call``). Optional now that a
# callback spec can instead carry a direct ``call`` descriptor; a spec carries ``call_ref``
# (gateway) XOR ``call`` (direct). Every producer today still sets ``call_ref``, so existing
# specs and the golden wire are unchanged.
call_ref: Optional[str] = Field(
default=None,
validation_alias=AliasChoices("call_ref", "callRef"),
serialization_alias="callRef",
)
# Direct-call descriptor (direct-call tools, Phase 1). When set the runner calls the endpoint
# directly instead of the gateway. Plumbing only: nothing emits or dispatches it yet.
call: Optional[ToolCall] = None

@model_validator(mode="after")
def _check_call_target(self) -> "CallbackToolSpec":
# A callback tool must have exactly one place to call: the gateway slug (``call_ref``) or
# the direct descriptor (``call``). This encodes the design's ``call`` XOR ``call_ref``
# rule and preserves the prior invariant that a callback spec always has a target (back
# when ``call_ref`` was required).
if (self.call_ref is None) == (self.call is None):
raise ValueError(
"a callback tool spec must carry exactly one of `call_ref` (gateway) "
"or `call` (direct)"
)
return self


class CodeToolSpec(ToolSpecBase):
Expand Down Expand Up @@ -313,7 +367,7 @@ def coerce_tool_spec(value: Any) -> ToolSpec:
raise TypeError("tool spec must be a mapping")
data = dict(value)
if not data.get("kind"):
if data.get("callRef") or data.get("call_ref"):
if data.get("callRef") or data.get("call_ref") or data.get("call"):
data["kind"] = "callback"
elif data.get("code") is not None:
data["kind"] = "code"
Expand Down
25 changes: 23 additions & 2 deletions sdks/python/agenta/sdk/agents/wire_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,20 +129,41 @@ class WireRenderHint(_WireModel):
component: Optional[str] = None


class WireToolCall(_WireModel):
"""The direct-call descriptor on a resolved tool (direct-call tools, Phase 1).

Present on a callback tool instead of ``callRef`` when the runner should call an Agenta
endpoint DIRECTLY (``call`` XOR ``callRef``). ``path`` is an absolute path from the Agenta
origin; ``body`` are static server-fixed fields; ``context`` maps a dotted body path to a
``"$ctx.<key>"`` token the runner fills from the run context; ``args_into`` is the dotted path
where the model's arguments are placed. All fields optional on the wire, matching the
contract's implicitly-all-optional convention. Plumbing only in this phase: it rides the wire
but nothing emits or dispatches it yet.
"""

method: Optional[str] = None
path: Optional[str] = None
Comment thread
coderabbitai[bot] marked this conversation as resolved.
body: Optional[Dict[str, Any]] = None
context: Optional[Dict[str, str]] = None
args_into: Optional[str] = None


class WireResolvedToolSpec(_WireModel):
"""A resolved tool the runner delivers to the harness (the three-axis tool surface).

``kind`` is the executor axis (``callback`` / ``code`` / ``client`` / ``builtin``);
``needsApproval`` / ``render`` are the orthogonal axes; ``callRef`` / ``runtime`` / ``code``
/ ``env`` are executor-specific. Extra fields are allowed so an executor variant the schema
has not enumerated still validates.
/ ``env`` are executor-specific. ``call`` is the direct-call descriptor a callback tool carries
instead of ``callRef`` (direct-call tools, Phase 1). Extra fields are allowed so an executor
variant the schema has not enumerated still validates.
"""

name: str
description: Optional[str] = None
input_schema: Optional[Dict[str, Any]] = Field(default=None, alias="inputSchema")
kind: Optional[str] = None
call_ref: Optional[str] = Field(default=None, alias="callRef")
call: Optional[WireToolCall] = None
runtime: Optional[str] = None
code: Optional[str] = None
env: Optional[Dict[str, str]] = None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@
"kind": "callback",
"readOnly": true,
"permission": "allow"
},
{
"name": "get_weather",
"description": "Look up weather for a city",
"inputSchema": {"type": "object", "properties": {"city": {"type": "string"}}},
"kind": "callback",
"call": {
"method": "POST",
"path": "/api/workflows/invoke",
"body": {"references": {"workflow_revision": {"id": "rev_abc123"}}},
"args_into": "data.inputs"
}
}
],
"toolCallback": {
Expand Down
30 changes: 29 additions & 1 deletion sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,21 @@
"kind": "callback",
"readOnly": True,
}
# A DIRECT-CALL tool (direct-call tools, Phase 1): a callback spec that carries a `call`
# descriptor instead of a `callRef` (the `call` XOR `callRef` rule). Plumbing only — nothing
# emits or dispatches it yet; the golden pins the wire shape so the optional field round-trips.
_DIRECT_CALL_TOOL = {
"name": "get_weather",
"description": "Look up weather for a city",
"inputSchema": {"type": "object", "properties": {"city": {"type": "string"}}},
"kind": "callback",
"call": {
"method": "POST",
"path": "/api/workflows/invoke",
"body": {"references": {"workflow_revision": {"id": "rev_abc123"}}},
"args_into": "data.inputs",
},
}
_CALLBACK = ToolCallback(
endpoint="https://api.example/tools/call", authorization="Access tok-123"
)
Expand All @@ -97,7 +112,7 @@ def _pi_payload():
agents_md="You are a helpful assistant.",
model="openai-codex/gpt-5.5",
builtin_tools=["read", "write"],
custom_tools=[dict(_CUSTOM_TOOL)],
custom_tools=[dict(_CUSTOM_TOOL), dict(_DIRECT_CALL_TOOL)],
tool_callback=_CALLBACK,
skills=[dict(_SKILL)],
sandbox_permission=SandboxPermission(network={"mode": "off"}),
Expand Down Expand Up @@ -204,6 +219,19 @@ def test_request_to_wire_pi_matches_golden(golden):
assert payload["customTools"][0]["readOnly"] is True
# No explicit author permission + read_only=True -> derived `allow` rides the wire.
assert payload["customTools"][0]["permission"] == "allow"
# The direct-call tool rides the wire carrying its `call` descriptor and NO `callRef`
# (the `call` XOR `callRef` rule). The descriptor keeps method/path/body and the snake_case
# `args_into`; `context` is unset so it is omitted. Plumbing only — the runner forwards it
# opaquely in Phase 1.
direct = payload["customTools"][1]
assert direct["kind"] == "callback"
assert "callRef" not in direct
assert direct["call"] == {
"method": "POST",
"path": "/api/workflows/invoke",
"body": {"references": {"workflow_revision": {"id": "rev_abc123"}}},
"args_into": "data.inputs",
}
# 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"] == {
Expand Down
45 changes: 45 additions & 0 deletions sdks/python/oss/tests/pytest/unit/agents/tools/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
CodeToolSpec,
ReferenceToolConfig,
)
from agenta.sdk.agents.tools.models import coerce_tool_spec


def test_reference_tool_variant_call_ref_grammar():
Expand Down Expand Up @@ -98,6 +99,50 @@ def test_callback_spec_has_stable_typed_contract():
)
assert spec.to_wire()["kind"] == "callback"
assert spec.to_wire()["callRef"] == "tools.composio.github.GET_USER.c1"
# A gateway spec carries no `call` descriptor.
assert "call" not in spec.to_wire()


def test_callback_spec_direct_call_round_trips_on_the_wire():
# A direct-call callback spec carries a `call` descriptor instead of `call_ref` (the
# `call` XOR `call_ref` rule). The descriptor round-trips through the wire keeping its
# method/path/body and the snake_case `args_into`; the unset `context` is omitted.
spec = CallbackToolSpec(
name="get_weather",
description="Look up weather for a city",
input_schema={"type": "object", "properties": {"city": {"type": "string"}}},
call={
"method": "POST",
"path": "/api/workflows/invoke",
"body": {"references": {"workflow_revision": {"id": "rev_abc123"}}},
"args_into": "data.inputs",
},
)
wire = spec.to_wire()
assert wire["kind"] == "callback"
assert "callRef" not in wire
assert wire["call"] == {
"method": "POST",
"path": "/api/workflows/invoke",
"body": {"references": {"workflow_revision": {"id": "rev_abc123"}}},
"args_into": "data.inputs",
}
# The wire dict round-trips back into an equal spec via the coercion path.
assert coerce_tool_spec(wire) == spec


def test_callback_spec_requires_exactly_one_call_target():
# Neither `call_ref` nor `call` -> invalid (a callback tool must have a target).
with pytest.raises(ValidationError):
CallbackToolSpec(name="t", description="t")
# Both `call_ref` and `call` -> invalid (the XOR rule).
with pytest.raises(ValidationError):
CallbackToolSpec(
name="t",
description="t",
call_ref="tools.composio.x.Y.c1",
call={"method": "GET", "path": "/api/ping"},
)


def test_secret_values_are_hidden_from_repr():
Expand Down
27 changes: 23 additions & 4 deletions services/agent/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,16 +56,35 @@ export interface TraceContext {
* - `needsApproval`: gate the call on a human yes/no (mechanics owned by the run-event layer).
* - `render`: a generative-UI hint (see `RenderHint`).
*
* `callRef` is set for `callback` tools (the slug the bridge sends back to /tools/call);
* `runtime`/`code`/`env` for `code` tools. The Composio key and connection auth stay
* server-side.
* `callRef` is set for `callback` (gateway) tools (the slug the bridge sends back to
* /tools/call); `call` is set for direct-call callback tools (reference / platform), which the
* runner calls directly instead of routing through /tools/call. A callback spec carries `call`
* XOR `callRef`. `runtime`/`code`/`env` for `code` tools. The Composio key and connection auth
* stay server-side.
*/
export interface ResolvedToolSpec {
name: string;
description?: string;
inputSchema?: Record<string, unknown> | null;
/** Set for `callback` (gateway) tools only; absent for `code` / `client`. */
/** Set for gateway `callback` tools (routes through /tools/call); absent for `code` / `client`, and absent when `call` is set. */
callRef?: string;
/**
* Direct-call descriptor (direct-call tools, Phase 1). When set, the runner calls this Agenta
* endpoint DIRECTLY (reusing the run's `toolCallback.authorization`) instead of routing through
* `/tools/call`. `path` is an absolute path from the Agenta origin (the runner derives the
* origin from `toolCallback.endpoint`, so a tool can never reach a non-Agenta host); `body` are
* static server-fixed fields baked at resolve time; `context` maps a dotted body path to a
* `"$ctx.<key>"` token the runner fills from the run context at dispatch; `args_into` is the
* dotted path where the model's arguments are placed (absent = the body root). A spec carries
* `call` XOR `callRef`. Plumbing only here: nothing emits or dispatches it yet.
*/
call?: {

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: The same call descriptor, mirrored on the TS wire. args_into rides as snake_case; the other sub-fields are lowercase. Confirm it matches the SDK ToolCall model.

method: "GET" | "POST";
path: string;
body?: Record<string, unknown>;
context?: Record<string, string>;
args_into?: string;
};
kind?: "callback" | "code" | "client";
runtime?: "python" | "node";
code?: string;
Expand Down
12 changes: 12 additions & 0 deletions services/agent/tests/unit/wire-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,18 @@ describe("wire contract: requests (vs Python golden)", () => {
assert.equal(tool.readOnly, true);
// The Layer-3 permission (derived `allow` from read-only) reaches the runner.
assert.equal(tool.permission, "allow");
// The direct-call tool (direct-call tools, Phase 1) reaches the runner carrying its `call`
// descriptor and NO `callRef` (the `call` XOR `callRef` rule). Plumbing only here: the runner
// forwards it opaquely; no dispatch branch reads it yet.
const direct = req.customTools![1];
assert.equal(direct.kind, "callback");
assert.equal(direct.callRef, undefined);
assert.equal(direct.call!.method, "POST");
assert.equal(direct.call!.path, "/api/workflows/invoke");
assert.equal(direct.call!.args_into, "data.inputs");
assert.deepEqual(direct.call!.body, {
references: { workflow_revision: { id: "rev_abc123" } },
});
// Pi exposes the prompt overrides.
assert.equal(req.systemPrompt, "You are Pi.");
assert.equal(req.appendSystemPrompt, "Be terse.");
Expand Down
Loading