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..fb846419a5 100644 --- a/api/oss/src/apis/fastapi/tools/router.py +++ b/api/oss/src/apis/fastapi/tools/router.py @@ -56,12 +56,24 @@ 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 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_-]+") log = get_module_logger(__name__) @@ -115,8 +127,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 +989,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 +1084,135 @@ 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 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. + + 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.", + ) + + 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) : + ] + 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 + 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 = {} + + invoke_request = WorkflowServiceRequest( + references=references, + 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/tests/pytest/unit/embeds/test_utils.py b/api/oss/tests/pytest/unit/embeds/test_utils.py index d362bd7dea..d095586e1a 100644 --- a/api/oss/tests/pytest/unit/embeds/test_utils.py +++ b/api/oss/tests/pytest/unit/embeds/test_utils.py @@ -158,6 +158,50 @@ def test_find_object_embeds_in_list(self): assert embeds[1].location == "items.1" +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_tool_node(self): + config = { + "tools": [ + { + "type": "reference", + "ref_by": "variant", + "slug": "summarize", + "name": "summarize", + } + ] + } + assert find_object_embeds(config) == [] + + def test_string_and_snippet_finders_ignore_reference_tool_node(self): + config = { + "tools": [ + {"type": "reference", "ref_by": "variant", "slug": "wf"}, + ] + } + assert find_string_embeds(config) == [] + assert find_snippet_embeds(config) == [] + + 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": [ + {"type": "reference", "ref_by": "variant", "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..96ad87ae26 --- /dev/null +++ b/api/oss/tests/pytest/unit/tools/test_workflow_tool_call.py @@ -0,0 +1,195 @@ +"""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 + +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_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.variant.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 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 + 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_variant_call_ref_pins_revision(): + workflows = FakeWorkflowsService(outputs=1) + await _router(workflows)._call_workflow_tool( + request=_request(), + 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__variant__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.variant.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.variant.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.variant.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.variant.wf", {}), + ) + assert caught.value.status_code == 501 + + +@pytest.mark.parametrize( + "name", + [ + "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): + 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..eb0c2415dd 100644 --- a/docs/design/agent-workflows/documentation/tools.md +++ b/docs/design/agent-workflows/documentation/tools.md @@ -34,6 +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` | `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 @@ -64,6 +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.{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 @@ -94,6 +114,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 `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.{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.*`). - **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 +198,23 @@ 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 `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 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 +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/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 50f4419dca..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 @@ -39,6 +39,27 @@ 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.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 + 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 +70,10 @@ 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.{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 dec4b5d12f..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 @@ -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,28 @@ optional), and `render` (optional), discriminated by `type`: "script": "...", "input_schema": {}, "secrets": ["API_KEY"] } { "type": "client", "name": "pick_file", "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** 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; runs in Agenta via /tools/call +// 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.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": "..." } } @@ -57,9 +71,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`, 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`: `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). ## Watch for when changing @@ -70,3 +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 `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 d74ea51204..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[]` | `[]` | Runnable tools: `builtin`, `gateway`, `code`, or `client`. 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[]` (one of four variants, discriminated by `type`) +### `tools[]` (a concrete variant — incl. `type: "reference"` — or an `@ag.embed` workflow) ```jsonc // builtin: a harness built-in by name @@ -136,8 +136,33 @@ 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 } + +// 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 } + +// 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" } } } ``` +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"`), else the global policy applies. See [Tool models and @@ -239,5 +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`. The schema must keep accepting that - form, or a valid default fails validation. +- **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/__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..b2d2420199 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 ``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(), 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..361d738172 --- /dev/null +++ b/sdks/python/agenta/sdk/agents/platform/workflow.py @@ -0,0 +1,90 @@ +"""Agenta adapter for ``type:"reference"`` workflow tools. + +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 +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.{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. +""" + +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 (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) + 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..6f35dd8cf1 100644 --- a/sdks/python/agenta/sdk/agents/tools/__init__.py +++ b/sdks/python/agenta/sdk/agents/tools/__init__.py @@ -16,7 +16,7 @@ ToolResolutionError, UnsupportedToolProviderError, ) -from .interfaces import GatewayToolResolver, ToolSecretProvider +from .interfaces import GatewayToolResolver, ToolSecretProvider, WorkflowToolResolver from .models import ( BuiltinToolConfig, CallbackToolSpec, @@ -27,6 +27,7 @@ GatewayToolConfig, GatewayToolResolution, MissingSecretPolicy, + ReferenceToolConfig, ResolvedToolSet, ToolCallback, ToolConfig, @@ -43,6 +44,7 @@ "GatewayToolConfig", "CodeToolConfig", "ClientToolConfig", + "ReferenceToolConfig", "ToolSpec", "CallbackToolSpec", "CodeToolSpec", @@ -54,6 +56,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..8ec0d581d1 100644 --- a/sdks/python/agenta/sdk/agents/tools/compat.py +++ b/sdks/python/agenta/sdk/agents/tools/compat.py @@ -12,6 +12,7 @@ ClientToolConfig, CodeToolConfig, GatewayToolConfig, + ReferenceToolConfig, ToolConfig, ) from .parsing import parse_tool_config @@ -74,6 +75,7 @@ def coerce_tool_config(value: Any) -> ToolConfig: GatewayToolConfig, CodeToolConfig, ClientToolConfig, + ReferenceToolConfig, ), ): return value @@ -90,7 +92,7 @@ def coerce_tool_config(value: Any) -> ToolConfig: data["type"] = "gateway" data.setdefault("provider", "composio") - if data.get("type") in {"builtin", "gateway", "code", "client"}: + 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..f56f04b326 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 ``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 + 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..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,12 +83,104 @@ class ClientToolConfig(ToolConfigBase): input_schema: Dict[str, Any] = Field(default_factory=_empty_object_schema) +# 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 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). 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" + 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.""" + return self.name or self.slug + + @property + def call_ref(self) -> str: + """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.""" + if self.ref_by == "environment": + return f"workflow.environment.{self.environment}.{self.slug}" + if self.version: + return f"workflow.variant.{self.slug}.{self.version}" + return f"workflow.variant.{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..ca1ab00545 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 ``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") + 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..781a92321b 100644 --- a/sdks/python/agenta/sdk/utils/types.py +++ b/sdks/python/agenta/sdk/utils/types.py @@ -1124,12 +1124,14 @@ class AgentConfigSchema(AgSchemaMixin): description="Model the agent runs on.", json_schema_extra={"x-parameter": "grouped_choice"}, ) - tools: List[ToolConfig] = Field( + 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, or client-fulfilled tools." + "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( @@ -1338,7 +1340,28 @@ 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.", + ) + + +# 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 new file mode 100644 index 0000000000..fea3db3682 --- /dev/null +++ b/sdks/python/oss/tests/pytest/unit/agents/platform/test_workflow_resolver.py @@ -0,0 +1,100 @@ +"""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 +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.variant.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.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( + [ + 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..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 @@ -7,9 +7,54 @@ CallbackToolSpec, CodeToolConfig, CodeToolSpec, + ReferenceToolConfig, ) +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" + + 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..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 @@ -5,6 +5,7 @@ from agenta.sdk.agents.tools import ( BuiltinToolConfig, GatewayToolConfig, + ReferenceToolConfig, ToolConfigurationError, coerce_tool_config, coerce_tool_configs, @@ -137,3 +138,70 @@ 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 + + +# --- type:"reference" workflow tool ------------------------------------------ + + +def test_typed_reference_config_round_trips(): + tool = parse_tool_config( + { + "type": "reference", + "slug": "summarize", + "name": "summarize", + "description": "Summarize text", + "input_schema": { + "type": "object", + "properties": {"text": {"type": "string"}}, + }, + } + ) + assert isinstance(tool, ReferenceToolConfig) + assert tool.ref_by == "variant" + assert tool.slug == "summarize" + assert tool.version is None + assert tool.call_ref == "workflow.variant.summarize" + assert tool.tool_name == "summarize" + assert tool.input_schema["properties"]["text"]["type"] == "string" + + +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( + { + "type": "reference", + "ref_by": "environment", + "environment": "production", + "slug": "wf", + } + ) + assert isinstance(tool, ReferenceToolConfig) + assert tool.call_ref == "workflow.environment.production.wf" + + +def test_typed_reference_carries_tool_axes(): + tool = coerce_tool_config( + { + "type": "reference", + "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_typed_reference_without_slug_raises(): + with pytest.raises(ToolConfigurationError): + 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 dddce819d2..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 @@ -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) + + +# --- type:"reference" workflow tool resolution ------------------------------- + + +async def test_reference_tool_resolves_to_callback_spec(): + # 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( + [ + 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.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.variant.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.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 6754985d36..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 @@ -102,3 +102,76 @@ def test_inline_skill_entry_validates(): ] jsonschema.validate(config, agent_config) + + +# --- 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 (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 = list(_flatten_union(tools_item["anyOf"])) + + # 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(_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 a type:"reference" arm' + + +def test_agent_config_with_reference_tool_validates(): + agent_config = CATALOG_TYPES["agent_config"] + + config = _base_agent_config() + config["tools"] = [ + { + "type": "reference", + "ref_by": "variant", + "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)