From 92bab8285b77b54a0e512049c48d50dc8eaf1a07 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 26 Jun 2026 00:24:56 +0200 Subject: [PATCH 1/3] feat(agent): reference a workflow as a tool via @ag.reference Add the @ag.reference syntax so an agent config tools[] entry can point at a workflow that runs server-side as a tool, the reference half of the embedref-tools two-syntax model (#4837): - @ag.reference (new): keep the reference. coerce_tool_config parses the kept marker into a ReferenceToolConfig (type "reference"); resolve_tools maps it to a CallbackToolSpec (call_ref "workflow.{slug}[.{version}]") + the shared ToolCallback. - @ag.embed (existing): inline the referenced value into a concrete client tool. The generic resolver stays tool-agnostic: it only inlines @ag.embed and leaves @ag.reference in place (AG_REFERENCE_KEY guard in the API embed finders). All tool-specific mapping lives in resolve_tools, via a new WorkflowToolResolver port + AgentaWorkflowToolResolver platform adapter. /tools/call routes a workflow.* call_ref to WorkflowsService.invoke_workflow (server-side execute; the workflow's secrets stay server-side, the gateway tool safety shape). No new runner kind, no wire change. Claude-Session: https://claude.ai/code/session_01GYo3UEfvsZpncagqb28Mbc --- api/entrypoints/routers.py | 1 + api/oss/src/apis/fastapi/tools/router.py | 127 +++++++++++++ api/oss/src/core/embeds/utils.py | 16 ++ .../tests/pytest/unit/embeds/test_utils.py | 67 +++++++ .../unit/tools/test_workflow_tool_call.py | 177 ++++++++++++++++++ .../agent-workflows/documentation/tools.md | 39 ++++ .../cross-service/runner-to-tool-callback.md | 21 ++- .../in-service/tool-models-and-resolution.md | 30 ++- .../public-edge/agent-config-schema.md | 29 ++- sdks/python/agenta/sdk/agents/__init__.py | 4 + .../agenta/sdk/agents/platform/__init__.py | 2 + .../agenta/sdk/agents/platform/resolve.py | 14 +- .../agenta/sdk/agents/platform/workflow.py | 90 +++++++++ .../agenta/sdk/agents/tools/__init__.py | 7 +- sdks/python/agenta/sdk/agents/tools/compat.py | 57 +++++- .../agenta/sdk/agents/tools/interfaces.py | 14 +- sdks/python/agenta/sdk/agents/tools/models.py | 43 +++++ .../agenta/sdk/agents/tools/resolver.py | 30 ++- sdks/python/agenta/sdk/utils/types.py | 63 ++++++- .../agents/platform/test_workflow_resolver.py | 92 +++++++++ .../pytest/unit/agents/tools/test_models.py | 16 ++ .../pytest/unit/agents/tools/test_parsing.py | 70 +++++++ .../pytest/unit/agents/tools/test_resolver.py | 91 +++++++++ .../pytest/unit/test_skill_config_catalog.py | 51 +++++ 24 files changed, 1126 insertions(+), 25 deletions(-) create mode 100644 api/oss/tests/pytest/unit/tools/test_workflow_tool_call.py create mode 100644 sdks/python/agenta/sdk/agents/platform/workflow.py create mode 100644 sdks/python/oss/tests/pytest/unit/agents/platform/test_workflow_resolver.py diff --git a/api/entrypoints/routers.py b/api/entrypoints/routers.py index 24644d0a97..08a8069b3b 100644 --- a/api/entrypoints/routers.py +++ b/api/entrypoints/routers.py @@ -826,6 +826,7 @@ async def lifespan(*args, **kwargs): tools = ToolsRouter( tools_service=tools_service, + workflows_service=workflows_service, ) triggers = TriggersRouter( diff --git a/api/oss/src/apis/fastapi/tools/router.py b/api/oss/src/apis/fastapi/tools/router.py index 9e87c8230b..20c7b2525f 100644 --- a/api/oss/src/apis/fastapi/tools/router.py +++ b/api/oss/src/apis/fastapi/tools/router.py @@ -56,12 +56,23 @@ ToolsService, ) from oss.src.core.gateway.connections.utils import decode_oauth_state +from oss.src.core.workflows.service import WorkflowsService +from oss.src.core.workflows.dtos import ( + WorkflowServiceRequest, + WorkflowServiceRequestData, +) +from oss.src.core.shared.dtos import Reference from oss.src.utils.env import env from oss.src.core.access.permissions.types import Permission from oss.src.core.access.permissions.service import check_action_access from oss.src.apis.fastapi.shared.exceptions import FORBIDDEN_EXCEPTION +# A referenced-workflow (@ag.reference) agent tool's call_ref. Distinct from the Composio +# 5-segment grammar (``tools.{provider}.{integration}.{action}.{connection}``): the call back +# carries only the workflow identity, ``workflow.{slug}`` or ``workflow.{slug}.{version}``. +_WORKFLOW_CALL_REF_PREFIX = "workflow." + _SLUG_SEGMENT_RE = re.compile(r"[a-zA-Z0-9_-]+") log = get_module_logger(__name__) @@ -115,8 +126,13 @@ def __init__( self, *, tools_service: ToolsService, + workflows_service: Optional[WorkflowsService] = None, ): self.tools_service = tools_service + # Used to execute a referenced-workflow (@ag.reference) agent tool server-side: a + # ``workflow.{slug}[.{version}]`` call_ref routes here instead of the Composio adapter. + # Optional so a deployment that wires only the tools service still serves gateway tools. + self.workflows_service = workflows_service self.router = APIRouter() @@ -972,6 +988,13 @@ async def call_tool( if not has_permission: raise FORBIDDEN_EXCEPTION + # Route by the call_ref prefix: a referenced-workflow (@ag.reference) tool carries a + # ``workflow.{slug}[.{version}]`` call_ref and runs a workflow revision server-side; a + # Composio gateway tool carries the 5-segment ``tools.*`` slug and runs via the adapter. + call_ref = body.data.function.name.replace("__", ".") + if call_ref.startswith(_WORKFLOW_CALL_REF_PREFIX): + return await self._call_workflow_tool(request=request, body=body) + # Parse tool slug — accept both dot and double-underscore formats. # Double-underscore is used for LLM function names where dots are forbidden. slug_parts = body.data.function.name.replace("__", ".").split(".") @@ -1060,6 +1083,110 @@ async def call_tool( return ToolCallResponse(call=result) + async def _call_workflow_tool( + self, + *, + request: Request, + body: ToolCall, + ) -> ToolCallResponse: + """Execute a referenced-workflow (@ag.reference) agent tool server-side. + + The model's call routes here via a ``workflow.{slug}[.{version}]`` call_ref. We invoke + that workflow revision with the model's arguments as ``inputs`` and return its outputs as + the tool result. Connections/secrets the workflow needs stay server-side (auth is minted + from the caller's project + user), the same safety shape a gateway tool has.""" + if self.workflows_service is None: + raise HTTPException( + status_code=501, + detail="Workflow tools are not enabled on this deployment.", + ) + + # Parse ``workflow.{slug}`` or ``workflow.{slug}.{version}`` (slug is opaque; version, if + # present, is the trailing segment). Reject anything past the version. + remainder = body.data.function.name.replace("__", ".")[ + len(_WORKFLOW_CALL_REF_PREFIX) : + ] + slug_parts = remainder.split(".") + if not slug_parts or not slug_parts[0]: + raise HTTPException( + status_code=400, + detail=( + f"Invalid workflow tool call_ref: {body.data.function.name}. " + "Expected: workflow.{slug} or workflow.{slug}.{version}" + ), + ) + if len(slug_parts) > 2: + raise HTTPException( + status_code=400, + detail=( + f"Invalid workflow tool call_ref: {body.data.function.name}. " + "Expected: workflow.{slug} or workflow.{slug}.{version}" + ), + ) + slug = slug_parts[0] + version = slug_parts[1] if len(slug_parts) == 2 else None + if not _SLUG_SEGMENT_RE.match(slug): + raise HTTPException( + status_code=400, + detail=f"Invalid characters in workflow slug: {slug!r}", + ) + + # Normalise arguments — the LLM may send them as a JSON string. + arguments = body.data.function.arguments + if isinstance(arguments, str): + try: + arguments = json.loads(arguments) + except json.JSONDecodeError as e: + log.warning("Failed to parse workflow tool arguments as JSON: %s", e) + arguments = {} + elif not isinstance(arguments, dict): + arguments = {} + + # Reference the artifact (latest revision) when no version is given; a versioned + # reference pins a specific revision. Same target-naming as the @ag.embed skills path. + workflow_ref = Reference(slug=slug, version=version) + invoke_request = WorkflowServiceRequest( + references={"workflow": workflow_ref}, + data=WorkflowServiceRequestData(inputs=arguments), + ) + + try: + response = await self.workflows_service.invoke_workflow( + project_id=UUID(request.state.project_id), + user_id=UUID(request.state.user_id), + request=invoke_request, + ) + except Exception as e: + log.error("Workflow tool invocation failed", exc_info=True) + raise HTTPException( + status_code=502, + detail=f"Workflow tool '{slug}' invocation failed.", + ) from e + + status_code = response.status.code if response.status else 200 + successful = status_code is not None and 200 <= status_code < 300 + outputs = response.data.outputs if response.data else None + message = ( + None + if successful + else (response.status.message if response.status else None) + ) + + result = ToolResult( + id=uuid4(), + data=ToolResultData( + tool_call_id=body.data.id, + content=json.dumps(outputs), + ), + status=Status( + timestamp=datetime.now(timezone.utc), + code="STATUS_CODE_OK" if successful else "STATUS_CODE_ERROR", + message=message, + ), + ) + + return ToolCallResponse(call=result) + # --------------------------------------------------------------------------- # Helpers diff --git a/api/oss/src/core/embeds/utils.py b/api/oss/src/core/embeds/utils.py index 5f1ec30536..c6ed717478 100644 --- a/api/oss/src/core/embeds/utils.py +++ b/api/oss/src/core/embeds/utils.py @@ -39,6 +39,12 @@ AG_EMBED_KEY = "@ag.embed" AG_REFERENCES_KEY = "@ag.references" AG_SELECTOR_KEY = "@ag.selector" +# The reference-syntax marker (sibling of @ag.embed). The generic resolver is tool-agnostic and +# only ever INLINES @ag.embed; it must LEAVE an @ag.reference in place so resolve_tools can build +# the callback spec from the kept reference. A node carrying @ag.reference is opaque to the +# resolver: it is not an embed, and the walker does not descend into it (so a nested @ag.embed +# inside a kept reference is not accidentally inlined). See the embedref-tools design. +AG_REFERENCE_KEY = "@ag.reference" SNIPPET_DEFAULT_PATH = "prompt.messages.0.content" # Entity hierarchy: category → ordered levels (shallow to deep) @@ -1061,6 +1067,10 @@ def find_object_embeds( visited.add(config_id) if isinstance(config, dict): + if AG_REFERENCE_KEY in config and AG_EMBED_KEY not in config: + # A kept @ag.reference node is opaque to the generic resolver: leave it in place and + # do not descend (the reference-syntax "leave it" branch). resolve_tools handles it. + return embeds if AG_EMBED_KEY in config: # Found an object embed embed_data = config[AG_EMBED_KEY] @@ -1157,6 +1167,9 @@ def find_string_embeds( visited.add(config_id) if isinstance(config, dict): + if AG_REFERENCE_KEY in config and AG_EMBED_KEY not in config: + # Leave a kept @ag.reference node opaque (do not scan it for string embeds either). + return embeds # Recurse into children for key, value in config.items(): child_path = f"{parent_path}.{key}" if parent_path else key @@ -1622,6 +1635,9 @@ def find_snippet_embeds( visited.add(config_id) if isinstance(config, dict): + if AG_REFERENCE_KEY in config and AG_EMBED_KEY not in config: + # Leave a kept @ag.reference node opaque (do not scan it for snippet embeds either). + return embeds for key, value in config.items(): child_path = f"{parent_path}.{key}" if parent_path else key diff --git a/api/oss/tests/pytest/unit/embeds/test_utils.py b/api/oss/tests/pytest/unit/embeds/test_utils.py index d362bd7dea..498bff16d6 100644 --- a/api/oss/tests/pytest/unit/embeds/test_utils.py +++ b/api/oss/tests/pytest/unit/embeds/test_utils.py @@ -158,6 +158,73 @@ def test_find_object_embeds_in_list(self): assert embeds[1].location == "items.1" +class TestAgReferenceLeftInPlace: + """The generic resolver is tool-agnostic: it INLINES @ag.embed but LEAVES @ag.reference. + + The reference-syntax "leave it" branch — a kept @ag.reference node (the embedref-tools + feature) is opaque to all three embed finders, so resolve_tools later builds the callback + spec from it. See ``api/oss/src/core/embeds/utils.py`` (``AG_REFERENCE_KEY``). + """ + + def test_object_finder_ignores_reference_node(self): + config = { + "tools": [ + { + "@ag.reference": { + AG_REFERENCES_KEY: {"workflow": {"slug": "summarize"}}, + }, + "name": "summarize", + } + ] + } + assert find_object_embeds(config) == [] + + def test_object_finder_does_not_inline_nested_embed_in_reference(self): + # A @ag.embed nested INSIDE a kept @ag.reference must not be discovered/inlined: the whole + # reference node is opaque. + config = { + "tools": [ + { + "@ag.reference": { + AG_REFERENCES_KEY: {"workflow": {"slug": "wf"}}, + "nested": { + AG_EMBED_KEY: { + AG_REFERENCES_KEY: {"workflow": {"slug": "inner"}} + } + }, + } + } + ] + } + assert find_object_embeds(config) == [] + + def test_string_and_snippet_finders_ignore_reference_node(self): + config = { + "@ag.reference": { + AG_REFERENCES_KEY: {"workflow": {"slug": "wf"}}, + "note": "@ag.embed[@ag.references[workflow_revision.version=v1]]", + "snippet": "@{{workflow.slug=wf}}", + } + } + assert find_string_embeds(config) == [] + assert find_snippet_embeds(config) == [] + + def test_sibling_embed_still_resolves_alongside_reference(self): + # A real @ag.embed sibling of a kept @ag.reference is still found and resolved; only the + # reference node is left. + config = { + "tools": [ + {"@ag.reference": {AG_REFERENCES_KEY: {"workflow": {"slug": "wf"}}}}, + ], + "skills": [ + {AG_EMBED_KEY: {AG_REFERENCES_KEY: {"workflow": {"slug": "skill"}}}} + ], + } + embeds = find_object_embeds(config) + assert len(embeds) == 1 + assert embeds[0].location == "skills.0" + + class TestFindStringEmbeds: """Tests for finding string embeds in configuration.""" diff --git a/api/oss/tests/pytest/unit/tools/test_workflow_tool_call.py b/api/oss/tests/pytest/unit/tools/test_workflow_tool_call.py new file mode 100644 index 0000000000..1c0cea8aff --- /dev/null +++ b/api/oss/tests/pytest/unit/tools/test_workflow_tool_call.py @@ -0,0 +1,177 @@ +"""The ``/tools/call`` server-side execute branch for referenced-workflow (@ag.reference) tools. + +A kept @ag.reference agent tool resolves (SDK-side) to a ``callback`` spec whose call_ref is +``workflow.{slug}[.{version}]``. When the model calls it the runner POSTs the OpenAI tool-call +envelope back to ``/tools/call``; the router routes the ``workflow.*`` prefix to +``_call_workflow_tool``, which invokes the workflow revision with the model's arguments and +returns its outputs as the tool result. These tests exercise that handler directly with a fake +WorkflowsService (no DB, no live workflow service). +""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from uuid import uuid4 + +import pytest +from fastapi import HTTPException + +from oss.src.apis.fastapi.tools.router import ToolsRouter +from oss.src.core.tools.dtos import ToolCall, ToolCallData, ToolCallFunction + + +class FakeWorkflowsService: + """Records the invoke call and returns a canned batch response.""" + + def __init__( + self, *, outputs=None, status_code=200, status_message=None, raises=None + ): + self._outputs = outputs + self._status_code = status_code + self._status_message = status_message + self._raises = raises + self.calls: list[dict] = [] + + async def invoke_workflow(self, *, project_id, user_id, request): + self.calls.append( + {"project_id": project_id, "user_id": user_id, "request": request} + ) + if self._raises is not None: + raise self._raises + return SimpleNamespace( + data=SimpleNamespace(outputs=self._outputs), + status=SimpleNamespace( + code=self._status_code, message=self._status_message + ), + ) + + +def _router(workflows_service): + # The Composio ToolsService is unused on the workflow branch; a stub is fine. + return ToolsRouter( + tools_service=SimpleNamespace(), + workflows_service=workflows_service, + ) + + +def _request(): + return SimpleNamespace( + state=SimpleNamespace(project_id=str(uuid4()), user_id=str(uuid4())) + ) + + +def _call(name: str, arguments) -> ToolCall: + return ToolCall( + data=ToolCallData( + id="call_1", + function=ToolCallFunction(name=name, arguments=arguments), + ) + ) + + +async def test_invokes_workflow_by_slug_and_returns_outputs(): + workflows = FakeWorkflowsService(outputs={"summary": "ok"}) + router = _router(workflows) + request = _request() + + response = await router._call_workflow_tool( + request=request, + body=_call("workflow.summarize", {"text": "hello"}), + ) + + # One invoke, scoped to the caller's project + user. + assert len(workflows.calls) == 1 + call = workflows.calls[0] + assert str(call["project_id"]) == request.state.project_id + assert str(call["user_id"]) == request.state.user_id + # The reference targets the workflow artifact by slug; arguments ride as inputs. + refs = call["request"].references + assert refs["workflow"].slug == "summarize" + assert refs["workflow"].version is None + assert call["request"].data.inputs == {"text": "hello"} + # The outputs come back as the tool result content (a JSON string). + result = response.call + assert result.status.code == "STATUS_CODE_OK" + assert json.loads(result.data.content) == {"summary": "ok"} + assert result.data.tool_call_id == "call_1" + + +async def test_versioned_call_ref_pins_revision(): + workflows = FakeWorkflowsService(outputs=1) + await _router(workflows)._call_workflow_tool( + request=_request(), + body=_call("workflow.summarize.3", {}), + ) + refs = workflows.calls[0]["request"].references + assert refs["workflow"].slug == "summarize" + assert refs["workflow"].version == "3" + + +async def test_double_underscore_call_ref_is_normalized(): + # LLM function names forbid dots; the runner may send the __ form. + workflows = FakeWorkflowsService(outputs={}) + await _router(workflows)._call_workflow_tool( + request=_request(), + body=_call("workflow__summarize", {}), + ) + assert workflows.calls[0]["request"].references["workflow"].slug == "summarize" + + +async def test_json_string_arguments_are_parsed(): + workflows = FakeWorkflowsService(outputs={}) + await _router(workflows)._call_workflow_tool( + request=_request(), + body=_call("workflow.wf", '{"a": 1}'), + ) + assert workflows.calls[0]["request"].data.inputs == {"a": 1} + + +async def test_workflow_error_status_maps_to_error_result(): + workflows = FakeWorkflowsService( + outputs=None, status_code=400, status_message="no runnable service URL" + ) + response = await _router(workflows)._call_workflow_tool( + request=_request(), + body=_call("workflow.missing", {}), + ) + assert response.call.status.code == "STATUS_CODE_ERROR" + assert response.call.status.message == "no runnable service URL" + + +async def test_invoke_exception_surfaces_as_502(): + workflows = FakeWorkflowsService(raises=RuntimeError("boom")) + with pytest.raises(HTTPException) as caught: + await _router(workflows)._call_workflow_tool( + request=_request(), + body=_call("workflow.wf", {}), + ) + assert caught.value.status_code == 502 + + +async def test_missing_workflows_service_returns_501(): + with pytest.raises(HTTPException) as caught: + await _router(None)._call_workflow_tool( + request=_request(), + body=_call("workflow.wf", {}), + ) + assert caught.value.status_code == 501 + + +@pytest.mark.parametrize( + "name", + [ + "workflow.", # empty slug + "workflow.a.b.c", # too many segments + "workflow.bad slug", # invalid characters + ], +) +async def test_malformed_call_ref_rejected(name): + workflows = FakeWorkflowsService(outputs={}) + with pytest.raises(HTTPException) as caught: + await _router(workflows)._call_workflow_tool( + request=_request(), + body=_call(name, {}), + ) + assert caught.value.status_code == 400 + assert workflows.calls == [] diff --git a/docs/design/agent-workflows/documentation/tools.md b/docs/design/agent-workflows/documentation/tools.md index ec7313b6f9..e41dda665a 100644 --- a/docs/design/agent-workflows/documentation/tools.md +++ b/docs/design/agent-workflows/documentation/tools.md @@ -34,6 +34,24 @@ shares two fields through `ToolConfigBase`, and then a `type` discriminator pick | `gateway` | `provider`, `integration`, `action`, `connection`, optional `name` | A Composio action, like `github__create_issue` on a connected account. | | `code` | `name`, `runtime` (`python`/`node`), `script`, `input_schema`, `secrets` | An inline snippet the author writes, with named vault secrets injected. | | `client` | `name`, `input_schema` | A tool the browser fulfils, like "ask the user to pick a date." | +| `reference` | `slug`, optional `version`, optional `name`/`description`/`input_schema` | A workflow pointed at via the `@ag.reference` syntax (see below); the service runs the referenced workflow revision when the model calls it. | + +A tool can also be a **workflow** pointed at via one of two syntaxes (the embedref-tools model): + +- **`@ag.reference`** (new) — keep the reference. `coerce_tool_config` parses the kept marker + into a `ReferenceToolConfig` (`type: "reference"`, carrying the workflow `slug`/`version` plus + the model-facing surface), and `resolve_tools` turns it into a `CallbackToolSpec` whose + `call_ref` is `workflow.{slug}[.{version}]`. The model's call routes back to `POST /tools/call`, + which runs the workflow revision server-side. Connections/secrets the workflow needs stay + server-side, the same safety shape a gateway tool has. **No new runner `kind`.** +- **`@ag.embed`** (existing) — inline the value. The generic resolver resolves the reference into + a concrete `client` tool config *before* `resolve_tools` runs, so it rides the existing `client` + path (fulfilled in the browser). + +The author's syntax decides the behavior. The generic resolver (`ResolverMiddleware` + the API +embed resolver in `api/oss/src/core/embeds/utils.py`) stays tool-agnostic: it only INLINES an +`@ag.embed` and LEAVES an `@ag.reference` in place (`AG_REFERENCE_KEY`); all tool-specific mapping +lives in `resolve_tools`. MCP servers are a sibling field, `AgentConfig.mcp_servers`, not a tool type. They are declared in `sdks/python/agenta/sdk/agents/mcp/models.py` and resolved alongside tools. They are @@ -64,6 +82,7 @@ because it is the seam between the two lives of a tool: | `gateway` | `CallbackToolSpec` with a `call_ref` slug | `callback` | | `code` | `CodeToolSpec` with secrets in `env` | `code` | | `client` | `ClientToolSpec` | `client` | +| `reference` | `CallbackToolSpec` with a `workflow.{slug}` `call_ref` | `callback` | The resolved specs are also defined in `tools/models.py` (`CallbackToolSpec`, `CodeToolSpec`, `ClientToolSpec`), and the matching TypeScript shape is `ResolvedToolSpec` in @@ -94,6 +113,13 @@ Resolution runs per type: `services/oss/src/agent/tools/secrets.py`) and injects the values into the spec's `env`. The script itself is not run here. - **Client** passes through to a `ClientToolSpec`. There is nothing to resolve server-side. +- **Reference** (a kept `@ag.reference` workflow) resolves through `AgentaWorkflowToolResolver` + (`sdks/python/agenta/sdk/agents/platform/workflow.py`). Unlike gateway it makes no HTTP + round-trip — the reference is already concrete in the config — so it builds the + `CallbackToolSpec` (`call_ref = workflow.{slug}[.{version}]`) directly and assembles the shared + `ToolCallback` to `{api}/tools/call`. Gateway and reference share the one `ToolCallback` + (same endpoint, same per-request auth); the server-side `/tools/call` routes by the `call_ref` + prefix (`tools.*` vs `workflow.*`). - **Gateway** is the involved one. `AgentaGatewayToolResolver` (`sdks/python/agenta/sdk/agents/platform/gateway.py`, re-exported by `services/oss/src/agent/tools/gateway.py`) posts the references to the API's @@ -171,6 +197,19 @@ runs it.** This is the central safety property of the whole tool system. The Com the connection's auth stay on the service. The agent, the sandbox, and the harness never hold a credential. They only ever ask Agenta to run a named, pre-validated action. +### Reference (workflow) tools: the service runs the workflow + +A `@ag.reference` workflow tool is the same callback shape with a different execute target. The +call_ref is `workflow.{slug}[.{version}]` instead of the Composio 5-segment slug. `POST /tools/call` +(`ToolsRouter.call_tool` in `api/oss/src/apis/fastapi/tools/router.py`) routes by the prefix: +`workflow.*` goes to `_call_workflow_tool`, which builds a `WorkflowServiceRequest` +(`references={"workflow": Reference(slug, version)}`, `data.inputs = `) and +calls `WorkflowsService.invoke_workflow(project_id, user_id, request)`. The auth is minted +server-side from the caller's project + user, so any connections/secrets the workflow itself uses +never leave the service — the same safety property gateway tools have. The workflow's +`response.data.outputs` becomes the tool result content. The runner needs no new `kind`; it +relays a `callback` spec exactly as it does a gateway tool (direct or via the Daytona file relay). + There is one transport wrinkle. On Daytona the in-sandbox process cannot reach Agenta over the network. So the call is relayed through files instead: the in-sandbox tool writes a request file to a relay directory, the runner (which can reach Agenta) reads it, performs the same 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 50f4419dca..8542a84e61 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 @@ -39,6 +39,23 @@ Two details bite. The LLM cannot put dots in a function name, so the slug travel separators and the router normalizes them back to dots. And `arguments` may arrive as a JSON string (from the model) or an object; the router normalizes to a dict before executing. +## Two call_ref grammars on one endpoint + +The same `POST /tools/call` serves two kinds of callback tool, routed by the `call_ref` prefix: + +- **`tools.{provider}.{integration}.{action}.{connection}`** — a Composio gateway action; the + router re-resolves the connection and runs it through the provider adapter. +- **`workflow.{slug}`** / **`workflow.{slug}.{version}`** — a `@ag.reference` workflow tool; the + router (`_call_workflow_tool`) builds a `WorkflowServiceRequest` + (`references={"workflow": Reference(slug, version)}`, `data.inputs = arguments`) and calls + `WorkflowsService.invoke_workflow(project_id, user_id, request)`. The workflow's + `response.data.outputs` is serialized into `call.data.content`. Auth is minted server-side from + the caller's project + user, so the workflow's own connections/secrets stay server-side — the + same safety property as a gateway tool. + +The runner is unchanged for both: it relays a `callback` spec with whatever `call_ref` the +resolver put on it. Only the router's prefix dispatch is aware of the two grammars. + ## Owned by - `services/agent/src/tools/callback.ts`: the runner caller (sends the envelope, reads `content`). @@ -49,7 +66,9 @@ string (from the model) or an object; the router normalizes to a dict before exe ## Watch for when changing - **The tool slug format.** The `tools.{provider}.{integration}.{action}.{connection}` - reference and the `__`/`.` normalization are a paired contract across runner and router. + reference, the `workflow.{slug}[.{version}]` reference, and the `__`/`.` normalization are a + paired contract across runner and router. The router dispatches by the `tools.*` vs + `workflow.*` prefix; keep the SDK resolvers and the router parser in agreement. - **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/in-service/tool-models-and-resolution.md b/docs/design/agent-workflows/interfaces/in-service/tool-models-and-resolution.md index dec4b5d12f..48efaa8e62 100644 --- a/docs/design/agent-workflows/interfaces/in-service/tool-models-and-resolution.md +++ b/docs/design/agent-workflows/interfaces/in-service/tool-models-and-resolution.md @@ -1,7 +1,7 @@ # Tool Models And Resolution A tool starts as editable config and ends as a resolved spec that the runner can run. This -page covers that in-service contract: the four config types the author writes, the three +page covers that in-service contract: the config types the author writes, the three resolved spec types they become, and the permission derivation that runs in between. The resolved specs become `/run` fields, so a change here usually reaches the wire (see [Runner to tool callback](../cross-service/runner-to-tool-callback.md) and @@ -22,14 +22,27 @@ optional), and `render` (optional), discriminated by `type`: "script": "...", "input_schema": {}, "secrets": ["API_KEY"] } { "type": "client", "name": "pick_file", "description": "...", "input_schema": {} } + +// reference: a workflow pointed at via @ag.reference; coerce_tool_config parses the kept +// marker into this. Runs the workflow revision server-side when the model calls it. +{ "type": "reference", "slug": "summarize", "version": null, + "name": "summarize", "description": "...", "input_schema": {} } ``` +A tool can also be a **workflow** the author points at via `@ag.reference` (keep the reference) +or `@ag.embed` (inline a client tool value). The author commits the marker dict; `@ag.reference` +coerces into the `reference` config above, while `@ag.embed` is inlined to a concrete `client` +config by the generic resolver before tool resolution runs. The author's syntax decides the +behavior; `resolve_tools` owns the tool-specific mapping. + ## Resolved spec types (what the runner gets) ```jsonc -// callback: a gateway tool; runs in Agenta via /tools/call +// callback: a gateway tool OR a @ag.reference workflow tool; runs in Agenta via /tools/call. +// call_ref is the Composio 5-segment slug (tools.*) or the workflow identity (workflow.{slug}). { "kind": "callback", "name": "...", "description": "...", "input_schema": {}, "call_ref": "tools.composio.github.create_issue.my-gh" } +// e.g. a referenced workflow tool: "call_ref": "workflow.summarize" (or "workflow.summarize.3") // code: sandboxed code with its named secrets injected into env { "kind": "code", "name": "...", "runtime": "python", "code": "...", "env": { "API_KEY": "..." } } @@ -57,9 +70,15 @@ secret; their provider key stays server-side and the call routes back through `/ ## Owned by -- `sdks/python/agenta/sdk/agents/tools/models.py`: the config and spec models and the - permission derivation. +- `sdks/python/agenta/sdk/agents/tools/models.py`: the config and spec models (incl. + `ReferenceToolConfig`) and the permission derivation. +- `sdks/python/agenta/sdk/agents/tools/compat.py`: coerces a kept `@ag.reference` marker into a + `ReferenceToolConfig`. - `sdks/python/agenta/sdk/agents/platform/gateway.py`: gateway resolution to a `call_ref`. +- `sdks/python/agenta/sdk/agents/platform/workflow.py`: `@ag.reference` workflow resolution to a + `workflow.{slug}` callback spec. +- `api/oss/src/apis/fastapi/tools/router.py`: `/tools/call` routes a `workflow.*` call_ref to + `WorkflowsService.invoke_workflow` (the server-side execute path). - `services/oss/src/agent/tools/resolver.py`: the service entrypoint (re-exports the SDK). ## Watch for when changing @@ -70,3 +89,6 @@ secret; their provider key stays server-side and the call routes back through `/ - **Secret injection for code tools.** Secrets ride `env` and are resolved once at parse time. - **Gateway call references.** The `call_ref` format is a paired contract with the tool endpoint. +- **Workflow call references.** A `@ag.reference` tool's `call_ref` is `workflow.{slug}` / + `workflow.{slug}.{version}`. The server-side `/tools/call` routes by the `tools.*` vs + `workflow.*` prefix; keep the SDK (`platform/workflow.py`) and the API parser in agreement. diff --git a/docs/design/agent-workflows/interfaces/public-edge/agent-config-schema.md b/docs/design/agent-workflows/interfaces/public-edge/agent-config-schema.md index d74ea51204..c785ce0318 100644 --- a/docs/design/agent-workflows/interfaces/public-edge/agent-config-schema.md +++ b/docs/design/agent-workflows/interfaces/public-edge/agent-config-schema.md @@ -21,7 +21,7 @@ The fields and the full schema follow. |---|---|---|---| | `agents_md` | string (textarea) | hello-world prompt | The agent's system prompt, its AGENTS.md. | | `model` | string (`grouped_choice`) | `"gpt-5.5"` | Model the agent runs on. A plain id (`"gpt-5.5"`) or a structured `{provider, connection}` ref. See [Model connection resolution](../in-service/model-connection-resolution.md). | -| `tools` | `ToolConfig[]` | `[]` | Runnable tools: `builtin`, `gateway`, `code`, or `client`. See [Tool models and resolution](../in-service/tool-models-and-resolution.md). | +| `tools` | `(ToolConfig \| EmbedRef \| ReferenceRef)[]` | `[]` | Runnable tools: `builtin`, `gateway`, `code`, or `client` — or a workflow pointed at via `@ag.embed` (inline a client tool value) or `@ag.reference` (keep the reference; run the workflow server-side as a callback tool). See [Tool models and resolution](../in-service/tool-models-and-resolution.md). | | `mcp_servers` | `MCPServerConfig[]` | `[]` | Declared MCP servers; secret env resolved from the vault at run time. See [MCP models and resolution](../in-service/mcp-models-and-resolution.md). | | `harness` | `"pi_core" \| "claude" \| "pi_agenta"` (see slug+name note) | `"pi_core"` | The coding agent to drive. `pi_core` and `pi_agenta` both drive the `pi` ACP agent; `pi_agenta` adds Agenta's forced skills, prompt, and policy. | | `sandbox` | `"local" \| "daytona"` | `"local"` | Where it runs. | @@ -117,7 +117,7 @@ Either form is valid: "connection": { "mode": "agenta", "slug": "my-openai" } } // structured ref ``` -### `tools[]` (one of four variants, discriminated by `type`) +### `tools[]` (a concrete variant, or an `@ag.embed` / `@ag.reference` workflow) ```jsonc // builtin: a harness built-in by name @@ -136,8 +136,25 @@ Either form is valid: // client: fulfilled by the browser; filtered out of the runner's MCP tools/list { "type": "client", "name": "pick_file", "description": "...", "input_schema": {}, "needs_approval": false, "permission": null, "render": null } + +// @ag.reference: keep the reference; the service runs the referenced workflow revision +// server-side when the model calls it (resolves to a callback tool, key stays server-side). +// The model-facing surface (name/description/input_schema) and the tool axes ride as siblings. +{ "@ag.reference": { "@ag.references": { "workflow": { "slug": "summarize" } } }, + "name": "summarize", "description": "...", "input_schema": {}, + "needs_approval": false, "permission": null, "render": null } + +// @ag.embed: inline the referenced value into a concrete `client` tool config before the +// runner sees it (the backend's embed resolver does the inlining; rides the `client` path). +{ "@ag.embed": { "@ag.references": { "workflow": { "slug": "my-client-tool" } }, + "@ag.selector": { "path": "parameters.tool" } } } ``` +The author's syntax decides the behavior: `@ag.reference` resolves to a server-side `callback` +tool (`call_ref = workflow.{slug}[.{version}]`); `@ag.embed` inlines to a `client` tool. The +generic resolver only inlines `@ag.embed` and leaves `@ag.reference`; `resolve_tools` owns the +tool-specific mapping. See [Tool models and resolution](../in-service/tool-models-and-resolution.md). + `permission` is `"allow" | "ask" | "deny"`. When unset, it is derived: explicit value wins, then `needs_approval` to `"ask"`, then `read_only` (`true` to `"allow"`, `false` to `"ask"`), else the global policy applies. See [Tool models and @@ -239,5 +256,9 @@ not `SKILL.md` itself. - **Nested shapes.** `tools`, `mcp_servers`, `skills`, and `sandbox_permission` each have their own page and their own wire fields. A change here usually means a change there and a golden fixture. -- **Embed references.** Skills can arrive as `@ag.embed`. The schema must keep accepting that - form, or a valid default fails validation. +- **Embed references.** Skills can arrive as `@ag.embed`, and tools as `@ag.embed` or + `@ag.reference`. The schema must keep accepting these forms (the `tools` field is a union of + the concrete `ToolConfig` variants plus an `@ag.embed` arm and an `@ag.reference` arm), or a + valid config fails validation. The `@ag.reference` marker must be recognized in BOTH the SDK + `ResolverMiddleware` and the API embed resolver, and both must LEAVE it (not inline it) — a + miss in either inlines a reference and breaks the callback path. diff --git a/sdks/python/agenta/sdk/agents/__init__.py b/sdks/python/agenta/sdk/agents/__init__.py index 06b150f238..cc8c577e8a 100644 --- a/sdks/python/agenta/sdk/agents/__init__.py +++ b/sdks/python/agenta/sdk/agents/__init__.py @@ -121,6 +121,7 @@ GatewayToolResolutionError, MissingSecretPolicy, MissingToolSecretError, + ReferenceToolConfig, ResolvedToolSet, ToolConfig, ToolConfigError, @@ -130,6 +131,7 @@ ToolSecretProvider, ToolSpec, UnsupportedToolProviderError, + WorkflowToolResolver, coerce_tool_config, coerce_tool_configs, parse_tool_config, @@ -173,6 +175,7 @@ "GatewayToolConfig", "CodeToolConfig", "ClientToolConfig", + "ReferenceToolConfig", "ToolSpec", "CallbackToolSpec", "CodeToolSpec", @@ -182,6 +185,7 @@ "ToolResolver", "ToolSecretProvider", "GatewayToolResolver", + "WorkflowToolResolver", "EnvironmentToolSecretProvider", "MissingSecretPolicy", "parse_tool_config", diff --git a/sdks/python/agenta/sdk/agents/platform/__init__.py b/sdks/python/agenta/sdk/agents/platform/__init__.py index 90dc474d8f..fd80685ae2 100644 --- a/sdks/python/agenta/sdk/agents/platform/__init__.py +++ b/sdks/python/agenta/sdk/agents/platform/__init__.py @@ -21,11 +21,13 @@ resolve_named_secrets, resolve_provider_keys, ) +from .workflow import AgentaWorkflowToolResolver __all__ = [ "PlatformConnection", "default_timeout", "AgentaGatewayToolResolver", + "AgentaWorkflowToolResolver", "AgentaNamedSecretProvider", "VaultConnectionResolver", "resolve_named_secrets", diff --git a/sdks/python/agenta/sdk/agents/platform/resolve.py b/sdks/python/agenta/sdk/agents/platform/resolve.py index 0832f7823a..6255c1258a 100644 --- a/sdks/python/agenta/sdk/agents/platform/resolve.py +++ b/sdks/python/agenta/sdk/agents/platform/resolve.py @@ -38,12 +38,17 @@ ToolResolver, coerce_tool_configs, ) -from agenta.sdk.agents.tools.interfaces import GatewayToolResolver, ToolSecretProvider +from agenta.sdk.agents.tools.interfaces import ( + GatewayToolResolver, + ToolSecretProvider, + WorkflowToolResolver, +) from .connections import VaultConnectionResolver from .gateway import AgentaGatewayToolResolver from .secrets import AgentaNamedSecretProvider from .secrets import resolve_provider_keys as resolve_secrets +from .workflow import AgentaWorkflowToolResolver __all__ = ["resolve_tools", "resolve_mcp", "resolve_secrets", "resolve_connection"] @@ -53,12 +58,17 @@ async def resolve_tools( *, secret_provider: Optional[ToolSecretProvider] = None, gateway_resolver: Optional[GatewayToolResolver] = None, + workflow_resolver: Optional[WorkflowToolResolver] = None, missing_secret_policy: MissingSecretPolicy = MissingSecretPolicy.ERROR, ) -> ResolvedToolSet: - """Resolve tool declarations into runnable specs. Defaults to the Agenta platform adapters.""" + """Resolve tool declarations into runnable specs. Defaults to the Agenta platform adapters. + + A kept ``@ag.reference`` workflow tool resolves through the ``workflow_resolver`` into a + ``callback`` spec (server-side workflow execute), the same executor a gateway tool uses.""" return await ToolResolver( secret_provider=secret_provider or AgentaNamedSecretProvider(), gateway_resolver=gateway_resolver or AgentaGatewayToolResolver(), + workflow_resolver=workflow_resolver or AgentaWorkflowToolResolver(), missing_secret_policy=missing_secret_policy, ).resolve(coerce_tool_configs(tools).tool_configs) diff --git a/sdks/python/agenta/sdk/agents/platform/workflow.py b/sdks/python/agenta/sdk/agents/platform/workflow.py new file mode 100644 index 0000000000..d54f07455d --- /dev/null +++ b/sdks/python/agenta/sdk/agents/platform/workflow.py @@ -0,0 +1,90 @@ +"""Agenta adapter for kept ``@ag.reference`` workflow tools. + +Turns a kept ``@ag.reference`` workflow declaration into a runnable ``callback`` spec and points +its calls back at ``/tools/call``, exactly like the gateway adapter does for Composio actions. +The difference from gateway is intrinsic, not transport: a workflow reference is already concrete +in the config (the model-facing ``name`` / ``description`` / ``input_schema`` are authored), so +there is no enrichment round-trip — the adapter builds the spec directly and only needs the +backend base URL + per-request auth to assemble the shared ``ToolCallback``. + +When the model calls the tool the runner POSTs ``{data:{function:{name: call_ref, arguments}}}`` +to ``{api}/tools/call``; the server parses the ``workflow.{slug}[.{version}]`` ``call_ref``, +invokes that workflow revision with the model's arguments, and returns the result. Any +connections/secrets the workflow needs stay server-side — the gateway tool's safety shape. + +Lives in the SDK so the service and a connected standalone SDK user resolve workflow tools the +same way. +""" + +from __future__ import annotations + +from typing import Optional, Sequence + +from agenta.sdk.agents.tools import ( + CallbackToolSpec, + GatewayToolResolution, + GatewayToolResolutionError, + ReferenceToolConfig, + ToolCallback, +) +from agenta.sdk.utils.logging import get_module_logger + +from .connection import PlatformConnection + +log = get_module_logger(__name__) + + +class AgentaWorkflowToolResolver: + """:class:`WorkflowToolResolver` backed by the Agenta backend's ``/tools/call`` endpoint.""" + + def __init__(self, connection: Optional[PlatformConnection] = None) -> None: + self._connection = connection or PlatformConnection() + + async def resolve( + self, + tools: Sequence[ReferenceToolConfig], + ) -> GatewayToolResolution: + api_base = self._connection.base_url() + if not api_base: + error = GatewayToolResolutionError( + "Agent has workflow (@ag.reference) tools configured but the Agenta API " + "base URL is unknown. Set AGENTA_AGENT_TOOLS_API_URL or AGENTA_API_URL." + ) + log.warning("agent: workflow tool resolution failed: %s", error) + raise error + + # Resolve the credential once and reuse it for the ToolCallback so the resolved + # endpoint and its auth cannot diverge. + authorization = self._connection.authorization() + + seen: set[str] = set() + tool_specs: list[CallbackToolSpec] = [] + for tool_config in tools: + call_ref = tool_config.call_ref + if call_ref in seen: + error = GatewayToolResolutionError( + f"Duplicate workflow reference: {call_ref}", + reference=call_ref, + ) + log.warning("agent: %s", error) + raise error + seen.add(call_ref) + tool_specs.append( + CallbackToolSpec( + name=tool_config.tool_name, + description=tool_config.description or tool_config.tool_name, + input_schema=tool_config.input_schema, + call_ref=call_ref, + needs_approval=tool_config.needs_approval, + render=tool_config.render, + permission=tool_config.permission, + ) + ) + + return GatewayToolResolution( + tool_specs=tool_specs, + tool_callback=ToolCallback( + endpoint=f"{api_base}/tools/call", + authorization=authorization, + ), + ) diff --git a/sdks/python/agenta/sdk/agents/tools/__init__.py b/sdks/python/agenta/sdk/agents/tools/__init__.py index 91d36f0a46..b8e0a58070 100644 --- a/sdks/python/agenta/sdk/agents/tools/__init__.py +++ b/sdks/python/agenta/sdk/agents/tools/__init__.py @@ -16,8 +16,9 @@ ToolResolutionError, UnsupportedToolProviderError, ) -from .interfaces import GatewayToolResolver, ToolSecretProvider +from .interfaces import GatewayToolResolver, ToolSecretProvider, WorkflowToolResolver from .models import ( + AG_REFERENCE_MARKER, BuiltinToolConfig, CallbackToolSpec, ClientToolConfig, @@ -27,6 +28,7 @@ GatewayToolConfig, GatewayToolResolution, MissingSecretPolicy, + ReferenceToolConfig, ResolvedToolSet, ToolCallback, ToolConfig, @@ -43,6 +45,8 @@ "GatewayToolConfig", "CodeToolConfig", "ClientToolConfig", + "ReferenceToolConfig", + "AG_REFERENCE_MARKER", "ToolSpec", "CallbackToolSpec", "CodeToolSpec", @@ -54,6 +58,7 @@ "ToolResolver", "ToolSecretProvider", "GatewayToolResolver", + "WorkflowToolResolver", "EnvironmentToolSecretProvider", "parse_tool_config", "coerce_tool_config", diff --git a/sdks/python/agenta/sdk/agents/tools/compat.py b/sdks/python/agenta/sdk/agents/tools/compat.py index 033cafdc78..8a0bdb3b39 100644 --- a/sdks/python/agenta/sdk/agents/tools/compat.py +++ b/sdks/python/agenta/sdk/agents/tools/compat.py @@ -8,14 +8,18 @@ from .errors import ToolConfigurationError from .models import ( + AG_REFERENCE_MARKER, BuiltinToolConfig, ClientToolConfig, CodeToolConfig, GatewayToolConfig, + ReferenceToolConfig, ToolConfig, ) from .parsing import parse_tool_config +_AG_REFERENCES_KEY = "@ag.references" + class ToolConfigDiagnostic(BaseModel): model_config = ConfigDict(frozen=True) @@ -65,6 +69,52 @@ def _copy_tool_metadata( return result +def _parse_workflow_reference(refs: Any) -> Optional[dict[str, Any]]: + """Pull a ``{slug, version}`` from an ``@ag.references`` block keyed by ``workflow``. + + Mirrors the ``@ag.embed`` target-naming: an artifact-level ``workflow`` reference resolves to + the latest revision. A ``workflow_revision`` slug matches the revision's hash slug, not the + author-facing artifact slug, so the artifact ``workflow`` key is the supported shape (same + gotcha skills have). Returns ``None`` when no workflow reference is present.""" + if not isinstance(refs, dict): + return None + workflow = refs.get("workflow") + if not isinstance(workflow, dict): + return None + slug = workflow.get("slug") + if not isinstance(slug, str) or not slug: + return None + parsed: dict[str, Any] = {"slug": slug} + version = workflow.get("version") + if version is not None: + parsed["version"] = str(version) + return parsed + + +def _coerce_reference_tool(data: dict[str, Any]) -> Optional[ToolConfig]: + """Build a :class:`ReferenceToolConfig` from a kept ``@ag.reference`` marker dict. + + The author commits ``{"@ag.reference": {"@ag.references": {"workflow": {"slug": ...}}}, ...}`` + (the same inner target-naming as ``@ag.embed``, differing only in leave-vs-inline). The + model-facing surface (``name`` / ``description`` / ``input_schema``) rides as sibling keys of + the marker. Returns ``None`` when the marker is absent so other shapes fall through.""" + marker = data.get(AG_REFERENCE_MARKER) + if not isinstance(marker, dict): + return None + workflow_ref = _parse_workflow_reference(marker.get(_AG_REFERENCES_KEY)) + if workflow_ref is None: + raise ToolConfigurationError( + f"{AG_REFERENCE_MARKER} tool requires a workflow reference " + f"({_AG_REFERENCES_KEY}.workflow.slug)", + value=data, + ) + config: dict[str, Any] = {"type": "reference", **workflow_ref} + for key in ("name", "description", "input_schema"): + if data.get(key) is not None: + config[key] = data[key] + return parse_tool_config(_copy_tool_metadata(data, config)) + + def coerce_tool_config(value: Any) -> ToolConfig: """Convert one supported legacy shape into canonical tool configuration.""" if isinstance( @@ -74,6 +124,7 @@ def coerce_tool_config(value: Any) -> ToolConfig: GatewayToolConfig, CodeToolConfig, ClientToolConfig, + ReferenceToolConfig, ), ): return value @@ -90,7 +141,11 @@ def coerce_tool_config(value: Any) -> ToolConfig: data["type"] = "gateway" data.setdefault("provider", "composio") - if data.get("type") in {"builtin", "gateway", "code", "client"}: + reference_config = _coerce_reference_tool(data) + if reference_config is not None: + return reference_config + + if data.get("type") in {"builtin", "gateway", "code", "client", "reference"}: return parse_tool_config(data) function = data.get("function") if isinstance(data.get("function"), dict) else {} diff --git a/sdks/python/agenta/sdk/agents/tools/interfaces.py b/sdks/python/agenta/sdk/agents/tools/interfaces.py index 3ccc4c767c..842b20fe5e 100644 --- a/sdks/python/agenta/sdk/agents/tools/interfaces.py +++ b/sdks/python/agenta/sdk/agents/tools/interfaces.py @@ -4,7 +4,7 @@ from typing import Mapping, Protocol, Sequence -from .models import GatewayToolConfig, GatewayToolResolution +from .models import GatewayToolConfig, GatewayToolResolution, ReferenceToolConfig class ToolSecretProvider(Protocol): @@ -18,3 +18,15 @@ async def resolve( tools: Sequence[GatewayToolConfig], ) -> GatewayToolResolution: """Resolve gateway declarations into callback specifications.""" + + +class WorkflowToolResolver(Protocol): + async def resolve( + self, + tools: Sequence[ReferenceToolConfig], + ) -> GatewayToolResolution: + """Resolve kept ``@ag.reference`` workflow declarations into callback specifications. + + Returns the same shape as the gateway resolver (callback specs + the single shared + :class:`ToolCallback` to the server-side execute endpoint) so a referenced workflow tool + rides the existing ``callback`` executor with no new runner ``kind``.""" diff --git a/sdks/python/agenta/sdk/agents/tools/models.py b/sdks/python/agenta/sdk/agents/tools/models.py index ecf2a5416a..20ba2a304c 100644 --- a/sdks/python/agenta/sdk/agents/tools/models.py +++ b/sdks/python/agenta/sdk/agents/tools/models.py @@ -82,12 +82,55 @@ class ClientToolConfig(ToolConfigBase): input_schema: Dict[str, Any] = Field(default_factory=_empty_object_schema) +# The top-level marker an author writes to KEEP (not inline) a workflow reference as a tool. +# Sibling of ``@ag.embed`` (which inlines the referenced value). The generic resolver leaves an +# ``@ag.reference`` node untouched; ``resolve_tools`` then maps it to a ``CallbackToolSpec`` so +# the model's call runs the referenced workflow revision server-side, like a gateway tool. +AG_REFERENCE_MARKER = "@ag.reference" + + +class ReferenceToolConfig(ToolConfigBase): + """A kept ``@ag.reference`` workflow tool (the reference syntax). + + ``type`` is the synthetic discriminator ``"reference"`` so this arm lives in the canonical + ``ToolConfig`` union; it is NOT a Composio-style declared variant (no provider/integration/ + action). It carries the workflow identity (``slug`` + optional ``version``) plus the + model-facing surface (``name`` / ``description`` / ``input_schema``). ``resolve_tools`` turns + it into a ``CallbackToolSpec`` whose ``call_ref`` encodes the workflow identity; the runner + dispatches the call back through the existing ``callback`` executor and the Agenta service runs + the workflow revision. Connections/secrets the workflow needs stay server-side.""" + + type: Literal["reference"] = "reference" + slug: str = Field(min_length=1) + version: Optional[str] = None + name: Optional[str] = Field(default=None, min_length=1) + description: Optional[str] = None + input_schema: Dict[str, Any] = Field(default_factory=_empty_object_schema) + + @property + def tool_name(self) -> str: + """The model-visible name; defaults to the workflow slug when none is authored.""" + return self.name or self.slug + + @property + def call_ref(self) -> str: + """The opaque ``workflow.{slug}`` / ``workflow.{slug}.{version}`` callback identity. + + Distinct from the Composio 5-segment grammar (``tools.{provider}.{integration}. + {action}.{connection}``). The runner treats this as opaque; only the server-side + ``/tools/call`` parser must agree on the ``workflow.*`` prefix.""" + if self.version: + return f"workflow.{self.slug}.{self.version}" + return f"workflow.{self.slug}" + + ToolConfig = Annotated[ Union[ BuiltinToolConfig, GatewayToolConfig, CodeToolConfig, ClientToolConfig, + ReferenceToolConfig, ], Field(discriminator="type"), ] diff --git a/sdks/python/agenta/sdk/agents/tools/resolver.py b/sdks/python/agenta/sdk/agents/tools/resolver.py index 306b9d0134..fd3ed4c22a 100644 --- a/sdks/python/agenta/sdk/agents/tools/resolver.py +++ b/sdks/python/agenta/sdk/agents/tools/resolver.py @@ -10,7 +10,7 @@ MissingToolSecretError, UnsupportedToolProviderError, ) -from .interfaces import GatewayToolResolver, ToolSecretProvider +from .interfaces import GatewayToolResolver, ToolSecretProvider, WorkflowToolResolver from .models import ( BuiltinToolConfig, ClientToolConfig, @@ -19,7 +19,9 @@ CodeToolSpec, GatewayToolConfig, MissingSecretPolicy, + ReferenceToolConfig, ResolvedToolSet, + ToolCallback, ToolConfig, ToolSpec, ) @@ -94,10 +96,12 @@ def __init__( *, secret_provider: Optional[ToolSecretProvider] = None, gateway_resolver: Optional[GatewayToolResolver] = None, + workflow_resolver: Optional[WorkflowToolResolver] = None, missing_secret_policy: MissingSecretPolicy = MissingSecretPolicy.ERROR, ) -> None: self._secret_provider = secret_provider or EnvironmentToolSecretProvider() self._gateway_resolver = gateway_resolver + self._workflow_resolver = workflow_resolver self._missing_secret_policy = missing_secret_policy async def resolve(self, tool_configs: Sequence[ToolConfig]) -> ResolvedToolSet: @@ -121,6 +125,11 @@ async def resolve(self, tool_configs: Sequence[ToolConfig]) -> ResolvedToolSet: for tool_config in tool_configs if isinstance(tool_config, GatewayToolConfig) ] + reference_configs = [ + tool_config + for tool_config in tool_configs + if isinstance(tool_config, ReferenceToolConfig) + ] secret_names = sorted( { @@ -159,13 +168,28 @@ async def resolve(self, tool_configs: Sequence[ToolConfig]) -> ResolvedToolSet: for tool_config in client_configs ) - tool_callback = None + tool_callback: Optional[ToolCallback] = None + # A kept ``@ag.reference`` workflow tool resolves to the same ``callback`` executor as a + # gateway tool: a ``CallbackToolSpec`` (``call_ref = workflow.{slug}[.{version}]``) plus the + # single shared ``ToolCallback`` to the server-side execute endpoint. The runner needs no + # new ``kind``; the server-side ``/tools/call`` routes by the ``workflow.*`` prefix. + if reference_configs: + if self._workflow_resolver is None: + raise UnsupportedToolProviderError("workflow") + workflow_resolution = await self._workflow_resolver.resolve( + reference_configs + ) + tool_specs = [*workflow_resolution.tool_specs, *tool_specs] + tool_callback = workflow_resolution.tool_callback + if gateway_configs: if self._gateway_resolver is None: raise UnsupportedToolProviderError(gateway_configs[0].provider) gateway_resolution = await self._gateway_resolver.resolve(gateway_configs) tool_specs = [*gateway_resolution.tool_specs, *tool_specs] - tool_callback = gateway_resolution.tool_callback + # Gateway and workflow callbacks both point at ``{api}/tools/call`` with the same + # per-request auth, so the single shared callback is identical; keep one. + tool_callback = gateway_resolution.tool_callback or tool_callback _validate_unique_names( builtin_names=builtin_names, diff --git a/sdks/python/agenta/sdk/utils/types.py b/sdks/python/agenta/sdk/utils/types.py index 59db5cb391..da15074cb8 100644 --- a/sdks/python/agenta/sdk/utils/types.py +++ b/sdks/python/agenta/sdk/utils/types.py @@ -1124,13 +1124,17 @@ class AgentConfigSchema(AgSchemaMixin): description="Model the agent runs on.", json_schema_extra={"x-parameter": "grouped_choice"}, ) - tools: List[ToolConfig] = Field( - default_factory=list, - title="Tools", - description=( - "Runnable tools the agent can call: harness built-ins, server-side gateway " - "actions (e.g. Composio), sandboxed code, or client-fulfilled tools." - ), + tools: List[Union[ToolConfig, "_ToolEmbedRefSchema", "_ToolReferenceSchema"]] = ( + Field( + default_factory=list, + title="Tools", + description=( + "Runnable tools the agent can call: harness built-ins, server-side gateway " + "actions (e.g. Composio), sandboxed code, or client-fulfilled tools — or a " + "workflow pointed at via @ag.embed (inline a client tool value) or @ag.reference " + "(keep the reference; run the workflow server-side as a callback tool)." + ), + ) ) mcp_servers: List[MCPServerConfig] = Field( default_factory=list, @@ -1338,7 +1342,50 @@ class _SkillEmbedRefSchema(BaseModel): ) -# Resolve the forward references on AgentConfigSchema.skills (inline + embed-ref variants). +class _ToolEmbedRefSchema(BaseModel): + """An ``@ag.embed`` reference standing in for one ``tools`` entry (the embed syntax). + + Mirrors :class:`_SkillEmbedRefSchema`: the playground keeps a tool the author references + (rather than writes inline) as a bare ``{"@ag.embed": {...}}`` object, and the backend's + embed resolver inlines it into a concrete ``client`` tool config before the runner sees it. + The raw/advanced schema must accept this reference form alongside the concrete tool variants. + The embed body stays permissive (``Dict[str, Any]``) — its inner ``@ag.references`` / + ``@ag.selector`` keys are the embed resolver's contract, not this schema's. + """ + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + embed: Dict[str, Any] = Field( + alias="@ag.embed", + title="Embed reference", + description="An @ag.embed reference resolved server-side into an inline client tool.", + ) + + +class _ToolReferenceSchema(BaseModel): + """An ``@ag.reference`` marker standing in for one ``tools`` entry (the reference syntax). + + The new top-level marker (sibling of ``@ag.embed``): unlike embed, the generic resolver + LEAVES this reference in the config, and ``resolve_tools`` turns the kept reference into a + server-side ``callback`` tool that runs the referenced workflow revision. The marker body + stays permissive (``Dict[str, Any]``) — its inner ``@ag.references`` / ``@ag.selector`` keys + name the workflow target, the same way ``@ag.embed`` does. ``extra="allow"`` so the + model-facing surface (``name`` / ``description`` / ``input_schema``) and the tool axes + (``needs_approval`` / ``render`` / ``permission``) ride as sibling keys of the marker.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + reference: Dict[str, Any] = Field( + alias="@ag.reference", + title="Workflow reference", + description=( + "An @ag.reference marker kept in the config; resolve_tools runs the referenced " + "workflow revision server-side as a callback tool." + ), + ) + + +# Resolve the forward references on AgentConfigSchema.skills + tools (inline / embed / reference). AgentConfigSchema.model_rebuild() diff --git a/sdks/python/oss/tests/pytest/unit/agents/platform/test_workflow_resolver.py b/sdks/python/oss/tests/pytest/unit/agents/platform/test_workflow_resolver.py new file mode 100644 index 0000000000..19267a106c --- /dev/null +++ b/sdks/python/oss/tests/pytest/unit/agents/platform/test_workflow_resolver.py @@ -0,0 +1,92 @@ +"""The Agenta workflow tool resolver for kept ``@ag.reference`` tools. + +Unlike the gateway resolver this makes NO HTTP call: a workflow reference is already concrete in +the config (the model-facing name/description/input_schema are authored), so the adapter builds +the callback spec directly and only needs the backend base URL + per-request auth to assemble the +shared ``ToolCallback`` to ``/tools/call``. +""" + +from __future__ import annotations + +import pytest + +from agenta.sdk.agents import ( + GatewayToolResolutionError, + ReferenceToolConfig, +) +from agenta.sdk.agents.platform import AgentaWorkflowToolResolver, PlatformConnection + + +def _resolver(connection): + return AgentaWorkflowToolResolver(connection=connection) + + +async def test_missing_api_base_raises_typed_error(): + resolver = _resolver(PlatformConnection()) # no base URL configured + with pytest.raises(GatewayToolResolutionError, match="API base URL"): + await resolver.resolve([ReferenceToolConfig(slug="wf")]) + + +async def test_builds_callback_spec_and_shared_callback(connection): + resolver = _resolver(connection) + resolution = await resolver.resolve( + [ + ReferenceToolConfig( + slug="summarize", + name="summarize", + description="Summarize text", + input_schema={ + "type": "object", + "properties": {"text": {"type": "string"}}, + }, + ) + ] + ) + + assert len(resolution.tool_specs) == 1 + spec = resolution.tool_specs[0] + assert spec.kind == "callback" + assert spec.call_ref == "workflow.summarize" + assert spec.name == "summarize" + assert spec.description == "Summarize text" + assert spec.input_schema["properties"]["text"]["type"] == "string" + + # The shared callback points at the backend execute endpoint with the caller's credential. + assert resolution.tool_callback.endpoint == "https://api.x/api/tools/call" + assert resolution.tool_callback.authorization == "Access tok" + + +async def test_versioned_slug_and_default_name(connection): + resolution = await _resolver(connection).resolve( + [ReferenceToolConfig(slug="wf", version="3")] + ) + spec = resolution.tool_specs[0] + assert spec.call_ref == "workflow.wf.3" + # No authored name -> the model-visible name defaults to the workflow slug. + assert spec.name == "wf" + + +async def test_tool_axes_carry_onto_spec(connection): + resolution = await _resolver(connection).resolve( + [ + ReferenceToolConfig( + slug="wf", + needs_approval=True, + render={"kind": "component", "component": "Card"}, + permission="ask", + ) + ] + ) + spec = resolution.tool_specs[0] + assert spec.needs_approval is True + assert spec.render == {"kind": "component", "component": "Card"} + assert spec.permission == "ask" + + +async def test_duplicate_call_ref_rejected(connection): + with pytest.raises( + GatewayToolResolutionError, match="Duplicate workflow reference" + ): + await _resolver(connection).resolve( + [ReferenceToolConfig(slug="wf"), ReferenceToolConfig(slug="wf")] + ) diff --git a/sdks/python/oss/tests/pytest/unit/agents/tools/test_models.py b/sdks/python/oss/tests/pytest/unit/agents/tools/test_models.py index ace0549665..b052d12ea0 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/tools/test_models.py +++ b/sdks/python/oss/tests/pytest/unit/agents/tools/test_models.py @@ -7,9 +7,25 @@ CallbackToolSpec, CodeToolConfig, CodeToolSpec, + ReferenceToolConfig, ) +def test_reference_tool_call_ref_grammar(): + # Slug-only -> workflow.{slug}; versioned -> workflow.{slug}.{version}. Distinct from the + # Composio 5-segment grammar so the server-side parser routes by the `workflow.` prefix. + assert ReferenceToolConfig(slug="wf").call_ref == "workflow.wf" + assert ReferenceToolConfig(slug="wf", version="2").call_ref == "workflow.wf.2" + # The model-visible name defaults to the slug when none is authored. + assert ReferenceToolConfig(slug="wf").tool_name == "wf" + assert ReferenceToolConfig(slug="wf", name="run").tool_name == "run" + + +def test_reference_tool_discriminator_is_reference(): + config = ReferenceToolConfig(slug="wf") + assert config.type == "reference" + + def test_canonical_config_forbids_unexpected_fields(): with pytest.raises(ValidationError): CodeToolConfig( diff --git a/sdks/python/oss/tests/pytest/unit/agents/tools/test_parsing.py b/sdks/python/oss/tests/pytest/unit/agents/tools/test_parsing.py index aa7b9c18bf..abcc739960 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/tools/test_parsing.py +++ b/sdks/python/oss/tests/pytest/unit/agents/tools/test_parsing.py @@ -5,6 +5,7 @@ from agenta.sdk.agents.tools import ( BuiltinToolConfig, GatewayToolConfig, + ReferenceToolConfig, ToolConfigurationError, coerce_tool_config, coerce_tool_configs, @@ -137,3 +138,72 @@ def test_default_compat_mode_raises_with_index(): with pytest.raises(ToolConfigurationError) as caught: coerce_tool_configs(["read", {"invalid": True}]) assert caught.value.index == 1 + + +# --- @ag.reference workflow tool (the reference syntax) ----------------------- + + +def test_coerces_kept_ag_reference_marker_into_reference_tool(): + # The kept @ag.reference shape an author commits: the marker names the workflow target, and + # the model-facing surface (name/description/input_schema) rides as sibling keys. + tool = coerce_tool_config( + { + "@ag.reference": { + "@ag.references": {"workflow": {"slug": "summarize"}}, + }, + "name": "summarize", + "description": "Summarize text", + "input_schema": { + "type": "object", + "properties": {"text": {"type": "string"}}, + }, + } + ) + assert isinstance(tool, ReferenceToolConfig) + assert tool.slug == "summarize" + assert tool.version is None + assert tool.call_ref == "workflow.summarize" + assert tool.tool_name == "summarize" + assert tool.input_schema["properties"]["text"]["type"] == "string" + + +def test_ag_reference_version_builds_versioned_call_ref(): + tool = coerce_tool_config( + { + "@ag.reference": { + "@ag.references": {"workflow": {"slug": "wf", "version": "3"}} + } + } + ) + assert isinstance(tool, ReferenceToolConfig) + assert tool.call_ref == "workflow.wf.3" + # No authored name -> the model-visible name defaults to the workflow slug. + assert tool.tool_name == "wf" + + +def test_ag_reference_carries_tool_axes(): + tool = coerce_tool_config( + { + "@ag.reference": {"@ag.references": {"workflow": {"slug": "wf"}}}, + "needs_approval": True, + "render": {"kind": "component", "component": "Card"}, + "permission": "ask", + } + ) + assert isinstance(tool, ReferenceToolConfig) + assert tool.needs_approval is True + assert tool.render == {"kind": "component", "component": "Card"} + assert tool.permission == "ask" + + +def test_ag_reference_without_workflow_slug_raises(): + with pytest.raises(ToolConfigurationError): + coerce_tool_config({"@ag.reference": {"@ag.references": {}}}) + with pytest.raises(ToolConfigurationError): + coerce_tool_config({"@ag.reference": {}}) + + +def test_typed_reference_config_round_trips(): + tool = parse_tool_config({"type": "reference", "slug": "wf", "name": "do_wf"}) + assert isinstance(tool, ReferenceToolConfig) + assert tool.call_ref == "workflow.wf" diff --git a/sdks/python/oss/tests/pytest/unit/agents/tools/test_resolver.py b/sdks/python/oss/tests/pytest/unit/agents/tools/test_resolver.py index dddce819d2..e9a13dfd1e 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/tools/test_resolver.py +++ b/sdks/python/oss/tests/pytest/unit/agents/tools/test_resolver.py @@ -14,6 +14,7 @@ GatewayToolResolution, MissingSecretPolicy, MissingToolSecretError, + ReferenceToolConfig, ToolCallback, ToolResolver, UnsupportedToolProviderError, @@ -51,6 +52,34 @@ async def resolve( ) +class FakeWorkflowResolver: + """Mirrors :class:`AgentaWorkflowToolResolver`: build a callback spec per reference config + + the single shared callback to the server-side execute endpoint.""" + + def __init__(self, endpoint: str = "https://example/tools/call"): + self.endpoint = endpoint + + async def resolve( + self, + tools: Sequence[ReferenceToolConfig], + ) -> GatewayToolResolution: + return GatewayToolResolution( + tool_specs=[ + CallbackToolSpec( + name=tool.tool_name, + description=tool.description or tool.tool_name, + input_schema=tool.input_schema, + call_ref=tool.call_ref, + needs_approval=tool.needs_approval, + render=tool.render, + permission=tool.permission, + ) + for tool in tools + ], + tool_callback=ToolCallback(endpoint=self.endpoint), + ) + + async def test_resolves_builtin_code_client_and_scopes_secrets(): secrets = DictSecretProvider({"A": "a", "B": "b"}) resolved = await ToolResolver(secret_provider=secrets).resolve( @@ -162,3 +191,65 @@ async def test_resolved_spec_omits_permission_when_unset(): async def test_duplicate_model_visible_names_are_rejected(configs): with pytest.raises(DuplicateToolNameError): await ToolResolver().resolve(configs) + + +# --- @ag.reference workflow tool resolution ---------------------------------- + + +async def test_reference_tool_resolves_to_callback_spec(): + # A kept @ag.reference workflow tool becomes a callback spec (server-side execute), the same + # executor a gateway tool uses, plus the shared ToolCallback to the execute endpoint. + resolved = await ToolResolver(workflow_resolver=FakeWorkflowResolver()).resolve( + [ + ReferenceToolConfig( + slug="summarize", + name="summarize", + description="Summarize text", + input_schema={ + "type": "object", + "properties": {"text": {"type": "string"}}, + }, + ) + ] + ) + assert len(resolved.tool_specs) == 1 + spec = resolved.tool_specs[0] + assert isinstance(spec, CallbackToolSpec) + assert spec.kind == "callback" + assert spec.call_ref == "workflow.summarize" + assert spec.name == "summarize" + assert resolved.tool_callback.endpoint == "https://example/tools/call" + # On the wire it is a `callback` spec carrying the workflow callRef — no new runner kind. + wire = spec.to_wire() + assert wire["kind"] == "callback" + assert wire["callRef"] == "workflow.summarize" + + +async def test_reference_tool_requires_injected_resolver(): + with pytest.raises(UnsupportedToolProviderError): + await ToolResolver().resolve([ReferenceToolConfig(slug="wf")]) + + +async def test_reference_tool_axes_survive_resolution(): + resolved = await ToolResolver(workflow_resolver=FakeWorkflowResolver()).resolve( + [ReferenceToolConfig(slug="wf", needs_approval=True, permission="ask")] + ) + spec = resolved.tool_specs[0] + assert spec.needs_approval is True + assert spec.permission == "ask" + + +async def test_reference_and_gateway_share_one_callback(): + # Both resolve to the same {api}/tools/call endpoint; the single shared callback is kept once. + resolved = await ToolResolver( + gateway_resolver=FakeGatewayResolver(), + workflow_resolver=FakeWorkflowResolver(), + ).resolve( + [ + ReferenceToolConfig(slug="wf"), + GatewayToolConfig(integration="github", action="GET_USER", connection="c1"), + ] + ) + call_refs = {spec.call_ref for spec in resolved.tool_specs} + assert "workflow.wf" in call_refs + assert resolved.tool_callback is not None diff --git a/sdks/python/oss/tests/pytest/unit/test_skill_config_catalog.py b/sdks/python/oss/tests/pytest/unit/test_skill_config_catalog.py index 6754985d36..22bc6077e5 100644 --- a/sdks/python/oss/tests/pytest/unit/test_skill_config_catalog.py +++ b/sdks/python/oss/tests/pytest/unit/test_skill_config_catalog.py @@ -102,3 +102,54 @@ def test_inline_skill_entry_validates(): ] jsonschema.validate(config, agent_config) + + +# --- tools: @ag.embed (inline) + @ag.reference (kept) arms ------------------- + + +def test_agent_config_tools_accepts_embed_and_reference_arms(): + """The tools field is a union: a concrete tool variant, an @ag.embed (inline a client tool), + or an @ag.reference (keep the reference; run the workflow server-side).""" + agent_config = CATALOG_TYPES["agent_config"] + tools_item = agent_config["properties"]["tools"]["items"] + variants = tools_item["anyOf"] + + # The embed and reference arms are present alongside the concrete tool variants. + has_embed = any("@ag.embed" in v.get("properties", {}) for v in variants) + has_reference = any("@ag.reference" in v.get("properties", {}) for v in variants) + assert has_embed, "tools union must include an @ag.embed arm" + assert has_reference, "tools union must include an @ag.reference arm" + + +def test_agent_config_with_reference_tool_validates(): + agent_config = CATALOG_TYPES["agent_config"] + + config = _base_agent_config() + config["tools"] = [ + { + "@ag.reference": { + "@ag.references": {"workflow": {"slug": "summarize"}}, + }, + "name": "summarize", + "description": "Summarize text", + "input_schema": {"type": "object", "properties": {}}, + } + ] + + jsonschema.validate(config, agent_config) + + +def test_agent_config_with_embed_tool_validates(): + agent_config = CATALOG_TYPES["agent_config"] + + config = _base_agent_config() + config["tools"] = [ + { + "@ag.embed": { + "@ag.references": {"workflow": {"slug": "my-client-tool"}}, + "@ag.selector": {"path": "parameters.tool"}, + } + } + ] + + jsonschema.validate(config, agent_config) From af638dd9659378fcac02df96d2bccc8a7e05645b Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 26 Jun 2026 17:02:55 +0200 Subject: [PATCH 2/3] fix(agent): reject malformed workflow tool slug via fullmatch --- api/oss/src/apis/fastapi/tools/router.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/oss/src/apis/fastapi/tools/router.py b/api/oss/src/apis/fastapi/tools/router.py index 20c7b2525f..3c7217cf97 100644 --- a/api/oss/src/apis/fastapi/tools/router.py +++ b/api/oss/src/apis/fastapi/tools/router.py @@ -1125,7 +1125,7 @@ async def _call_workflow_tool( ) slug = slug_parts[0] version = slug_parts[1] if len(slug_parts) == 2 else None - if not _SLUG_SEGMENT_RE.match(slug): + if not _SLUG_SEGMENT_RE.fullmatch(slug): raise HTTPException( status_code=400, detail=f"Invalid characters in workflow slug: {slug!r}", From 63318acfd36d06ab55c43d2394fe797eb1b39127 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sat, 27 Jun 2026 16:18:24 +0200 Subject: [PATCH 3/3] feat(agent): drop @ag.reference marker, keep type:"reference" tool with env/variant targeting Remove the @ag.reference marker machinery (AG_REFERENCE_MARKER + _coerce_reference_tool in the SDK, _ToolReferenceSchema in the catalog, the AG_REFERENCE_KEY 'leave it' guards in the embed resolver). A workflow referenced as a tool is now the plain type:"reference" ToolConfig arm. Add ref_by (variant/environment) + slug/version/environment targeting, wired through _call_workflow_tool to WorkflowServiceRequest.references. Keeps the /tools/call workflow.* routing and its slug .fullmatch validation intact. Claude-Session: https://claude.ai/code/session_01GYo3UEfvsZpncagqb28Mbc --- api/oss/src/apis/fastapi/tools/router.py | 102 +++++++++++------- api/oss/src/core/embeds/utils.py | 16 --- .../tests/pytest/unit/embeds/test_utils.py | 51 +++------ .../unit/tools/test_workflow_tool_call.py | 60 +++++++---- .../agent-workflows/documentation/tools.md | 57 +++++----- .../agent-workflows/interfaces/README.md | 2 +- .../cross-service/runner-to-tool-callback.md | 17 +-- .../in-service/tool-models-and-resolution.md | 43 ++++---- .../public-edge/agent-config-schema.md | 44 ++++---- .../agenta/sdk/agents/platform/resolve.py | 2 +- .../agenta/sdk/agents/platform/workflow.py | 12 +-- .../agenta/sdk/agents/tools/__init__.py | 2 - sdks/python/agenta/sdk/agents/tools/compat.py | 53 --------- .../agenta/sdk/agents/tools/interfaces.py | 2 +- sdks/python/agenta/sdk/agents/tools/models.py | 86 +++++++++++---- .../agenta/sdk/agents/tools/resolver.py | 8 +- sdks/python/agenta/sdk/utils/types.py | 46 ++------ .../agents/platform/test_workflow_resolver.py | 14 ++- .../pytest/unit/agents/tools/test_models.py | 39 ++++++- .../pytest/unit/agents/tools/test_parsing.py | 54 +++++----- .../pytest/unit/agents/tools/test_resolver.py | 10 +- .../pytest/unit/test_skill_config_catalog.py | 42 ++++++-- 22 files changed, 408 insertions(+), 354 deletions(-) diff --git a/api/oss/src/apis/fastapi/tools/router.py b/api/oss/src/apis/fastapi/tools/router.py index 3c7217cf97..fb846419a5 100644 --- a/api/oss/src/apis/fastapi/tools/router.py +++ b/api/oss/src/apis/fastapi/tools/router.py @@ -68,9 +68,10 @@ from oss.src.core.access.permissions.service import check_action_access from oss.src.apis.fastapi.shared.exceptions import FORBIDDEN_EXCEPTION -# A referenced-workflow (@ag.reference) agent tool's call_ref. Distinct from the Composio -# 5-segment grammar (``tools.{provider}.{integration}.{action}.{connection}``): the call back -# carries only the workflow identity, ``workflow.{slug}`` or ``workflow.{slug}.{version}``. +# A workflow referenced as an agent tool (a ``type:"reference"`` tool) carries this call_ref +# prefix. Distinct from the Composio 5-segment grammar (``tools.{provider}.{integration}. +# {action}.{connection}``): the callback encodes the targeting axis + identity — +# ``workflow.variant.{slug}[.{version}]`` or ``workflow.environment.{environment}.{slug}``. _WORKFLOW_CALL_REF_PREFIX = "workflow." _SLUG_SEGMENT_RE = re.compile(r"[a-zA-Z0-9_-]+") @@ -1083,53 +1084,81 @@ async def call_tool( return ToolCallResponse(call=result) + @staticmethod + def _validate_slug_segments(*, segments: List[str]) -> None: + """Reject any call_ref segment with characters outside the safe slug allowlist.""" + for segment in segments: + if not _SLUG_SEGMENT_RE.fullmatch(segment): + raise HTTPException( + status_code=400, + detail=f"Invalid characters in workflow tool segment: {segment!r}", + ) + async def _call_workflow_tool( self, *, request: Request, body: ToolCall, ) -> ToolCallResponse: - """Execute a referenced-workflow (@ag.reference) agent tool server-side. + """Execute a workflow referenced as an agent tool (``type:"reference"``) server-side. + + The model's call routes here via a ``workflow.{axis}.*`` call_ref. We parse the targeting + axis, invoke the selected workflow revision with the model's arguments as ``inputs``, and + return its outputs as the tool result. Connections/secrets the workflow needs stay + server-side (auth is minted from the caller's project + user), the same safety shape a + gateway tool has. - The model's call routes here via a ``workflow.{slug}[.{version}]`` call_ref. We invoke - that workflow revision with the model's arguments as ``inputs`` and return its outputs as - the tool result. Connections/secrets the workflow needs stay server-side (auth is minted - from the caller's project + user), the same safety shape a gateway tool has.""" + Grammar (after the ``workflow.`` prefix): + + - ``variant.{slug}`` / ``variant.{slug}.{version}`` — the workflow's latest revision by + slug, or a pinned revision. Mapped to the ``workflow`` reference family. + - ``environment.{environment}.{slug}`` — whatever revision is deployed in ``environment`` + for the workflow ``slug``. Mapped to the ``environment`` + ``workflow`` families (the + environment selects the revision via the derived ``{slug}.revision`` key).""" if self.workflows_service is None: raise HTTPException( status_code=501, detail="Workflow tools are not enabled on this deployment.", ) - # Parse ``workflow.{slug}`` or ``workflow.{slug}.{version}`` (slug is opaque; version, if - # present, is the trailing segment). Reject anything past the version. + invalid_call_ref = HTTPException( + status_code=400, + detail=( + f"Invalid workflow tool call_ref: {body.data.function.name}. " + "Expected: workflow.variant.{slug}[.{version}] or " + "workflow.environment.{environment}.{slug}" + ), + ) + remainder = body.data.function.name.replace("__", ".")[ len(_WORKFLOW_CALL_REF_PREFIX) : ] - slug_parts = remainder.split(".") - if not slug_parts or not slug_parts[0]: - raise HTTPException( - status_code=400, - detail=( - f"Invalid workflow tool call_ref: {body.data.function.name}. " - "Expected: workflow.{slug} or workflow.{slug}.{version}" - ), - ) - if len(slug_parts) > 2: - raise HTTPException( - status_code=400, - detail=( - f"Invalid workflow tool call_ref: {body.data.function.name}. " - "Expected: workflow.{slug} or workflow.{slug}.{version}" - ), - ) - slug = slug_parts[0] - version = slug_parts[1] if len(slug_parts) == 2 else None - if not _SLUG_SEGMENT_RE.fullmatch(slug): - raise HTTPException( - status_code=400, - detail=f"Invalid characters in workflow slug: {slug!r}", - ) + parts = remainder.split(".") + axis = parts[0] if parts else "" + operands = parts[1:] + + if axis == "variant": + # ``variant.{slug}`` (latest) or ``variant.{slug}.{version}`` (pinned). + if len(operands) not in (1, 2): + raise invalid_call_ref + slug = operands[0] + version = operands[1] if len(operands) == 2 else None + segments = [slug] + ([version] if version else []) + self._validate_slug_segments(segments=segments) + references = {"workflow": Reference(slug=slug, version=version)} + elif axis == "environment": + # ``environment.{environment}.{slug}`` — the environment pins the revision; the + # workflow ref supplies the ``{slug}.revision`` selector key the env lookup needs. + if len(operands) != 2: + raise invalid_call_ref + environment, slug = operands + self._validate_slug_segments(segments=[environment, slug]) + references = { + "environment": Reference(slug=environment), + "workflow": Reference(slug=slug), + } + else: + raise invalid_call_ref # Normalise arguments — the LLM may send them as a JSON string. arguments = body.data.function.arguments @@ -1142,11 +1171,8 @@ async def _call_workflow_tool( elif not isinstance(arguments, dict): arguments = {} - # Reference the artifact (latest revision) when no version is given; a versioned - # reference pins a specific revision. Same target-naming as the @ag.embed skills path. - workflow_ref = Reference(slug=slug, version=version) invoke_request = WorkflowServiceRequest( - references={"workflow": workflow_ref}, + references=references, data=WorkflowServiceRequestData(inputs=arguments), ) diff --git a/api/oss/src/core/embeds/utils.py b/api/oss/src/core/embeds/utils.py index c6ed717478..5f1ec30536 100644 --- a/api/oss/src/core/embeds/utils.py +++ b/api/oss/src/core/embeds/utils.py @@ -39,12 +39,6 @@ AG_EMBED_KEY = "@ag.embed" AG_REFERENCES_KEY = "@ag.references" AG_SELECTOR_KEY = "@ag.selector" -# The reference-syntax marker (sibling of @ag.embed). The generic resolver is tool-agnostic and -# only ever INLINES @ag.embed; it must LEAVE an @ag.reference in place so resolve_tools can build -# the callback spec from the kept reference. A node carrying @ag.reference is opaque to the -# resolver: it is not an embed, and the walker does not descend into it (so a nested @ag.embed -# inside a kept reference is not accidentally inlined). See the embedref-tools design. -AG_REFERENCE_KEY = "@ag.reference" SNIPPET_DEFAULT_PATH = "prompt.messages.0.content" # Entity hierarchy: category → ordered levels (shallow to deep) @@ -1067,10 +1061,6 @@ def find_object_embeds( visited.add(config_id) if isinstance(config, dict): - if AG_REFERENCE_KEY in config and AG_EMBED_KEY not in config: - # A kept @ag.reference node is opaque to the generic resolver: leave it in place and - # do not descend (the reference-syntax "leave it" branch). resolve_tools handles it. - return embeds if AG_EMBED_KEY in config: # Found an object embed embed_data = config[AG_EMBED_KEY] @@ -1167,9 +1157,6 @@ def find_string_embeds( visited.add(config_id) if isinstance(config, dict): - if AG_REFERENCE_KEY in config and AG_EMBED_KEY not in config: - # Leave a kept @ag.reference node opaque (do not scan it for string embeds either). - return embeds # Recurse into children for key, value in config.items(): child_path = f"{parent_path}.{key}" if parent_path else key @@ -1635,9 +1622,6 @@ def find_snippet_embeds( visited.add(config_id) if isinstance(config, dict): - if AG_REFERENCE_KEY in config and AG_EMBED_KEY not in config: - # Leave a kept @ag.reference node opaque (do not scan it for snippet embeds either). - return embeds for key, value in config.items(): child_path = f"{parent_path}.{key}" if parent_path else key diff --git a/api/oss/tests/pytest/unit/embeds/test_utils.py b/api/oss/tests/pytest/unit/embeds/test_utils.py index 498bff16d6..d095586e1a 100644 --- a/api/oss/tests/pytest/unit/embeds/test_utils.py +++ b/api/oss/tests/pytest/unit/embeds/test_utils.py @@ -158,63 +158,40 @@ def test_find_object_embeds_in_list(self): assert embeds[1].location == "items.1" -class TestAgReferenceLeftInPlace: - """The generic resolver is tool-agnostic: it INLINES @ag.embed but LEAVES @ag.reference. - - The reference-syntax "leave it" branch — a kept @ag.reference node (the embedref-tools - feature) is opaque to all three embed finders, so resolve_tools later builds the callback - spec from it. See ``api/oss/src/core/embeds/utils.py`` (``AG_REFERENCE_KEY``). +class TestReferenceToolIsPlainConfig: + """A workflow referenced as a tool is a plain ``type:"reference"`` config entry, not an embed + marker. The embed resolver has no special case for it: with no ``@ag.embed`` key, the finders + simply ignore it, and an ``@ag.embed`` sibling still resolves normally. """ - def test_object_finder_ignores_reference_node(self): + def test_object_finder_ignores_reference_tool_node(self): config = { "tools": [ { - "@ag.reference": { - AG_REFERENCES_KEY: {"workflow": {"slug": "summarize"}}, - }, + "type": "reference", + "ref_by": "variant", + "slug": "summarize", "name": "summarize", } ] } assert find_object_embeds(config) == [] - def test_object_finder_does_not_inline_nested_embed_in_reference(self): - # A @ag.embed nested INSIDE a kept @ag.reference must not be discovered/inlined: the whole - # reference node is opaque. + def test_string_and_snippet_finders_ignore_reference_tool_node(self): config = { "tools": [ - { - "@ag.reference": { - AG_REFERENCES_KEY: {"workflow": {"slug": "wf"}}, - "nested": { - AG_EMBED_KEY: { - AG_REFERENCES_KEY: {"workflow": {"slug": "inner"}} - } - }, - } - } + {"type": "reference", "ref_by": "variant", "slug": "wf"}, ] } - assert find_object_embeds(config) == [] - - def test_string_and_snippet_finders_ignore_reference_node(self): - config = { - "@ag.reference": { - AG_REFERENCES_KEY: {"workflow": {"slug": "wf"}}, - "note": "@ag.embed[@ag.references[workflow_revision.version=v1]]", - "snippet": "@{{workflow.slug=wf}}", - } - } assert find_string_embeds(config) == [] assert find_snippet_embeds(config) == [] - def test_sibling_embed_still_resolves_alongside_reference(self): - # A real @ag.embed sibling of a kept @ag.reference is still found and resolved; only the - # reference node is left. + def test_sibling_embed_still_resolves_alongside_reference_tool(self): + # An @ag.embed sibling of a type:"reference" tool is still found and resolved; the + # reference tool entry carries no embed so it is skipped. config = { "tools": [ - {"@ag.reference": {AG_REFERENCES_KEY: {"workflow": {"slug": "wf"}}}}, + {"type": "reference", "ref_by": "variant", "slug": "wf"}, ], "skills": [ {AG_EMBED_KEY: {AG_REFERENCES_KEY: {"workflow": {"slug": "skill"}}}} diff --git a/api/oss/tests/pytest/unit/tools/test_workflow_tool_call.py b/api/oss/tests/pytest/unit/tools/test_workflow_tool_call.py index 1c0cea8aff..96ad87ae26 100644 --- a/api/oss/tests/pytest/unit/tools/test_workflow_tool_call.py +++ b/api/oss/tests/pytest/unit/tools/test_workflow_tool_call.py @@ -1,11 +1,11 @@ -"""The ``/tools/call`` server-side execute branch for referenced-workflow (@ag.reference) tools. - -A kept @ag.reference agent tool resolves (SDK-side) to a ``callback`` spec whose call_ref is -``workflow.{slug}[.{version}]``. When the model calls it the runner POSTs the OpenAI tool-call -envelope back to ``/tools/call``; the router routes the ``workflow.*`` prefix to -``_call_workflow_tool``, which invokes the workflow revision with the model's arguments and -returns its outputs as the tool result. These tests exercise that handler directly with a fake -WorkflowsService (no DB, no live workflow service). +"""The ``/tools/call`` server-side execute branch for workflow-reference (type:"reference") tools. + +A type:"reference" agent tool resolves (SDK-side) to a ``callback`` spec whose call_ref is +``workflow.variant.{slug}[.{version}]`` or ``workflow.environment.{environment}.{slug}``. When the +model calls it the runner POSTs the OpenAI tool-call envelope back to ``/tools/call``; the router +routes the ``workflow.*`` prefix to ``_call_workflow_tool``, which invokes the selected workflow +revision with the model's arguments and returns its outputs as the tool result. These tests +exercise that handler directly with a fake WorkflowsService (no DB, no live workflow service). """ from __future__ import annotations @@ -70,14 +70,14 @@ def _call(name: str, arguments) -> ToolCall: ) -async def test_invokes_workflow_by_slug_and_returns_outputs(): +async def test_invokes_workflow_by_variant_slug_and_returns_outputs(): workflows = FakeWorkflowsService(outputs={"summary": "ok"}) router = _router(workflows) request = _request() response = await router._call_workflow_tool( request=request, - body=_call("workflow.summarize", {"text": "hello"}), + body=_call("workflow.variant.summarize", {"text": "hello"}), ) # One invoke, scoped to the caller's project + user. @@ -85,10 +85,11 @@ async def test_invokes_workflow_by_slug_and_returns_outputs(): call = workflows.calls[0] assert str(call["project_id"]) == request.state.project_id assert str(call["user_id"]) == request.state.user_id - # The reference targets the workflow artifact by slug; arguments ride as inputs. + # The variant axis targets the workflow by slug; arguments ride as inputs. refs = call["request"].references assert refs["workflow"].slug == "summarize" assert refs["workflow"].version is None + assert "environment" not in refs assert call["request"].data.inputs == {"text": "hello"} # The outputs come back as the tool result content (a JSON string). result = response.call @@ -97,23 +98,36 @@ async def test_invokes_workflow_by_slug_and_returns_outputs(): assert result.data.tool_call_id == "call_1" -async def test_versioned_call_ref_pins_revision(): +async def test_versioned_variant_call_ref_pins_revision(): workflows = FakeWorkflowsService(outputs=1) await _router(workflows)._call_workflow_tool( request=_request(), - body=_call("workflow.summarize.3", {}), + body=_call("workflow.variant.summarize.3", {}), ) refs = workflows.calls[0]["request"].references assert refs["workflow"].slug == "summarize" assert refs["workflow"].version == "3" +async def test_environment_axis_targets_environment_and_workflow(): + workflows = FakeWorkflowsService(outputs={"ok": True}) + await _router(workflows)._call_workflow_tool( + request=_request(), + body=_call("workflow.environment.production.summarize", {}), + ) + refs = workflows.calls[0]["request"].references + # The environment selects the revision; the workflow ref supplies the selector key slug. + assert refs["environment"].slug == "production" + assert refs["workflow"].slug == "summarize" + assert refs["workflow"].version is None + + async def test_double_underscore_call_ref_is_normalized(): # LLM function names forbid dots; the runner may send the __ form. workflows = FakeWorkflowsService(outputs={}) await _router(workflows)._call_workflow_tool( request=_request(), - body=_call("workflow__summarize", {}), + body=_call("workflow__variant__summarize", {}), ) assert workflows.calls[0]["request"].references["workflow"].slug == "summarize" @@ -122,7 +136,7 @@ async def test_json_string_arguments_are_parsed(): workflows = FakeWorkflowsService(outputs={}) await _router(workflows)._call_workflow_tool( request=_request(), - body=_call("workflow.wf", '{"a": 1}'), + body=_call("workflow.variant.wf", '{"a": 1}'), ) assert workflows.calls[0]["request"].data.inputs == {"a": 1} @@ -133,7 +147,7 @@ async def test_workflow_error_status_maps_to_error_result(): ) response = await _router(workflows)._call_workflow_tool( request=_request(), - body=_call("workflow.missing", {}), + body=_call("workflow.variant.missing", {}), ) assert response.call.status.code == "STATUS_CODE_ERROR" assert response.call.status.message == "no runnable service URL" @@ -144,7 +158,7 @@ async def test_invoke_exception_surfaces_as_502(): with pytest.raises(HTTPException) as caught: await _router(workflows)._call_workflow_tool( request=_request(), - body=_call("workflow.wf", {}), + body=_call("workflow.variant.wf", {}), ) assert caught.value.status_code == 502 @@ -153,7 +167,7 @@ async def test_missing_workflows_service_returns_501(): with pytest.raises(HTTPException) as caught: await _router(None)._call_workflow_tool( request=_request(), - body=_call("workflow.wf", {}), + body=_call("workflow.variant.wf", {}), ) assert caught.value.status_code == 501 @@ -161,9 +175,13 @@ async def test_missing_workflows_service_returns_501(): @pytest.mark.parametrize( "name", [ - "workflow.", # empty slug - "workflow.a.b.c", # too many segments - "workflow.bad slug", # invalid characters + "workflow.", # empty axis + "workflow.variant", # variant axis, no slug + "workflow.variant.wf.1.2", # variant axis, too many segments + "workflow.variant.bad slug", # invalid characters + "workflow.environment.production", # environment axis missing workflow slug + "workflow.environment.prod.wf.extra", # environment axis, too many segments + "workflow.unknown.wf", # unknown axis ], ) async def test_malformed_call_ref_rejected(name): diff --git a/docs/design/agent-workflows/documentation/tools.md b/docs/design/agent-workflows/documentation/tools.md index e41dda665a..eb0c2415dd 100644 --- a/docs/design/agent-workflows/documentation/tools.md +++ b/docs/design/agent-workflows/documentation/tools.md @@ -34,24 +34,25 @@ shares two fields through `ToolConfigBase`, and then a `type` discriminator pick | `gateway` | `provider`, `integration`, `action`, `connection`, optional `name` | A Composio action, like `github__create_issue` on a connected account. | | `code` | `name`, `runtime` (`python`/`node`), `script`, `input_schema`, `secrets` | An inline snippet the author writes, with named vault secrets injected. | | `client` | `name`, `input_schema` | A tool the browser fulfils, like "ask the user to pick a date." | -| `reference` | `slug`, optional `version`, optional `name`/`description`/`input_schema` | A workflow pointed at via the `@ag.reference` syntax (see below); the service runs the referenced workflow revision when the model calls it. | - -A tool can also be a **workflow** pointed at via one of two syntaxes (the embedref-tools model): - -- **`@ag.reference`** (new) — keep the reference. `coerce_tool_config` parses the kept marker - into a `ReferenceToolConfig` (`type: "reference"`, carrying the workflow `slug`/`version` plus - the model-facing surface), and `resolve_tools` turns it into a `CallbackToolSpec` whose - `call_ref` is `workflow.{slug}[.{version}]`. The model's call routes back to `POST /tools/call`, - which runs the workflow revision server-side. Connections/secrets the workflow needs stay - server-side, the same safety shape a gateway tool has. **No new runner `kind`.** -- **`@ag.embed`** (existing) — inline the value. The generic resolver resolves the reference into - a concrete `client` tool config *before* `resolve_tools` runs, so it rides the existing `client` - path (fulfilled in the browser). - -The author's syntax decides the behavior. The generic resolver (`ResolverMiddleware` + the API -embed resolver in `api/oss/src/core/embeds/utils.py`) stays tool-agnostic: it only INLINES an -`@ag.embed` and LEAVES an `@ag.reference` in place (`AG_REFERENCE_KEY`); all tool-specific mapping -lives in `resolve_tools`. +| `reference` | `ref_by` (`variant`/`environment`), `slug`, optional `environment`/`version`, optional `name`/`description`/`input_schema` | A workflow referenced as a tool (see below); the service runs the referenced workflow revision when the model calls it. | + +A tool can also be a **workflow** referenced as a tool: + +- **`type: "reference"`** — a `ReferenceToolConfig` (the author writes it as a plain `tools` entry, + no marker). It carries the workflow identity on one of two axes — `ref_by: "variant"` (the + workflow `slug`, latest revision or a pinned `version`) or `ref_by: "environment"` (whatever is + deployed in `environment` for `slug`) — plus the model-facing surface. `resolve_tools` turns it + into a `CallbackToolSpec` whose `call_ref` is `workflow.variant.{slug}[.{version}]` or + `workflow.environment.{environment}.{slug}`. The model's call routes back to `POST /tools/call`, + which runs the selected workflow revision server-side. Connections/secrets the workflow needs + stay server-side, the same safety shape a gateway tool has. **No new runner `kind`.** +- **`@ag.embed`** (a different feature) — inline a value. The generic resolver resolves the + reference into a concrete `client` tool config *before* `resolve_tools` runs, so it rides the + existing `client` path (fulfilled in the browser). Not surfaced in the tool-authoring UI. + +A `type: "reference"` tool is plain config, not a marker: the generic resolver (`ResolverMiddleware` ++ the API embed resolver in `api/oss/src/core/embeds/utils.py`) only ever INLINES `@ag.embed` and +has no special case for reference tools; all tool-specific mapping lives in `resolve_tools`. MCP servers are a sibling field, `AgentConfig.mcp_servers`, not a tool type. They are declared in `sdks/python/agenta/sdk/agents/mcp/models.py` and resolved alongside tools. They are @@ -82,7 +83,7 @@ because it is the seam between the two lives of a tool: | `gateway` | `CallbackToolSpec` with a `call_ref` slug | `callback` | | `code` | `CodeToolSpec` with secrets in `env` | `code` | | `client` | `ClientToolSpec` | `client` | -| `reference` | `CallbackToolSpec` with a `workflow.{slug}` `call_ref` | `callback` | +| `reference` | `CallbackToolSpec` with a `workflow.{axis}.*` `call_ref` | `callback` | The resolved specs are also defined in `tools/models.py` (`CallbackToolSpec`, `CodeToolSpec`, `ClientToolSpec`), and the matching TypeScript shape is `ResolvedToolSpec` in @@ -113,10 +114,10 @@ Resolution runs per type: `services/oss/src/agent/tools/secrets.py`) and injects the values into the spec's `env`. The script itself is not run here. - **Client** passes through to a `ClientToolSpec`. There is nothing to resolve server-side. -- **Reference** (a kept `@ag.reference` workflow) resolves through `AgentaWorkflowToolResolver` +- **Reference** (a `type: "reference"` workflow) resolves through `AgentaWorkflowToolResolver` (`sdks/python/agenta/sdk/agents/platform/workflow.py`). Unlike gateway it makes no HTTP round-trip — the reference is already concrete in the config — so it builds the - `CallbackToolSpec` (`call_ref = workflow.{slug}[.{version}]`) directly and assembles the shared + `CallbackToolSpec` (`call_ref = workflow.{axis}.*`) directly and assembles the shared `ToolCallback` to `{api}/tools/call`. Gateway and reference share the one `ToolCallback` (same endpoint, same per-request auth); the server-side `/tools/call` routes by the `call_ref` prefix (`tools.*` vs `workflow.*`). @@ -199,12 +200,16 @@ a credential. They only ever ask Agenta to run a named, pre-validated action. ### Reference (workflow) tools: the service runs the workflow -A `@ag.reference` workflow tool is the same callback shape with a different execute target. The -call_ref is `workflow.{slug}[.{version}]` instead of the Composio 5-segment slug. `POST /tools/call` +A `type: "reference"` workflow tool is the same callback shape with a different execute target. The +call_ref is `workflow.variant.{slug}[.{version}]` or `workflow.environment.{environment}.{slug}` +instead of the Composio 5-segment slug. `POST /tools/call` (`ToolsRouter.call_tool` in `api/oss/src/apis/fastapi/tools/router.py`) routes by the prefix: -`workflow.*` goes to `_call_workflow_tool`, which builds a `WorkflowServiceRequest` -(`references={"workflow": Reference(slug, version)}`, `data.inputs = `) and -calls `WorkflowsService.invoke_workflow(project_id, user_id, request)`. The auth is minted +`workflow.*` goes to `_call_workflow_tool`, which parses the targeting axis and builds a +`WorkflowServiceRequest` — the variant axis sets `references={"workflow": Reference(slug, version)}`; +the environment axis sets `references={"environment": Reference(slug=environment), +"workflow": Reference(slug)}` (the environment selects the deployed revision via the derived +`{slug}.revision` key) — with `data.inputs = `, and calls +`WorkflowsService.invoke_workflow(project_id, user_id, request)`. The auth is minted server-side from the caller's project + user, so any connections/secrets the workflow itself uses never leave the service — the same safety property gateway tools have. The workflow's `response.data.outputs` becomes the tool result content. The runner needs no new `kind`; it diff --git a/docs/design/agent-workflows/interfaces/README.md b/docs/design/agent-workflows/interfaces/README.md index 88d645d705..b33f2d24d8 100644 --- a/docs/design/agent-workflows/interfaces/README.md +++ b/docs/design/agent-workflows/interfaces/README.md @@ -48,7 +48,7 @@ page. `Status` is read from each page's prose: **stable** (wired and unlikely to | [`/run`](cross-service/service-to-agent-runner.md) | cross-service (the spine) | `protocol.ts`, `utils/wire.py`, `utils/ts_runner.py`, `server.ts`/`cli.ts` | stable (pinned by golden) | `unit/agents/test_wire_contract.py` + `golden/`, `services/agent/tests/unit/wire-contract.test.ts` | | [Runner to harness](cross-service/runner-to-harness.md) | cross-service (ACP) | `engines/sandbox_agent.ts` + `sandbox_agent/{run-plan,capabilities,permissions}.ts` | evolving | `services/agent/tests/unit/sandbox-agent-*.test.ts` | | [Runner to MCP server](cross-service/runner-to-mcp-server.md) | cross-service | `agents/mcp/`, `engines/sandbox_agent/mcp.ts`, `tools/{mcp-bridge,mcp-server,relay}.ts` | evolving (stdio wired; remote deferred) | `services/agent/tests/unit/mcp-servers.test.ts` | -| [Runner to tool callback](cross-service/runner-to-tool-callback.md) | cross-service | `tools/{callback,dispatch}.ts`, `apis/fastapi/tools/router.py`, `agent/tools/resolver.py` | stable | `services/agent/tests/unit/{code-tool,extension-tools}.test.ts` | +| [Runner to tool callback](cross-service/runner-to-tool-callback.md) | cross-service | `tools/{callback,dispatch}.ts`, `apis/fastapi/tools/router.py`, `agent/tools/resolver.py` | stable | `services/agent/tests/unit/{code-tool,extension-tools}.test.ts`, `api unit/tools/test_workflow_tool_call.py` | | [Service and runner trace export](cross-service/service-and-runner-trace-export.md) | cross-service | `agent/tracing.py`, `tracing/otel.ts`, `extensions/agenta.ts` | stable | `services/agent/tests/unit/` | | [Service to vault and tool providers](cross-service/service-to-vault-and-tool-providers.md) | cross-service (external) | `agent/app.py`, `platform/{resolve,connections}.py`, `agents/capabilities.py`, `tools/router.py` | stable | `unit/agents/connections/`, `unit/agents/platform/`, `unit/agents/tools/` | | [Agent service handler](in-service/agent-service-handler.md) | in-service | `services/oss/src/agent/app.py` | stable | `services/oss/tests/pytest/unit/agent/` | 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 8542a84e61..2bc6b2e0e5 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 @@ -45,9 +45,13 @@ The same `POST /tools/call` serves two kinds of callback tool, routed by the `ca - **`tools.{provider}.{integration}.{action}.{connection}`** — a Composio gateway action; the router re-resolves the connection and runs it through the provider adapter. -- **`workflow.{slug}`** / **`workflow.{slug}.{version}`** — a `@ag.reference` workflow tool; the - router (`_call_workflow_tool`) builds a `WorkflowServiceRequest` - (`references={"workflow": Reference(slug, version)}`, `data.inputs = arguments`) and calls +- **`workflow.variant.{slug}[.{version}]`** / **`workflow.environment.{environment}.{slug}`** — a + `type: "reference"` workflow tool; the router (`_call_workflow_tool`) parses the targeting axis + and builds a `WorkflowServiceRequest` — the variant axis sets + `references={"workflow": Reference(slug, version)}`; the environment axis sets + `references={"environment": Reference(slug=environment), "workflow": Reference(slug)}` (the + environment selects the deployed revision via the derived `{slug}.revision` key) — with + `data.inputs = arguments`, and calls `WorkflowsService.invoke_workflow(project_id, user_id, request)`. The workflow's `response.data.outputs` is serialized into `call.data.content`. Auth is minted server-side from the caller's project + user, so the workflow's own connections/secrets stay server-side — the @@ -66,9 +70,10 @@ resolver put on it. Only the router's prefix dispatch is aware of the two gramma ## Watch for when changing - **The tool slug format.** The `tools.{provider}.{integration}.{action}.{connection}` - reference, the `workflow.{slug}[.{version}]` reference, and the `__`/`.` normalization are a - paired contract across runner and router. The router dispatches by the `tools.*` vs - `workflow.*` prefix; keep the SDK resolvers and the router parser in agreement. + reference, the `workflow.{axis}.*` reference (`workflow.variant.{slug}[.{version}]` / + `workflow.environment.{environment}.{slug}`), and the `__`/`.` normalization are a paired + contract across runner and router. The router dispatches by the `tools.*` vs `workflow.*` + prefix; keep the SDK resolvers and the router parser in agreement. - **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/in-service/tool-models-and-resolution.md b/docs/design/agent-workflows/interfaces/in-service/tool-models-and-resolution.md index 48efaa8e62..404b69ba39 100644 --- a/docs/design/agent-workflows/interfaces/in-service/tool-models-and-resolution.md +++ b/docs/design/agent-workflows/interfaces/in-service/tool-models-and-resolution.md @@ -23,26 +23,27 @@ optional), and `render` (optional), discriminated by `type`: { "type": "client", "name": "pick_file", "description": "...", "input_schema": {} } -// reference: a workflow pointed at via @ag.reference; coerce_tool_config parses the kept -// marker into this. Runs the workflow revision server-side when the model calls it. -{ "type": "reference", "slug": "summarize", "version": null, - "name": "summarize", "description": "...", "input_schema": {} } +// reference: a workflow referenced as a tool (plain config, no marker). Runs the selected +// workflow revision server-side when the model calls it. ref_by "variant" (latest or a pinned +// `version`) or "environment" (whatever is deployed in `environment` for `slug`). +{ "type": "reference", "ref_by": "variant", "slug": "summarize", "version": null, + "environment": null, "name": "summarize", "description": "...", "input_schema": {} } ``` -A tool can also be a **workflow** the author points at via `@ag.reference` (keep the reference) -or `@ag.embed` (inline a client tool value). The author commits the marker dict; `@ag.reference` -coerces into the `reference` config above, while `@ag.embed` is inlined to a concrete `client` -config by the generic resolver before tool resolution runs. The author's syntax decides the -behavior; `resolve_tools` owns the tool-specific mapping. +A tool can also be a **workflow** referenced as a tool (`type: "reference"`, above). A separate +feature, `@ag.embed`, inlines a workflow value into a concrete `client` config — the generic +resolver does that inlining before tool resolution runs. The reference tool is plain config (not a +marker); `resolve_tools` owns the tool-specific mapping. ## Resolved spec types (what the runner gets) ```jsonc -// callback: a gateway tool OR a @ag.reference workflow tool; runs in Agenta via /tools/call. -// call_ref is the Composio 5-segment slug (tools.*) or the workflow identity (workflow.{slug}). +// callback: a gateway tool OR a type:"reference" workflow tool; runs in Agenta via /tools/call. +// call_ref is the Composio 5-segment slug (tools.*) or the workflow identity (workflow.{axis}.*). { "kind": "callback", "name": "...", "description": "...", "input_schema": {}, "call_ref": "tools.composio.github.create_issue.my-gh" } -// e.g. a referenced workflow tool: "call_ref": "workflow.summarize" (or "workflow.summarize.3") +// e.g. a referenced workflow tool: "call_ref": "workflow.variant.summarize" +// (or "workflow.variant.summarize.3", or "workflow.environment.production.summarize") // code: sandboxed code with its named secrets injected into env { "kind": "code", "name": "...", "runtime": "python", "code": "...", "env": { "API_KEY": "..." } } @@ -71,12 +72,12 @@ secret; their provider key stays server-side and the call routes back through `/ ## Owned by - `sdks/python/agenta/sdk/agents/tools/models.py`: the config and spec models (incl. - `ReferenceToolConfig`) and the permission derivation. -- `sdks/python/agenta/sdk/agents/tools/compat.py`: coerces a kept `@ag.reference` marker into a - `ReferenceToolConfig`. + `ReferenceToolConfig`, its `ref_by` axes, and `call_ref`) and the permission derivation. +- `sdks/python/agenta/sdk/agents/tools/compat.py`: coerces legacy/typed tool dicts (a + `type: "reference"` dict parses straight into `ReferenceToolConfig`). - `sdks/python/agenta/sdk/agents/platform/gateway.py`: gateway resolution to a `call_ref`. -- `sdks/python/agenta/sdk/agents/platform/workflow.py`: `@ag.reference` workflow resolution to a - `workflow.{slug}` callback spec. +- `sdks/python/agenta/sdk/agents/platform/workflow.py`: `type: "reference"` workflow resolution to + a `workflow.{axis}.*` callback spec. - `api/oss/src/apis/fastapi/tools/router.py`: `/tools/call` routes a `workflow.*` call_ref to `WorkflowsService.invoke_workflow` (the server-side execute path). - `services/oss/src/agent/tools/resolver.py`: the service entrypoint (re-exports the SDK). @@ -89,6 +90,8 @@ secret; their provider key stays server-side and the call routes back through `/ - **Secret injection for code tools.** Secrets ride `env` and are resolved once at parse time. - **Gateway call references.** The `call_ref` format is a paired contract with the tool endpoint. -- **Workflow call references.** A `@ag.reference` tool's `call_ref` is `workflow.{slug}` / - `workflow.{slug}.{version}`. The server-side `/tools/call` routes by the `tools.*` vs - `workflow.*` prefix; keep the SDK (`platform/workflow.py`) and the API parser in agreement. +- **Workflow call references.** A `type: "reference"` tool's `call_ref` is + `workflow.variant.{slug}[.{version}]` or `workflow.environment.{environment}.{slug}`. The + server-side `/tools/call` routes by the `tools.*` vs `workflow.*` prefix; keep the SDK + (`platform/workflow.py`) and the API parser (`_call_workflow_tool`) in agreement on the axis + grammar. diff --git a/docs/design/agent-workflows/interfaces/public-edge/agent-config-schema.md b/docs/design/agent-workflows/interfaces/public-edge/agent-config-schema.md index c785ce0318..dddfa8ae4a 100644 --- a/docs/design/agent-workflows/interfaces/public-edge/agent-config-schema.md +++ b/docs/design/agent-workflows/interfaces/public-edge/agent-config-schema.md @@ -21,7 +21,7 @@ The fields and the full schema follow. |---|---|---|---| | `agents_md` | string (textarea) | hello-world prompt | The agent's system prompt, its AGENTS.md. | | `model` | string (`grouped_choice`) | `"gpt-5.5"` | Model the agent runs on. A plain id (`"gpt-5.5"`) or a structured `{provider, connection}` ref. See [Model connection resolution](../in-service/model-connection-resolution.md). | -| `tools` | `(ToolConfig \| EmbedRef \| ReferenceRef)[]` | `[]` | Runnable tools: `builtin`, `gateway`, `code`, or `client` — or a workflow pointed at via `@ag.embed` (inline a client tool value) or `@ag.reference` (keep the reference; run the workflow server-side as a callback tool). See [Tool models and resolution](../in-service/tool-models-and-resolution.md). | +| `tools` | `(ToolConfig \| EmbedRef)[]` | `[]` | Runnable tools: `builtin`, `gateway`, `code`, `client`, or `reference` (a workflow referenced as a tool — `type: "reference"` — the service runs server-side as a callback tool). A workflow value can also be inlined via `@ag.embed`. See [Tool models and resolution](../in-service/tool-models-and-resolution.md). | | `mcp_servers` | `MCPServerConfig[]` | `[]` | Declared MCP servers; secret env resolved from the vault at run time. See [MCP models and resolution](../in-service/mcp-models-and-resolution.md). | | `harness` | `"pi_core" \| "claude" \| "pi_agenta"` (see slug+name note) | `"pi_core"` | The coding agent to drive. `pi_core` and `pi_agenta` both drive the `pi` ACP agent; `pi_agenta` adds Agenta's forced skills, prompt, and policy. | | `sandbox` | `"local" \| "daytona"` | `"local"` | Where it runs. | @@ -117,7 +117,7 @@ Either form is valid: "connection": { "mode": "agenta", "slug": "my-openai" } } // structured ref ``` -### `tools[]` (a concrete variant, or an `@ag.embed` / `@ag.reference` workflow) +### `tools[]` (a concrete variant — incl. `type: "reference"` — or an `@ag.embed` workflow) ```jsonc // builtin: a harness built-in by name @@ -137,23 +137,31 @@ Either form is valid: { "type": "client", "name": "pick_file", "description": "...", "input_schema": {}, "needs_approval": false, "permission": null, "render": null } -// @ag.reference: keep the reference; the service runs the referenced workflow revision -// server-side when the model calls it (resolves to a callback tool, key stays server-side). -// The model-facing surface (name/description/input_schema) and the tool axes ride as siblings. -{ "@ag.reference": { "@ag.references": { "workflow": { "slug": "summarize" } } }, +// reference (variant axis): the service runs the workflow's latest revision (or a pinned +// `version`) server-side when the model calls it. Resolves to a callback tool; key stays +// server-side. The model-facing surface (name/description/input_schema) and the tool axes ride +// as siblings. +{ "type": "reference", "ref_by": "variant", "slug": "summarize", "version": null, "name": "summarize", "description": "...", "input_schema": {}, "needs_approval": false, "permission": null, "render": null } -// @ag.embed: inline the referenced value into a concrete `client` tool config before the -// runner sees it (the backend's embed resolver does the inlining; rides the `client` path). +// reference (environment axis): the service runs whatever revision is deployed in `environment` +// for `slug`. `version` is not allowed (the environment is the pin). +{ "type": "reference", "ref_by": "environment", "environment": "production", "slug": "summarize", + "name": "summarize", "needs_approval": false, "permission": null, "render": null } + +// @ag.embed (a different feature, not in the tool-authoring UI): inline the referenced value into +// a concrete `client` tool config before the runner sees it (the backend's embed resolver does +// the inlining; rides the `client` path). { "@ag.embed": { "@ag.references": { "workflow": { "slug": "my-client-tool" } }, "@ag.selector": { "path": "parameters.tool" } } } ``` -The author's syntax decides the behavior: `@ag.reference` resolves to a server-side `callback` -tool (`call_ref = workflow.{slug}[.{version}]`); `@ag.embed` inlines to a `client` tool. The -generic resolver only inlines `@ag.embed` and leaves `@ag.reference`; `resolve_tools` owns the -tool-specific mapping. See [Tool models and resolution](../in-service/tool-models-and-resolution.md). +A `type: "reference"` tool resolves to a server-side `callback` tool (`call_ref = +workflow.variant.{slug}[.{version}]` or `workflow.environment.{environment}.{slug}`); `@ag.embed` +inlines to a `client` tool. A reference tool is plain config, not a marker — the generic resolver +only inlines `@ag.embed`, and `resolve_tools` owns the tool-specific mapping. See [Tool models and +resolution](../in-service/tool-models-and-resolution.md). `permission` is `"allow" | "ask" | "deny"`. When unset, it is derived: explicit value wins, then `needs_approval` to `"ask"`, then `read_only` (`true` to `"allow"`, `false` to `"ask"`), @@ -256,9 +264,9 @@ not `SKILL.md` itself. - **Nested shapes.** `tools`, `mcp_servers`, `skills`, and `sandbox_permission` each have their own page and their own wire fields. A change here usually means a change there and a golden fixture. -- **Embed references.** Skills can arrive as `@ag.embed`, and tools as `@ag.embed` or - `@ag.reference`. The schema must keep accepting these forms (the `tools` field is a union of - the concrete `ToolConfig` variants plus an `@ag.embed` arm and an `@ag.reference` arm), or a - valid config fails validation. The `@ag.reference` marker must be recognized in BOTH the SDK - `ResolverMiddleware` and the API embed resolver, and both must LEAVE it (not inline it) — a - miss in either inlines a reference and breaks the callback path. +- **Reference tools and embeds.** A workflow referenced as a tool is the `type: "reference"` arm + of the `ToolConfig` union (plain config, no marker). Skills can also arrive as `@ag.embed`, and + tools as `@ag.embed`. The schema must keep accepting these forms (the `tools` field is a union of + the concrete `ToolConfig` variants — including `reference` — plus an `@ag.embed` arm), or a + valid config fails validation. `@ag.embed` is a separate feature the generic resolver inlines; + it is not surfaced in the tool-authoring UI. diff --git a/sdks/python/agenta/sdk/agents/platform/resolve.py b/sdks/python/agenta/sdk/agents/platform/resolve.py index 6255c1258a..b2d2420199 100644 --- a/sdks/python/agenta/sdk/agents/platform/resolve.py +++ b/sdks/python/agenta/sdk/agents/platform/resolve.py @@ -63,7 +63,7 @@ async def resolve_tools( ) -> ResolvedToolSet: """Resolve tool declarations into runnable specs. Defaults to the Agenta platform adapters. - A kept ``@ag.reference`` workflow tool resolves through the ``workflow_resolver`` into a + A ``type:"reference"`` workflow tool resolves through the ``workflow_resolver`` into a ``callback`` spec (server-side workflow execute), the same executor a gateway tool uses.""" return await ToolResolver( secret_provider=secret_provider or AgentaNamedSecretProvider(), diff --git a/sdks/python/agenta/sdk/agents/platform/workflow.py b/sdks/python/agenta/sdk/agents/platform/workflow.py index d54f07455d..361d738172 100644 --- a/sdks/python/agenta/sdk/agents/platform/workflow.py +++ b/sdks/python/agenta/sdk/agents/platform/workflow.py @@ -1,6 +1,6 @@ -"""Agenta adapter for kept ``@ag.reference`` workflow tools. +"""Agenta adapter for ``type:"reference"`` workflow tools. -Turns a kept ``@ag.reference`` workflow declaration into a runnable ``callback`` spec and points +Turns a ``type:"reference"`` workflow declaration into a runnable ``callback`` spec and points its calls back at ``/tools/call``, exactly like the gateway adapter does for Composio actions. The difference from gateway is intrinsic, not transport: a workflow reference is already concrete in the config (the model-facing ``name`` / ``description`` / ``input_schema`` are authored), so @@ -8,9 +8,9 @@ backend base URL + per-request auth to assemble the shared ``ToolCallback``. When the model calls the tool the runner POSTs ``{data:{function:{name: call_ref, arguments}}}`` -to ``{api}/tools/call``; the server parses the ``workflow.{slug}[.{version}]`` ``call_ref``, -invokes that workflow revision with the model's arguments, and returns the result. Any -connections/secrets the workflow needs stay server-side — the gateway tool's safety shape. +to ``{api}/tools/call``; the server parses the ``workflow.{axis}.*`` ``call_ref``, invokes that +workflow revision with the model's arguments, and returns the result. Any connections/secrets the +workflow needs stay server-side — the gateway tool's safety shape. Lives in the SDK so the service and a connected standalone SDK user resolve workflow tools the same way. @@ -47,7 +47,7 @@ async def resolve( api_base = self._connection.base_url() if not api_base: error = GatewayToolResolutionError( - "Agent has workflow (@ag.reference) tools configured but the Agenta API " + "Agent has workflow (type:'reference') tools configured but the Agenta API " "base URL is unknown. Set AGENTA_AGENT_TOOLS_API_URL or AGENTA_API_URL." ) log.warning("agent: workflow tool resolution failed: %s", error) diff --git a/sdks/python/agenta/sdk/agents/tools/__init__.py b/sdks/python/agenta/sdk/agents/tools/__init__.py index b8e0a58070..6f35dd8cf1 100644 --- a/sdks/python/agenta/sdk/agents/tools/__init__.py +++ b/sdks/python/agenta/sdk/agents/tools/__init__.py @@ -18,7 +18,6 @@ ) from .interfaces import GatewayToolResolver, ToolSecretProvider, WorkflowToolResolver from .models import ( - AG_REFERENCE_MARKER, BuiltinToolConfig, CallbackToolSpec, ClientToolConfig, @@ -46,7 +45,6 @@ "CodeToolConfig", "ClientToolConfig", "ReferenceToolConfig", - "AG_REFERENCE_MARKER", "ToolSpec", "CallbackToolSpec", "CodeToolSpec", diff --git a/sdks/python/agenta/sdk/agents/tools/compat.py b/sdks/python/agenta/sdk/agents/tools/compat.py index 8a0bdb3b39..8ec0d581d1 100644 --- a/sdks/python/agenta/sdk/agents/tools/compat.py +++ b/sdks/python/agenta/sdk/agents/tools/compat.py @@ -8,7 +8,6 @@ from .errors import ToolConfigurationError from .models import ( - AG_REFERENCE_MARKER, BuiltinToolConfig, ClientToolConfig, CodeToolConfig, @@ -18,8 +17,6 @@ ) from .parsing import parse_tool_config -_AG_REFERENCES_KEY = "@ag.references" - class ToolConfigDiagnostic(BaseModel): model_config = ConfigDict(frozen=True) @@ -69,52 +66,6 @@ def _copy_tool_metadata( return result -def _parse_workflow_reference(refs: Any) -> Optional[dict[str, Any]]: - """Pull a ``{slug, version}`` from an ``@ag.references`` block keyed by ``workflow``. - - Mirrors the ``@ag.embed`` target-naming: an artifact-level ``workflow`` reference resolves to - the latest revision. A ``workflow_revision`` slug matches the revision's hash slug, not the - author-facing artifact slug, so the artifact ``workflow`` key is the supported shape (same - gotcha skills have). Returns ``None`` when no workflow reference is present.""" - if not isinstance(refs, dict): - return None - workflow = refs.get("workflow") - if not isinstance(workflow, dict): - return None - slug = workflow.get("slug") - if not isinstance(slug, str) or not slug: - return None - parsed: dict[str, Any] = {"slug": slug} - version = workflow.get("version") - if version is not None: - parsed["version"] = str(version) - return parsed - - -def _coerce_reference_tool(data: dict[str, Any]) -> Optional[ToolConfig]: - """Build a :class:`ReferenceToolConfig` from a kept ``@ag.reference`` marker dict. - - The author commits ``{"@ag.reference": {"@ag.references": {"workflow": {"slug": ...}}}, ...}`` - (the same inner target-naming as ``@ag.embed``, differing only in leave-vs-inline). The - model-facing surface (``name`` / ``description`` / ``input_schema``) rides as sibling keys of - the marker. Returns ``None`` when the marker is absent so other shapes fall through.""" - marker = data.get(AG_REFERENCE_MARKER) - if not isinstance(marker, dict): - return None - workflow_ref = _parse_workflow_reference(marker.get(_AG_REFERENCES_KEY)) - if workflow_ref is None: - raise ToolConfigurationError( - f"{AG_REFERENCE_MARKER} tool requires a workflow reference " - f"({_AG_REFERENCES_KEY}.workflow.slug)", - value=data, - ) - config: dict[str, Any] = {"type": "reference", **workflow_ref} - for key in ("name", "description", "input_schema"): - if data.get(key) is not None: - config[key] = data[key] - return parse_tool_config(_copy_tool_metadata(data, config)) - - def coerce_tool_config(value: Any) -> ToolConfig: """Convert one supported legacy shape into canonical tool configuration.""" if isinstance( @@ -141,10 +92,6 @@ def coerce_tool_config(value: Any) -> ToolConfig: data["type"] = "gateway" data.setdefault("provider", "composio") - reference_config = _coerce_reference_tool(data) - if reference_config is not None: - return reference_config - if data.get("type") in {"builtin", "gateway", "code", "client", "reference"}: return parse_tool_config(data) diff --git a/sdks/python/agenta/sdk/agents/tools/interfaces.py b/sdks/python/agenta/sdk/agents/tools/interfaces.py index 842b20fe5e..f56f04b326 100644 --- a/sdks/python/agenta/sdk/agents/tools/interfaces.py +++ b/sdks/python/agenta/sdk/agents/tools/interfaces.py @@ -25,7 +25,7 @@ async def resolve( self, tools: Sequence[ReferenceToolConfig], ) -> GatewayToolResolution: - """Resolve kept ``@ag.reference`` workflow declarations into callback specifications. + """Resolve ``type:"reference"`` workflow declarations into callback specifications. Returns the same shape as the gateway resolver (callback specs + the single shared :class:`ToolCallback` to the server-side execute endpoint) so a referenced workflow tool diff --git a/sdks/python/agenta/sdk/agents/tools/models.py b/sdks/python/agenta/sdk/agents/tools/models.py index 20ba2a304c..c24fc9791d 100644 --- a/sdks/python/agenta/sdk/agents/tools/models.py +++ b/sdks/python/agenta/sdk/agents/tools/models.py @@ -12,6 +12,7 @@ Field, TypeAdapter, field_validator, + model_validator, ) @@ -82,31 +83,75 @@ class ClientToolConfig(ToolConfigBase): input_schema: Dict[str, Any] = Field(default_factory=_empty_object_schema) -# The top-level marker an author writes to KEEP (not inline) a workflow reference as a tool. -# Sibling of ``@ag.embed`` (which inlines the referenced value). The generic resolver leaves an -# ``@ag.reference`` node untouched; ``resolve_tools`` then maps it to a ``CallbackToolSpec`` so -# the model's call runs the referenced workflow revision server-side, like a gateway tool. -AG_REFERENCE_MARKER = "@ag.reference" +# Which axis selects the referenced workflow revision. ``variant`` resolves the workflow by slug +# (latest revision, or a pinned ``version``); ``environment`` resolves whatever revision is +# deployed in a named environment. +ReferenceAxis = Literal["variant", "environment"] class ReferenceToolConfig(ToolConfigBase): - """A kept ``@ag.reference`` workflow tool (the reference syntax). + """A workflow referenced as a tool (the ``type:"reference"`` config). ``type`` is the synthetic discriminator ``"reference"`` so this arm lives in the canonical ``ToolConfig`` union; it is NOT a Composio-style declared variant (no provider/integration/ - action). It carries the workflow identity (``slug`` + optional ``version``) plus the - model-facing surface (``name`` / ``description`` / ``input_schema``). ``resolve_tools`` turns - it into a ``CallbackToolSpec`` whose ``call_ref`` encodes the workflow identity; the runner - dispatches the call back through the existing ``callback`` executor and the Agenta service runs - the workflow revision. Connections/secrets the workflow needs stay server-side.""" + action). The author points at a workflow on one of two axes: + + - ``ref_by="variant"`` — by workflow ``slug``; takes the latest revision, or pins one via + ``version``. + - ``ref_by="environment"`` — by ``environment`` slug; takes whatever revision is deployed in + that environment for the workflow ``slug`` (``version`` is not allowed, the environment is + the pin). + + The model-facing surface (``name`` / ``description`` / ``input_schema``) is authored. + ``resolve_tools`` turns it into a ``CallbackToolSpec`` whose ``call_ref`` encodes the axis + + identity; the runner dispatches the call through the existing ``callback`` executor and the + Agenta service runs the workflow revision server-side. Connections/secrets the workflow needs + stay server-side.""" type: Literal["reference"] = "reference" - slug: str = Field(min_length=1) - version: Optional[str] = None + ref_by: ReferenceAxis = Field( + default="variant", + description=( + "Which axis selects the workflow revision: 'variant' (by workflow slug; latest or a " + "pinned version) or 'environment' (whatever is deployed in `environment`)." + ), + ) + slug: str = Field( + min_length=1, + description="The workflow slug to reference.", + ) + environment: Optional[str] = Field( + default=None, + min_length=1, + description="Environment slug; required when ref_by == 'environment'.", + ) + version: Optional[str] = Field( + default=None, + description="Pin a workflow revision (ref_by='variant' only); absent = latest.", + ) name: Optional[str] = Field(default=None, min_length=1) description: Optional[str] = None input_schema: Dict[str, Any] = Field(default_factory=_empty_object_schema) + @model_validator(mode="after") + def _check_axis(self) -> "ReferenceToolConfig": + if self.ref_by == "environment": + if not self.environment: + raise ValueError( + "reference tool with ref_by='environment' requires `environment` " + "(the environment slug)" + ) + if self.version is not None: + raise ValueError( + "reference tool with ref_by='environment' must not set `version`; the " + "environment selects the deployed revision" + ) + elif self.environment is not None: + raise ValueError( + "reference tool with ref_by='variant' must not set `environment`" + ) + return self + @property def tool_name(self) -> str: """The model-visible name; defaults to the workflow slug when none is authored.""" @@ -114,14 +159,19 @@ def tool_name(self) -> str: @property def call_ref(self) -> str: - """The opaque ``workflow.{slug}`` / ``workflow.{slug}.{version}`` callback identity. + """The opaque ``workflow.{axis}.*`` callback identity the server-side ``/tools/call`` + parser routes by the ``workflow.`` prefix: + + - variant: ``workflow.variant.{slug}`` or ``workflow.variant.{slug}.{version}`` + - environment: ``workflow.environment.{environment}.{slug}`` Distinct from the Composio 5-segment grammar (``tools.{provider}.{integration}. - {action}.{connection}``). The runner treats this as opaque; only the server-side - ``/tools/call`` parser must agree on the ``workflow.*`` prefix.""" + {action}.{connection}``). The runner treats this as opaque.""" + if self.ref_by == "environment": + return f"workflow.environment.{self.environment}.{self.slug}" if self.version: - return f"workflow.{self.slug}.{self.version}" - return f"workflow.{self.slug}" + return f"workflow.variant.{self.slug}.{self.version}" + return f"workflow.variant.{self.slug}" ToolConfig = Annotated[ diff --git a/sdks/python/agenta/sdk/agents/tools/resolver.py b/sdks/python/agenta/sdk/agents/tools/resolver.py index fd3ed4c22a..ca1ab00545 100644 --- a/sdks/python/agenta/sdk/agents/tools/resolver.py +++ b/sdks/python/agenta/sdk/agents/tools/resolver.py @@ -169,10 +169,10 @@ async def resolve(self, tool_configs: Sequence[ToolConfig]) -> ResolvedToolSet: ) tool_callback: Optional[ToolCallback] = None - # A kept ``@ag.reference`` workflow tool resolves to the same ``callback`` executor as a - # gateway tool: a ``CallbackToolSpec`` (``call_ref = workflow.{slug}[.{version}]``) plus the - # single shared ``ToolCallback`` to the server-side execute endpoint. The runner needs no - # new ``kind``; the server-side ``/tools/call`` routes by the ``workflow.*`` prefix. + # A ``type:"reference"`` workflow tool resolves to the same ``callback`` executor as a + # gateway tool: a ``CallbackToolSpec`` (``call_ref = workflow.{axis}.*``) plus the single + # shared ``ToolCallback`` to the server-side execute endpoint. The runner needs no new + # ``kind``; the server-side ``/tools/call`` routes by the ``workflow.*`` prefix. if reference_configs: if self._workflow_resolver is None: raise UnsupportedToolProviderError("workflow") diff --git a/sdks/python/agenta/sdk/utils/types.py b/sdks/python/agenta/sdk/utils/types.py index da15074cb8..781a92321b 100644 --- a/sdks/python/agenta/sdk/utils/types.py +++ b/sdks/python/agenta/sdk/utils/types.py @@ -1124,17 +1124,15 @@ class AgentConfigSchema(AgSchemaMixin): description="Model the agent runs on.", json_schema_extra={"x-parameter": "grouped_choice"}, ) - tools: List[Union[ToolConfig, "_ToolEmbedRefSchema", "_ToolReferenceSchema"]] = ( - Field( - default_factory=list, - title="Tools", - description=( - "Runnable tools the agent can call: harness built-ins, server-side gateway " - "actions (e.g. Composio), sandboxed code, or client-fulfilled tools — or a " - "workflow pointed at via @ag.embed (inline a client tool value) or @ag.reference " - "(keep the reference; run the workflow server-side as a callback tool)." - ), - ) + tools: List[Union[ToolConfig, "_ToolEmbedRefSchema"]] = Field( + default_factory=list, + title="Tools", + description=( + "Runnable tools the agent can call: harness built-ins, server-side gateway " + "actions (e.g. Composio), sandboxed code, client-fulfilled tools, or a workflow " + "referenced as a tool (a type:'reference' entry the Agenta service runs server-side " + "as a callback tool). A workflow value can also be inlined via @ag.embed." + ), ) mcp_servers: List[MCPServerConfig] = Field( default_factory=list, @@ -1362,30 +1360,8 @@ class _ToolEmbedRefSchema(BaseModel): ) -class _ToolReferenceSchema(BaseModel): - """An ``@ag.reference`` marker standing in for one ``tools`` entry (the reference syntax). - - The new top-level marker (sibling of ``@ag.embed``): unlike embed, the generic resolver - LEAVES this reference in the config, and ``resolve_tools`` turns the kept reference into a - server-side ``callback`` tool that runs the referenced workflow revision. The marker body - stays permissive (``Dict[str, Any]``) — its inner ``@ag.references`` / ``@ag.selector`` keys - name the workflow target, the same way ``@ag.embed`` does. ``extra="allow"`` so the - model-facing surface (``name`` / ``description`` / ``input_schema``) and the tool axes - (``needs_approval`` / ``render`` / ``permission``) ride as sibling keys of the marker.""" - - model_config = ConfigDict(extra="allow", populate_by_name=True) - - reference: Dict[str, Any] = Field( - alias="@ag.reference", - title="Workflow reference", - description=( - "An @ag.reference marker kept in the config; resolve_tools runs the referenced " - "workflow revision server-side as a callback tool." - ), - ) - - -# Resolve the forward references on AgentConfigSchema.skills + tools (inline / embed / reference). +# Resolve the forward references on AgentConfigSchema.skills + tools (inline / embed). +# A workflow referenced as a tool is the ``type:"reference"`` arm of ``ToolConfig`` itself. AgentConfigSchema.model_rebuild() diff --git a/sdks/python/oss/tests/pytest/unit/agents/platform/test_workflow_resolver.py b/sdks/python/oss/tests/pytest/unit/agents/platform/test_workflow_resolver.py index 19267a106c..fea3db3682 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/platform/test_workflow_resolver.py +++ b/sdks/python/oss/tests/pytest/unit/agents/platform/test_workflow_resolver.py @@ -1,4 +1,4 @@ -"""The Agenta workflow tool resolver for kept ``@ag.reference`` tools. +"""The Agenta workflow tool resolver for ``type:"reference"`` tools. Unlike the gateway resolver this makes NO HTTP call: a workflow reference is already concrete in the config (the model-facing name/description/input_schema are authored), so the adapter builds @@ -46,7 +46,7 @@ async def test_builds_callback_spec_and_shared_callback(connection): assert len(resolution.tool_specs) == 1 spec = resolution.tool_specs[0] assert spec.kind == "callback" - assert spec.call_ref == "workflow.summarize" + assert spec.call_ref == "workflow.variant.summarize" assert spec.name == "summarize" assert spec.description == "Summarize text" assert spec.input_schema["properties"]["text"]["type"] == "string" @@ -61,11 +61,19 @@ async def test_versioned_slug_and_default_name(connection): [ReferenceToolConfig(slug="wf", version="3")] ) spec = resolution.tool_specs[0] - assert spec.call_ref == "workflow.wf.3" + assert spec.call_ref == "workflow.variant.wf.3" # No authored name -> the model-visible name defaults to the workflow slug. assert spec.name == "wf" +async def test_environment_axis_call_ref(connection): + resolution = await _resolver(connection).resolve( + [ReferenceToolConfig(ref_by="environment", environment="production", slug="wf")] + ) + spec = resolution.tool_specs[0] + assert spec.call_ref == "workflow.environment.production.wf" + + async def test_tool_axes_carry_onto_spec(connection): resolution = await _resolver(connection).resolve( [ diff --git a/sdks/python/oss/tests/pytest/unit/agents/tools/test_models.py b/sdks/python/oss/tests/pytest/unit/agents/tools/test_models.py index b052d12ea0..097616e5fc 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/tools/test_models.py +++ b/sdks/python/oss/tests/pytest/unit/agents/tools/test_models.py @@ -11,16 +11,45 @@ ) -def test_reference_tool_call_ref_grammar(): - # Slug-only -> workflow.{slug}; versioned -> workflow.{slug}.{version}. Distinct from the - # Composio 5-segment grammar so the server-side parser routes by the `workflow.` prefix. - assert ReferenceToolConfig(slug="wf").call_ref == "workflow.wf" - assert ReferenceToolConfig(slug="wf", version="2").call_ref == "workflow.wf.2" +def test_reference_tool_variant_call_ref_grammar(): + # variant axis -> workflow.variant.{slug}; pinned -> workflow.variant.{slug}.{version}. + # Distinct from the Composio 5-segment grammar so the server routes by the `workflow.` prefix. + assert ReferenceToolConfig(slug="wf").call_ref == "workflow.variant.wf" + assert ( + ReferenceToolConfig(slug="wf", version="2").call_ref == "workflow.variant.wf.2" + ) + # ref_by defaults to "variant". + assert ReferenceToolConfig(slug="wf").ref_by == "variant" # The model-visible name defaults to the slug when none is authored. assert ReferenceToolConfig(slug="wf").tool_name == "wf" assert ReferenceToolConfig(slug="wf", name="run").tool_name == "run" +def test_reference_tool_environment_call_ref_grammar(): + # environment axis -> workflow.environment.{environment}.{slug}; the environment is the pin. + config = ReferenceToolConfig( + ref_by="environment", environment="production", slug="wf" + ) + assert config.call_ref == "workflow.environment.production.wf" + + +def test_reference_tool_environment_requires_environment_slug(): + with pytest.raises(ValidationError): + ReferenceToolConfig(ref_by="environment", slug="wf") + + +def test_reference_tool_environment_forbids_version(): + with pytest.raises(ValidationError): + ReferenceToolConfig( + ref_by="environment", environment="production", slug="wf", version="2" + ) + + +def test_reference_tool_variant_forbids_environment_slug(): + with pytest.raises(ValidationError): + ReferenceToolConfig(ref_by="variant", environment="production", slug="wf") + + def test_reference_tool_discriminator_is_reference(): config = ReferenceToolConfig(slug="wf") assert config.type == "reference" diff --git a/sdks/python/oss/tests/pytest/unit/agents/tools/test_parsing.py b/sdks/python/oss/tests/pytest/unit/agents/tools/test_parsing.py index abcc739960..a9c9ed238f 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/tools/test_parsing.py +++ b/sdks/python/oss/tests/pytest/unit/agents/tools/test_parsing.py @@ -140,17 +140,14 @@ def test_default_compat_mode_raises_with_index(): assert caught.value.index == 1 -# --- @ag.reference workflow tool (the reference syntax) ----------------------- +# --- type:"reference" workflow tool ------------------------------------------ -def test_coerces_kept_ag_reference_marker_into_reference_tool(): - # The kept @ag.reference shape an author commits: the marker names the workflow target, and - # the model-facing surface (name/description/input_schema) rides as sibling keys. - tool = coerce_tool_config( +def test_typed_reference_config_round_trips(): + tool = parse_tool_config( { - "@ag.reference": { - "@ag.references": {"workflow": {"slug": "summarize"}}, - }, + "type": "reference", + "slug": "summarize", "name": "summarize", "description": "Summarize text", "input_schema": { @@ -160,31 +157,40 @@ def test_coerces_kept_ag_reference_marker_into_reference_tool(): } ) assert isinstance(tool, ReferenceToolConfig) + assert tool.ref_by == "variant" assert tool.slug == "summarize" assert tool.version is None - assert tool.call_ref == "workflow.summarize" + assert tool.call_ref == "workflow.variant.summarize" assert tool.tool_name == "summarize" assert tool.input_schema["properties"]["text"]["type"] == "string" -def test_ag_reference_version_builds_versioned_call_ref(): +def test_typed_reference_version_builds_versioned_call_ref(): + tool = coerce_tool_config({"type": "reference", "slug": "wf", "version": "3"}) + assert isinstance(tool, ReferenceToolConfig) + assert tool.call_ref == "workflow.variant.wf.3" + # No authored name -> the model-visible name defaults to the workflow slug. + assert tool.tool_name == "wf" + + +def test_typed_reference_environment_axis(): tool = coerce_tool_config( { - "@ag.reference": { - "@ag.references": {"workflow": {"slug": "wf", "version": "3"}} - } + "type": "reference", + "ref_by": "environment", + "environment": "production", + "slug": "wf", } ) assert isinstance(tool, ReferenceToolConfig) - assert tool.call_ref == "workflow.wf.3" - # No authored name -> the model-visible name defaults to the workflow slug. - assert tool.tool_name == "wf" + assert tool.call_ref == "workflow.environment.production.wf" -def test_ag_reference_carries_tool_axes(): +def test_typed_reference_carries_tool_axes(): tool = coerce_tool_config( { - "@ag.reference": {"@ag.references": {"workflow": {"slug": "wf"}}}, + "type": "reference", + "slug": "wf", "needs_approval": True, "render": {"kind": "component", "component": "Card"}, "permission": "ask", @@ -196,14 +202,6 @@ def test_ag_reference_carries_tool_axes(): assert tool.permission == "ask" -def test_ag_reference_without_workflow_slug_raises(): +def test_typed_reference_without_slug_raises(): with pytest.raises(ToolConfigurationError): - coerce_tool_config({"@ag.reference": {"@ag.references": {}}}) - with pytest.raises(ToolConfigurationError): - coerce_tool_config({"@ag.reference": {}}) - - -def test_typed_reference_config_round_trips(): - tool = parse_tool_config({"type": "reference", "slug": "wf", "name": "do_wf"}) - assert isinstance(tool, ReferenceToolConfig) - assert tool.call_ref == "workflow.wf" + coerce_tool_config({"type": "reference"}) diff --git a/sdks/python/oss/tests/pytest/unit/agents/tools/test_resolver.py b/sdks/python/oss/tests/pytest/unit/agents/tools/test_resolver.py index e9a13dfd1e..5ac853002b 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/tools/test_resolver.py +++ b/sdks/python/oss/tests/pytest/unit/agents/tools/test_resolver.py @@ -193,11 +193,11 @@ async def test_duplicate_model_visible_names_are_rejected(configs): await ToolResolver().resolve(configs) -# --- @ag.reference workflow tool resolution ---------------------------------- +# --- type:"reference" workflow tool resolution ------------------------------- async def test_reference_tool_resolves_to_callback_spec(): - # A kept @ag.reference workflow tool becomes a callback spec (server-side execute), the same + # A type:"reference" workflow tool becomes a callback spec (server-side execute), the same # executor a gateway tool uses, plus the shared ToolCallback to the execute endpoint. resolved = await ToolResolver(workflow_resolver=FakeWorkflowResolver()).resolve( [ @@ -216,13 +216,13 @@ async def test_reference_tool_resolves_to_callback_spec(): spec = resolved.tool_specs[0] assert isinstance(spec, CallbackToolSpec) assert spec.kind == "callback" - assert spec.call_ref == "workflow.summarize" + assert spec.call_ref == "workflow.variant.summarize" assert spec.name == "summarize" assert resolved.tool_callback.endpoint == "https://example/tools/call" # On the wire it is a `callback` spec carrying the workflow callRef — no new runner kind. wire = spec.to_wire() assert wire["kind"] == "callback" - assert wire["callRef"] == "workflow.summarize" + assert wire["callRef"] == "workflow.variant.summarize" async def test_reference_tool_requires_injected_resolver(): @@ -251,5 +251,5 @@ async def test_reference_and_gateway_share_one_callback(): ] ) call_refs = {spec.call_ref for spec in resolved.tool_specs} - assert "workflow.wf" in call_refs + assert "workflow.variant.wf" in call_refs assert resolved.tool_callback is not None diff --git a/sdks/python/oss/tests/pytest/unit/test_skill_config_catalog.py b/sdks/python/oss/tests/pytest/unit/test_skill_config_catalog.py index 22bc6077e5..f308f68b61 100644 --- a/sdks/python/oss/tests/pytest/unit/test_skill_config_catalog.py +++ b/sdks/python/oss/tests/pytest/unit/test_skill_config_catalog.py @@ -104,21 +104,43 @@ def test_inline_skill_entry_validates(): jsonschema.validate(config, agent_config) -# --- tools: @ag.embed (inline) + @ag.reference (kept) arms ------------------- +# --- tools: @ag.embed (inline) + type:"reference" arms ----------------------- + + +def _flatten_union(variants): + """Flatten nested anyOf/oneOf members (the concrete tool variants live in a discriminated + oneOf nested inside the tools anyOf).""" + for variant in variants: + nested = variant.get("anyOf") or variant.get("oneOf") + if nested: + yield from _flatten_union(nested) + else: + yield variant + + +def _type_const(variant): + """The discriminator const/enum of a tool union variant, if any.""" + type_schema = variant.get("properties", {}).get("type", {}) + if "const" in type_schema: + return type_schema["const"] + enum = type_schema.get("enum") + if isinstance(enum, list) and len(enum) == 1: + return enum[0] + return None def test_agent_config_tools_accepts_embed_and_reference_arms(): - """The tools field is a union: a concrete tool variant, an @ag.embed (inline a client tool), - or an @ag.reference (keep the reference; run the workflow server-side).""" + """The tools field is a union: a concrete tool variant (incl. type:"reference", a workflow + run server-side), or an @ag.embed (inline a client tool value).""" agent_config = CATALOG_TYPES["agent_config"] tools_item = agent_config["properties"]["tools"]["items"] - variants = tools_item["anyOf"] + variants = list(_flatten_union(tools_item["anyOf"])) - # The embed and reference arms are present alongside the concrete tool variants. + # The embed arm and the type:"reference" arm are present alongside the concrete tool variants. has_embed = any("@ag.embed" in v.get("properties", {}) for v in variants) - has_reference = any("@ag.reference" in v.get("properties", {}) for v in variants) + has_reference = any(_type_const(v) == "reference" for v in variants) assert has_embed, "tools union must include an @ag.embed arm" - assert has_reference, "tools union must include an @ag.reference arm" + assert has_reference, 'tools union must include a type:"reference" arm' def test_agent_config_with_reference_tool_validates(): @@ -127,9 +149,9 @@ def test_agent_config_with_reference_tool_validates(): config = _base_agent_config() config["tools"] = [ { - "@ag.reference": { - "@ag.references": {"workflow": {"slug": "summarize"}}, - }, + "type": "reference", + "ref_by": "variant", + "slug": "summarize", "name": "summarize", "description": "Summarize text", "input_schema": {"type": "object", "properties": {}},