diff --git a/api/entrypoints/routers.py b/api/entrypoints/routers.py index 79b107cb93..8cfcabfd9b 100644 --- a/api/entrypoints/routers.py +++ b/api/entrypoints/routers.py @@ -979,6 +979,7 @@ async def _dispatch_detached_run(*, project_id, user_id, request) -> str: tools = ToolsRouter( tools_service=tools_service, workflows_service=workflows_service, + tracing_service=tracing_service, ) triggers = TriggersRouter( diff --git a/api/oss/src/apis/fastapi/applications/overlay.py b/api/oss/src/apis/fastapi/applications/overlay.py index c61a2379f5..f0b13de6fa 100644 --- a/api/oss/src/apis/fastapi/applications/overlay.py +++ b/api/oss/src/apis/fastapi/applications/overlay.py @@ -2,13 +2,27 @@ from typing import Any, Dict, List, Optional -from agenta.sdk.agents.adapters.agenta_builtins import GETTING_STARTED_WITH_AGENTA_SLUG -from agenta.sdk.agents.platform.op_catalog import PLATFORM_OPS +from agenta.sdk.agents.adapters.agenta_builtins import ( + AGENTA_FORCED_TOOLS, + BUILD_AN_AGENT_SLUG, +) + +from oss.src.core.workflows.static_catalog import StaticWorkflowCatalog -from oss.src.core.workflows.static_catalog import ( - STATIC_SLUG_PREFIX, - StaticWorkflowCatalog, - _STATIC_WORKFLOWS, +# Cut ops stay catalog opt-ins. +DEFAULT_BUILD_KIT_OPS: tuple[str, ...] = ( + "discover_tools", + "commit_revision", + "annotate_trace", + "query_spans", + "discover_triggers", + "create_schedule", + "create_subscription", + "list_schedules", + "list_deliveries", + "test_subscription", + "remove_schedule", + "remove_subscription", ) @@ -45,9 +59,7 @@ def _reserved_static_tool_embeds( revision or missing flags is skipped so an invalid tool embed can't leak into the playground. """ embeds: List[Dict[str, Any]] = [] - for slug in _STATIC_WORKFLOWS: - if not slug.startswith(STATIC_SLUG_PREFIX): - continue + for slug in catalog.list_slugs(): revision = catalog.retrieve_revision(slug=slug) if not revision or not revision.flags or revision.flags.is_skill: continue @@ -67,11 +79,11 @@ def build_agent_template_overlay() -> Dict[str, Any]: catalog = StaticWorkflowCatalog() skills: List[Dict[str, Any]] = [] - authoring_skill = catalog.retrieve_revision(slug=GETTING_STARTED_WITH_AGENTA_SLUG) + authoring_skill = catalog.retrieve_revision(slug=BUILD_AN_AGENT_SLUG) if authoring_skill: skills.append( _workflow_embed( - GETTING_STARTED_WITH_AGENTA_SLUG, + BUILD_AN_AGENT_SLUG, name=authoring_skill.name, selector_path="parameters.skill", ) @@ -79,7 +91,11 @@ def build_agent_template_overlay() -> Dict[str, Any]: return { "tools": [ - *[{"type": "platform", "op": op_name} for op_name in PLATFORM_OPS], + # Harness builtins must be granted by selection: any custom tool on the wire + # switches the runner's Pi builtin gating from "Pi defaults" to "granted only", + # and without `read` the skill below is announced but unloadable. + *[{"type": "builtin", "name": name} for name in AGENTA_FORCED_TOOLS], + *[{"type": "platform", "op": op_name} for op_name in DEFAULT_BUILD_KIT_OPS], *_reserved_static_tool_embeds(catalog), ], "skills": skills, diff --git a/api/oss/src/apis/fastapi/tools/models.py b/api/oss/src/apis/fastapi/tools/models.py index 144401a091..7ec9472dbf 100644 --- a/api/oss/src/apis/fastapi/tools/models.py +++ b/api/oss/src/apis/fastapi/tools/models.py @@ -138,7 +138,7 @@ class ToolResolveResponse(BaseModel): # --------------------------------------------------------------------------- -# Tool discovery (find_capabilities) +# Tool discovery (discover_tools) # --------------------------------------------------------------------------- diff --git a/api/oss/src/apis/fastapi/tools/router.py b/api/oss/src/apis/fastapi/tools/router.py index 703f17182c..de21f95e6c 100644 --- a/api/oss/src/apis/fastapi/tools/router.py +++ b/api/oss/src/apis/fastapi/tools/router.py @@ -49,11 +49,6 @@ # CapabilitiesResult, ) -from oss.src.core.tools.discovery import ( - AGENTA_TOOL_CALL_REF_PREFIX, - FIND_CAPABILITIES_OP, - parse_find_capabilities_arguments, -) from oss.src.core.tools.exceptions import ( ActionNotFoundError, AdapterError, @@ -69,6 +64,13 @@ ) from oss.src.core.gateway.connections.utils import decode_oauth_state from oss.src.core.workflows.service import WorkflowsService +from oss.src.core.tracing.service import TracingService +from oss.src.core.tools.exceptions import PlatformToolHandlerError +from oss.src.core.tools.platform_handlers import ( + dispatch_platform_tool_handler, + is_reserved_agenta_call_ref, + required_elevated_permission, +) from oss.src.core.workflows.dtos import ( WorkflowServiceRequest, WorkflowServiceRequestData, @@ -140,12 +142,14 @@ def __init__( *, tools_service: ToolsService, workflows_service: Optional[WorkflowsService] = None, + tracing_service: Optional[TracingService] = 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.tracing_service = tracing_service self.router = APIRouter() @@ -1092,20 +1096,14 @@ async def call_tool( # ``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 is_reserved_agenta_call_ref(call_ref): + return await self._call_reserved_agenta_tool( + request=request, + body=body, + call_ref=call_ref, + ) if call_ref.startswith(_WORKFLOW_CALL_REF_PREFIX): return await self._call_workflow_tool(request=request, body=body) - if call_ref.startswith(AGENTA_TOOL_CALL_REF_PREFIX): - # Reserved discovery tools expose per-project connection state, so gate them - # with VIEW_TOOLS at the boundary on top of the outer RUN_TOOLS check. - has_view_permission = await check_action_access( - user_uid=request.state.user_id, - project_id=request.state.project_id, - permission=Permission.VIEW_TOOLS, - ) - if not has_view_permission: - raise FORBIDDEN_EXCEPTION - return await self._call_agenta_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(".") @@ -1194,69 +1192,60 @@ async def call_tool( return ToolCallResponse(call=result) - async def _call_agenta_tool( + async def _call_reserved_agenta_tool( self, *, request: Request, body: ToolCall, + call_ref: str, ) -> ToolCallResponse: - """Run a reserved ``tools.agenta.*`` platform tool. v1 op: find_capabilities. - - Routed here from ``call_tool`` by the ``tools.agenta.`` prefix (so the reserved - tool is out of the Composio 5-segment namespace). Project scope comes from the - run's caller auth, exactly like the gateway path. - """ - call_ref = body.data.function.name.replace("__", ".") - op = call_ref[len(AGENTA_TOOL_CALL_REF_PREFIX) :] - if op != FIND_CAPABILITIES_OP: - raise HTTPException( - status_code=404, - detail=f"Unknown Agenta tool: {call_ref}", - ) - - arguments = body.data.function.arguments - if isinstance(arguments, str): - try: - arguments = json.loads(arguments) - except json.JSONDecodeError as e: - log.warning( - "Failed to parse find_capabilities arguments as JSON: %s", e - ) - arguments = {} - elif not isinstance(arguments, dict): - arguments = {} - - use_cases, provider, limit_alternatives = parse_find_capabilities_arguments( - arguments - ) - if not use_cases: - raise HTTPException( - status_code=400, - detail="find_capabilities requires at least one use_case", + # Some handlers demand an extra permission for specific argument shapes (e.g. a + # test_run delta needs EDIT_WORKFLOWS); the policy lives on the handler registration. + elevated_permission = required_elevated_permission( + call_ref=call_ref, + arguments=body.data.function.arguments, + ) + if elevated_permission is not None: + has_permission = await check_action_access( + user_uid=request.state.user_id, + project_id=request.state.project_id, + permission=elevated_permission, ) + if not has_permission: + raise FORBIDDEN_EXCEPTION try: - result = await self.tools_service.discover_capabilities( + response = await dispatch_platform_tool_handler( + call_ref=call_ref, + arguments=body.data.function.arguments, + headers=request.headers, project_id=UUID(request.state.project_id), - use_cases=use_cases, - provider_key=provider, - limit_alternatives=limit_alternatives, + user_id=UUID(request.state.user_id), + workflows_service=self.workflows_service, + tracing_service=self.tracing_service, ) - except DiscoveryUnsupportedError as e: - raise HTTPException(status_code=422, detail=e.message) from e + except PlatformToolHandlerError as e: + raise HTTPException(status_code=e.status_code, detail=e.message) from e - tool_result = ToolResult( + # A business-level failed verdict is still a successful tool call (OK); only an + # infrastructure failure (child invoke never completed) surfaces as an error status, + # mirroring the adapter path's successful/unsuccessful split. + result = ToolResult( id=uuid4(), data=ToolResultData( tool_call_id=body.data.id, - content=json.dumps(result.model_dump(mode="json", exclude_none=True)), + content=response.model_dump_json(exclude_none=True), ), status=Status( timestamp=datetime.now(timezone.utc), - code="STATUS_CODE_OK", + code="STATUS_CODE_ERROR" + if response.infra_failure + else "STATUS_CODE_OK", + message=response.verdict_reason if response.infra_failure else None, ), ) - return ToolCallResponse(call=tool_result) + + return ToolCallResponse(call=result) @staticmethod def _validate_slug_segments(*, segments: List[str]) -> None: diff --git a/api/oss/src/core/tools/discovery.py b/api/oss/src/core/tools/discovery.py index 5806d3f85d..3f331ab2c1 100644 --- a/api/oss/src/core/tools/discovery.py +++ b/api/oss/src/core/tools/discovery.py @@ -1,6 +1,6 @@ """Translate a Composio semantic search into the Agenta-native discovery contract. -These are the pure functions behind ``find_capabilities``: they take a parsed +These are the pure functions behind ``discover_tools``: they take a parsed ``ComposioSearchResult`` plus the project's per-integration connection state and produce a ``CapabilitiesResult``. No I/O, no provider strings leak to the agent. @@ -11,7 +11,7 @@ """ import re -from typing import Any, Dict, List, Optional, Set, Tuple +from typing import Dict, List, Optional, Set, Tuple from oss.src.core.tools.dtos import ( Capability, @@ -22,7 +22,6 @@ DiscoveredAlternative, DiscoveredTool, ToolConnectionState, - ToolProviderKind, ) from oss.src.core.tools.providers.composio.dtos import ( ComposioSearchQueryResult, @@ -30,74 +29,6 @@ ) -# --------------------------------------------------------------------------- -# The reserved agent-facing tool: tools.agenta.find_capabilities (D1) -# --------------------------------------------------------------------------- -# -# The agent calls this reserved tool; its call routes back through ``POST /tools/call`` -# (server-side, by the ``tools.agenta.`` prefix) to ``ToolsService.discover_capabilities``. -# It lives outside the Composio 5-segment namespace. The SDK-side declaration/resolution -# (how an agent config surfaces this tool and how ``platform.resolve_tools`` emits its -# ``CallbackToolSpec``) is a follow-up that rides the direct-call-tools platform-op seam; -# the runner forwards the call_ref opaquely, so it needs no change. - -AGENTA_TOOL_CALL_REF_PREFIX = f"tools.{ToolProviderKind.AGENTA.value}." -FIND_CAPABILITIES_OP = "find_capabilities" -FIND_CAPABILITIES_CALL_REF = f"{AGENTA_TOOL_CALL_REF_PREFIX}{FIND_CAPABILITIES_OP}" -FIND_CAPABILITIES_DESCRIPTION = ( - "Discover the Agenta tools that fit a set of plain-language use cases. Returns the " - "best-match tool per use case (with its input schema), companion/alternative tools, " - "each integration's connection state and how to connect it, and operating guidance. " - "Use it while wiring tools for an agent you are building." -) -FIND_CAPABILITIES_INPUT_SCHEMA: Dict[str, Any] = { - "type": "object", - "properties": { - "use_cases": { - "type": "array", - "items": {"type": "string"}, - "description": "One short fragment per capability the agent needs " - "(e.g. 'create a github issue').", - }, - "provider": { - "type": "string", - "default": ToolProviderKind.COMPOSIO.value, - "description": "Tool provider to search.", - }, - "limit_alternatives": { - "type": "integer", - "default": 3, - "minimum": 0, - "description": "Max alternative tools to return per use case.", - }, - }, - "required": ["use_cases"], -} - - -def parse_find_capabilities_arguments( - arguments: Dict[str, Any], -) -> Tuple[List[str], str, int]: - """Normalize the reserved tool's call arguments into discovery inputs. - - Returns ``(use_cases, provider, limit_alternatives)``. Drops blank fragments. - A bare string is treated as one use_case, never iterated character-by-character. - """ - raw_use_cases = arguments.get("use_cases") - if isinstance(raw_use_cases, str): - raw_use_cases = [raw_use_cases] - elif not isinstance(raw_use_cases, list): - raw_use_cases = [] - use_cases = [str(u).strip() for u in raw_use_cases if str(u).strip()] - provider = str(arguments.get("provider") or ToolProviderKind.COMPOSIO.value).strip() - limit_raw = arguments.get("limit_alternatives", 3) - try: - limit_alternatives = max(int(limit_raw), 0) - except (TypeError, ValueError): - limit_alternatives = 3 - return use_cases, provider, limit_alternatives - - # A use_case that reads like an event subscription rather than an action. Composio # has no semantic trigger search (research.md §4), so v1 scopes to action tools and # flags these for a follow-up trigger subscription (D5). @@ -122,7 +53,7 @@ def parse_find_capabilities_arguments( ) _TRIGGER_CAPABILITY_NOTE = ( - "This use case reads like a trigger (listening for events). find_capabilities " + "This use case reads like a trigger (listening for events). discover_tools " "covers action tools only; listening needs a trigger subscription, a separate " "Agenta setup that is a follow-up. The action tools below can still be attached." ) diff --git a/api/oss/src/core/tools/dtos.py b/api/oss/src/core/tools/dtos.py index 823382753b..ef6cfa8cc1 100644 --- a/api/oss/src/core/tools/dtos.py +++ b/api/oss/src/core/tools/dtos.py @@ -1,9 +1,12 @@ from enum import Enum from typing import Any, Dict, List, Literal, Optional, Union +from uuid import UUID from agenta.sdk.agents.tools import BuiltinToolConfig, GatewayToolConfig from agenta.sdk.models.workflows import JsonSchemas -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field + +from oss.src.core.workflows.dtos import WorkflowRevisionDelta from oss.src.core.gateway.catalog.dtos import ( CatalogCategory, @@ -231,10 +234,95 @@ class ToolsResolution(BaseModel): # --------------------------------------------------------------------------- -# Tool discovery (find_capabilities) — Agenta-native response contract +# Platform tool handlers (reserved ``tools.agenta.*`` ops) — test_run contract +# --------------------------------------------------------------------------- +# +# The request/response contract for the server-side ``test_run`` handler (see +# ``core/tools/platform_handlers.py`` for the orchestration and the registry). +# Every model forbids extra fields: the arguments come straight from a model +# tool call and must not smuggle unknown keys. + + +class TestRunTarget(BaseModel): + """The workflow variant under test. Bound from run context by the runner + (``$ctx.workflow.variant.id``), never chosen by the model.""" + + model_config = ConfigDict(extra="forbid") + + workflow_variant_id: UUID + + +class TestRunInputs(BaseModel): + model_config = ConfigDict(extra="forbid") + + messages: List[Dict[str, Any]] + + +class TestRunExpectations(BaseModel): + """Checks that define a passing run. Without a ``terminal_tool`` the verdict can + never be ``pass``, only ``unconfirmed``.""" + + model_config = ConfigDict(extra="forbid") + + terminal_tool: Optional[str] = None + + +class TestRunRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + target: TestRunTarget + inputs: TestRunInputs + # In-memory only: the delta is applied to the resolved revision for this one run and + # never committed. Its scope is restricted to the ``parameters`` tree (enforced by the + # handler) so it cannot redirect the child invoke. + delta: Optional[WorkflowRevisionDelta] = None + expectations: Optional[TestRunExpectations] = None + + +class TestRunToolDigest(BaseModel): + """Per-tool observation, merged from transcript messages and trace spans. + + ``error`` is excluded from the payload: it only feeds the verdict, so a transient + span-read failure cannot leak a false error flag to the model.""" + + name: str + called: bool = True + returned: bool = False + error: bool = Field(default=False, exclude=True) + + +class TestRunResolved(BaseModel): + """Execution metadata resolved by the child run (read from its spans).""" + + harness: Optional[str] = None + model: Optional[str] = None + provider: Optional[str] = None + connection_mode: Optional[str] = None + + +TestRunVerdict = Literal["pass", "incomplete", "unconfirmed", "failed"] + + +class TestRunResponse(BaseModel): + output: str = "" + tools: List[TestRunToolDigest] = Field(default_factory=list) + approvals: List[str] = Field(default_factory=list) + resolved: TestRunResolved = Field(default_factory=TestRunResolved) + trace_id: Optional[str] = None + test_id: Optional[str] = None + verdict: TestRunVerdict + verdict_reason: Optional[str] = None + # Excluded from the payload: distinguishes "the child invoke never completed" + # (timeout / non-2xx / no output) from a business-level failed verdict, so the API + # boundary can set the outer ToolResult status accordingly. + infra_failure: bool = Field(default=False, exclude=True) + + +# --------------------------------------------------------------------------- +# Tool discovery (discover_tools) — Agenta-native response contract # --------------------------------------------------------------------------- # -# ``find_capabilities`` translates a Composio semantic search into Agenta terms so +# ``discover_tools`` translates a Composio semantic search into Agenta terms so # the agent never sees Composio. See ``docs/design/agent-workflows/projects/ # tool-discovery/design.md`` for the field-by-field mapping and the connection # state machine, and ``core/tools/discovery.py`` for the translation itself. @@ -327,7 +415,7 @@ class CapabilityGuidance(BaseModel): class CapabilitiesResult(BaseModel): - """The ``find_capabilities`` response (Agenta-native).""" + """The ``discover_tools`` response (Agenta-native).""" capabilities: List[Capability] = Field(default_factory=list) connections: List[ConnectionRequirement] = Field(default_factory=list) diff --git a/api/oss/src/core/tools/exceptions.py b/api/oss/src/core/tools/exceptions.py index 8f5bcf6ff4..7b3d442958 100644 --- a/api/oss/src/core/tools/exceptions.py +++ b/api/oss/src/core/tools/exceptions.py @@ -172,3 +172,31 @@ def __init__( if detail: msg += f": {detail}" super().__init__(msg) + + +class PlatformToolHandlerError(ToolsError): + """Base for reserved ``tools.agenta.*`` handler failures. + + ``status_code`` is the HTTP status the API boundary maps the error to; subclasses + override it instead of raising ``HTTPException`` from the core layer. + """ + + status_code = 400 + + +class PlatformToolHandlerNotFound(PlatformToolHandlerError): + """Raised when a reserved call_ref has no registered handler.""" + + status_code = 404 + + +class PlatformToolHandlerUnavailable(PlatformToolHandlerError): + """Raised when a handler exists but its backing service is not wired on this deployment.""" + + status_code = 501 + + +class PlatformToolHandlerRefused(PlatformToolHandlerError): + """Raised when a request is well-formed but violates a handler policy (e.g. recursion).""" + + status_code = 400 diff --git a/api/oss/src/core/tools/platform_handlers.py b/api/oss/src/core/tools/platform_handlers.py new file mode 100644 index 0000000000..2384809d36 --- /dev/null +++ b/api/oss/src/core/tools/platform_handlers.py @@ -0,0 +1,672 @@ +"""Server-side handlers for reserved ``tools.agenta.*`` tool calls. + +Handler-mode platform ops route through ``POST /tools/call`` like gateway tools, but their +business logic runs behind a registered Python handler instead of a provider adapter. The +module enforces three constraints: + +- Only call_refs in ``PLATFORM_TOOL_HANDLERS`` dispatch; anything else in the reserved + namespace is a 404 (`PlatformToolHandlerNotFound`), never a fall-through to a provider. +- A handler may demand an extra permission for specific argument shapes (the elevation + policy on its registration); the API boundary checks it via + :func:`required_elevated_permission` before dispatching. +- ``test_run`` refuses recursion (a child test_run marked via ``x-agenta-run-kind``) and + confines revision deltas to the ``parameters`` tree so a caller can never redirect the + server-side child invoke (or its minted credentials) to another endpoint. + +Contracts (``TestRun*``) live in ``core/tools/dtos.py``; exceptions in +``core/tools/exceptions.py``. +""" + +from __future__ import annotations + +import asyncio +import json +from dataclasses import dataclass +from typing import Any, Awaitable, Callable, Dict, List, Optional +from uuid import UUID + +import httpx +from pydantic import ValidationError + +from agenta.sdk.engines.tracing.propagation import inject +from agenta.sdk.models.workflows import WorkflowServiceStatus + +from oss.src.core.access.permissions.types import Permission +from oss.src.core.shared.dtos import Reference, Windowing +from oss.src.core.tools.dtos import ( + TestRunExpectations, + TestRunRequest, + TestRunResolved, + TestRunResponse, + TestRunToolDigest, + TestRunVerdict, +) +from oss.src.core.tools.exceptions import ( + PlatformToolHandlerError, + PlatformToolHandlerNotFound, + PlatformToolHandlerRefused, + PlatformToolHandlerUnavailable, +) +from oss.src.core.tracing.dtos import ( + Condition, + Filtering, + Formatting, + Focus, + TracingQuery, +) +from oss.src.core.tracing.service import TracingService +from oss.src.core.workflows.dtos import ( + WorkflowRevisionCommit, + WorkflowRevisionDelta, + WorkflowServiceBatchResponse, + WorkflowServiceRequest, + WorkflowServiceRequestData, +) +from oss.src.core.workflows.service import WorkflowsService + +AGENTA_TOOL_CALL_REF_PREFIX = "tools.agenta." +TEST_RUN_CALL_REF = "tools.agenta.test_run" +TEST_RUN_DEFAULT_TIMEOUT_MS = 120_000 +TEST_RUN_SERVER_TIMEOUT_CEILING_MS = 120_000 +TEST_RUN_RECURSION_HEADER = "x-agenta-run-kind" +TEST_RUN_RECURSION_VALUE = "test" + +# A test_run delta may only touch this subtree of the revision data. Everything else +# (``url``, ``uri``, ``headers``, ``script``) changes where or how the child invoke +# executes, which would let an EDIT_WORKFLOWS caller point the server-side POST at an +# arbitrary endpoint. +_DELTA_ALLOWED_ROOT = "parameters" + + +def is_reserved_agenta_call_ref(call_ref: str) -> bool: + return call_ref.startswith(AGENTA_TOOL_CALL_REF_PREFIX) + + +# --------------------------------------------------------------------------- +# test_run — run the target workflow variant once, headlessly, and digest the +# outcome (parse -> resolve revision -> invoke child -> digest -> verdict). +# --------------------------------------------------------------------------- + + +async def handle_test_run( + *, + arguments: Any, + headers: Any, + project_id: UUID, + user_id: UUID, + workflows_service: Optional[WorkflowsService], + tracing_service: Optional[TracingService], + timeout_ms: int = TEST_RUN_DEFAULT_TIMEOUT_MS, +) -> TestRunResponse: + if _header_value(headers, TEST_RUN_RECURSION_HEADER) == TEST_RUN_RECURSION_VALUE: + raise PlatformToolHandlerRefused( + "test_run refused: recursive test runs are not allowed." + ) + if workflows_service is None: + raise PlatformToolHandlerUnavailable( + "test_run is not enabled on this deployment: workflows service is missing." + ) + + request = _parse_test_run_arguments(arguments) + workflow_request = await _build_test_workflow_request( + workflows_service=workflows_service, + project_id=project_id, + request=request, + ) + + meta = dict(workflow_request.meta or {}) + # Recursion marker mechanism: the child invoke body carries meta.run_kind="test". The + # runner-side 5b half forwards that run kind to `/tools/call` as x-agenta-run-kind, and this + # handler refuses that marked request before any child invoke can start. + meta["run_kind"] = TEST_RUN_RECURSION_VALUE + workflow_request.meta = meta + + credentials, service_url = await workflows_service._prepare_invoke( + project_id=project_id, + user_id=user_id, + request=workflow_request, + ) + if not service_url: + return _failed_response("Workflow revision has no runnable service URL.") + + effective_timeout_ms = min(timeout_ms, TEST_RUN_SERVER_TIMEOUT_CEILING_MS) + response = await _invoke_child_workflow( + workflows_service=workflows_service, + service_url=service_url, + credentials=credentials, + request=workflow_request, + timeout_ms=effective_timeout_ms, + ) + + return await _digest_test_run_response( + response=response, + tracing_service=tracing_service, + project_id=project_id, + expectations=request.expectations, + ) + + +def _header_value(headers: Any, name: str) -> Optional[str]: + if headers is None: + return None + if hasattr(headers, "get"): + value = headers.get(name) or headers.get(name.lower()) + return str(value).lower() if value is not None else None + return None + + +def _parse_test_run_arguments(arguments: Any) -> TestRunRequest: + if isinstance(arguments, str): + try: + arguments = json.loads(arguments) + except json.JSONDecodeError as e: + raise PlatformToolHandlerError( + "test_run arguments must be valid JSON." + ) from e + if not isinstance(arguments, dict): + raise PlatformToolHandlerError("test_run arguments must be a JSON object.") + try: + request = TestRunRequest.model_validate(arguments) + except ValidationError as e: + raise PlatformToolHandlerError(f"Invalid test_run arguments: {e}") from e + if request.delta is not None: + _validate_delta_scope(request.delta) + return request + + +def _validate_delta_scope(delta: WorkflowRevisionDelta) -> None: + out_of_scope = sorted(set(delta.set or {}) - {_DELTA_ALLOWED_ROOT}) + if out_of_scope: + raise PlatformToolHandlerRefused( + "test_run delta may only set the revision's " + f"'{_DELTA_ALLOWED_ROOT}' tree (got: {', '.join(out_of_scope)})." + ) + for path in delta.remove or []: + if path != _DELTA_ALLOWED_ROOT and not path.startswith( + f"{_DELTA_ALLOWED_ROOT}." + ): + raise PlatformToolHandlerRefused( + "test_run delta may only remove paths under the revision's " + f"'{_DELTA_ALLOWED_ROOT}' tree (got: {path})." + ) + + +async def _build_test_workflow_request( + *, + workflows_service: WorkflowsService, + project_id: UUID, + request: TestRunRequest, +) -> WorkflowServiceRequest: + workflow_request = WorkflowServiceRequest( + references={ + "workflow_variant": Reference(id=request.target.workflow_variant_id), + }, + data=WorkflowServiceRequestData(inputs={"messages": request.inputs.messages}), + ) + + # Resolving the committed revision first (even with a delta) is the target validation: + # a delta can only ever be applied on top of a variant that exists in THIS project. + await workflows_service._ensure_request_revision( + project_id=project_id, + request=workflow_request, + ) + if not workflow_request.data or not workflow_request.data.revision: + raise PlatformToolHandlerError( + "test_run could not resolve the target workflow variant revision." + ) + + if request.delta is not None: + resolved = await workflows_service._resolve_revision_delta( + project_id=project_id, + workflow_revision_commit=WorkflowRevisionCommit( + workflow_variant_id=request.target.workflow_variant_id, + delta=request.delta, + ), + ) + if resolved.data is None: + raise PlatformToolHandlerError( + "test_run could not resolve the revision delta." + ) + workflow_request.data.revision = {"data": resolved.data.model_dump(mode="json")} + + return workflow_request + + +async def _invoke_child_workflow( + *, + workflows_service: WorkflowsService, + service_url: str, + credentials: str, + request: WorkflowServiceRequest, + timeout_ms: int, +) -> WorkflowServiceBatchResponse: + payload = request.model_dump(mode="json", exclude_none=True) + timeout_s = max(timeout_ms / 1000, 0.001) + headers = inject( + { + "Authorization": credentials, + "Content-Type": "application/json", + "Accept": "application/json", + } + ) + try: + async with httpx.AsyncClient( + timeout=httpx.Timeout(timeout_s), + follow_redirects=True, + ) as client: + raw_response = await client.post( + f"{service_url}/invoke", + json=payload, + headers=headers, + ) + except httpx.TimeoutException: + return WorkflowServiceBatchResponse( + status=WorkflowServiceStatus( + code=504, + message=f"test_run timed out after {timeout_ms}ms.", + ) + ) + except httpx.HTTPError as e: + return WorkflowServiceBatchResponse( + status=WorkflowServiceStatus( + code=502, message=f"test_run invoke failed: {e}" + ) + ) + + body = None + try: + parsed = raw_response.json() + if isinstance(parsed, dict): + body = parsed + except Exception: + body = None + + return workflows_service._coerce_invoke_response(response=raw_response, body=body) + + +async def _digest_test_run_response( + *, + response: WorkflowServiceBatchResponse, + tracing_service: Optional[TracingService], + project_id: UUID, + expectations: Optional[TestRunExpectations], +) -> TestRunResponse: + status_code = response.status.code if response.status else None + status_message = response.status.message if response.status else None + if status_code is not None and (status_code < 200 or status_code >= 300): + return _failed_response( + status_message or f"Workflow service returned status {status_code}.", + trace_id=response.trace_id, + ) + + outputs = response.data.outputs if response.data else None + if not isinstance(outputs, dict): + return _failed_response( + "Workflow service response did not include output data." + ) + + messages = ( + outputs.get("messages") if isinstance(outputs.get("messages"), list) else [] + ) + tools = _tools_from_messages(messages) + approvals = _approvals_from_pending_interaction(outputs.get("pending_interaction")) + + spans = await _query_trace_spans( + tracing_service=tracing_service, + project_id=project_id, + trace_id=response.trace_id, + ) + _merge_span_observations(tools, spans) + resolved = _resolved_from_spans(spans) + verdict, reason = _verdict( + tools=tools, + expectations=expectations, + invoke_stop_reason=outputs.get("stop_reason"), + ) + + return TestRunResponse( + output=_last_assistant_content(messages), + tools=list(tools.values()), + approvals=approvals, + resolved=resolved, + trace_id=response.trace_id, + verdict=verdict, + verdict_reason=reason, + ) + + +def _failed_response( + message: str, *, trace_id: Optional[str] = None +) -> TestRunResponse: + """A ``failed`` verdict for a run that never produced a digestible child response + (no service URL, timeout, non-2xx, malformed body). ``infra_failure`` marks it so + the API boundary reports an error status instead of a normal tool result.""" + return TestRunResponse( + trace_id=trace_id, + verdict="failed", + verdict_reason=message, + infra_failure=True, + ) + + +# --- transcript digest ----------------------------------------------------- + + +def _last_assistant_content(messages: List[Any]) -> str: + for message in reversed(messages): + if not isinstance(message, dict) or message.get("role") != "assistant": + continue + content = message.get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for part in content: + if isinstance(part, str): + parts.append(part) + elif isinstance(part, dict) and isinstance(part.get("text"), str): + parts.append(part["text"]) + return "".join(parts) + if content is not None: + return str(content) + return "" + + +def _tools_from_messages(messages: List[Any]) -> Dict[str, TestRunToolDigest]: + by_call_id: Dict[str, TestRunToolDigest] = {} + by_name: Dict[str, TestRunToolDigest] = {} + for message in messages: + if not isinstance(message, dict) or message.get("role") != "tool": + continue + call_id = message.get("tool_call_id") or message.get("toolCallId") + name = message.get("tool_name") or message.get("toolName") + if name: + digest = by_name.setdefault(name, TestRunToolDigest(name=name)) + if call_id: + by_call_id[str(call_id)] = digest + _apply_tool_result(digest, message) + continue + if call_id and str(call_id) in by_call_id: + _apply_tool_result(by_call_id[str(call_id)], message) + return by_name + + +def _apply_tool_result(digest: TestRunToolDigest, message: Dict[str, Any]) -> None: + """Flags only accumulate: a tool called twice keeps an earlier error even if a later + call succeeds, so a real failure can never be masked within one run.""" + if not _has_tool_result_content(message): + return + digest.returned = True + digest.error = digest.error or bool( + message.get("is_error") or message.get("isError") + ) + + +def _has_tool_result_content(message: Dict[str, Any]) -> bool: + if "is_error" in message or "isError" in message: + return True + return "content" in message and message.get("content") not in (None, "") + + +def _approvals_from_pending_interaction(interaction: Any) -> List[str]: + if not isinstance(interaction, dict): + return [] + tool = interaction.get("tool") + payload = ( + interaction.get("payload") + if isinstance(interaction.get("payload"), dict) + else {} + ) + tool_call = ( + payload.get("toolCall") if isinstance(payload.get("toolCall"), dict) else {} + ) + tool = ( + tool + or payload.get("toolName") + or tool_call.get("name") + or tool_call.get("toolName") + ) + return [str(tool)] if tool else [] + + +# --- span digest ----------------------------------------------------------- + + +async def _query_trace_spans( + *, + tracing_service: Optional[TracingService], + project_id: UUID, + trace_id: Optional[str], +) -> List[Any]: + if tracing_service is None or not trace_id: + return [] + + query = TracingQuery( + formatting=Formatting(focus=Focus.SPAN), + filtering=Filtering(conditions=[Condition(field="trace_id", value=trace_id)]), + windowing=Windowing(limit=1000), + ) + for attempt, delay in enumerate((0.0, 0.05, 0.2)): + if delay: + await asyncio.sleep(delay) + spans = await tracing_service.query_spans(project_id=project_id, query=query) + if spans or attempt == 2: + return list(spans or []) + return [] + + +def _merge_span_observations( + tools: Dict[str, TestRunToolDigest], spans: List[Any] +) -> None: + for span in spans: + name = _tool_name_from_span(span) + if not name: + continue + digest = tools.setdefault(name, TestRunToolDigest(name=name)) + if _span_returned(span): + digest.returned = True + if _span_error(span): + digest.error = True + + +def _tool_name_from_span(span: Any) -> Optional[str]: + attrs = _span_attrs(span) + for key in ("gen_ai.tool.name", "tool.name", "ag.tool.name"): + value = attrs.get(key) + if value: + return str(value) + for path in ( + ("ag", "data", "inputs", "name"), + ("ag", "data", "inputs", "tool_name"), + ("ag", "data", "outputs", "tool_name"), + ): + value = _get_path(attrs, path) + if value: + return str(value) + span_type = getattr(span, "span_type", None) + span_type_value = getattr(span_type, "value", span_type) + span_name = getattr(span, "span_name", None) + if span_type_value == "tool" and span_name: + return str(span_name) + return None + + +def _span_returned(span: Any) -> bool: + attrs = _span_attrs(span) + outputs = _get_path(attrs, ("ag", "data", "outputs")) + if outputs is None: + outputs = attrs.get("ag.data.outputs") + return outputs not in (None, "") and not _span_error(span) + + +def _span_error(span: Any) -> bool: + status = getattr(span, "status_code", None) + status_value = getattr(status, "value", status) + if status_value == "STATUS_CODE_ERROR": + return True + attrs = _span_attrs(span) + return bool(attrs.get("error") or _get_path(attrs, ("ag", "exception"))) + + +def _resolved_from_spans(spans: List[Any]) -> TestRunResolved: + for span in spans: + attrs = _span_attrs(span) + resolved = ( + _get_path(attrs, ("ag", "meta", "resolved")) + or _get_path(attrs, ("ag", "data", "outputs", "resolved")) + or attrs.get("ag.meta.resolved") + ) + if isinstance(resolved, dict): + return TestRunResolved( + harness=resolved.get("harness"), + model=resolved.get("model"), + provider=resolved.get("provider"), + connection_mode=resolved.get("connection_mode") + or resolved.get("connectionMode"), + ) + flat = { + "harness": attrs.get("ag.resolved.harness"), + "model": attrs.get("ag.resolved.model"), + "provider": attrs.get("ag.resolved.provider"), + "connection_mode": attrs.get("ag.resolved.connection_mode") + or attrs.get("ag.resolved.connectionMode"), + } + if any(flat.values()): + return TestRunResolved(**flat) + return TestRunResolved() + + +def _span_attrs(span: Any) -> Dict[str, Any]: + attrs = getattr(span, "attributes", None) + if isinstance(attrs, dict): + return attrs + if hasattr(attrs, "model_dump"): + return attrs.model_dump(mode="json", exclude_none=True) + return {} + + +def _get_path(data: Dict[str, Any], path: tuple[str, ...]) -> Any: + node: Any = data + for part in path: + if not isinstance(node, dict) or part not in node: + return None + node = node[part] + return node + + +# --- verdict --------------------------------------------------------------- + + +def _verdict( + *, + tools: Dict[str, TestRunToolDigest], + expectations: Optional[TestRunExpectations], + invoke_stop_reason: Optional[Any], +) -> tuple[TestRunVerdict, Optional[str]]: + for tool in tools.values(): + if tool.error: + return "failed", f"tool '{tool.name}' returned an error" + terminal_tool = expectations.terminal_tool if expectations else None + if not terminal_tool: + return "unconfirmed", "no terminal_tool expectation provided" + terminal = tools.get(terminal_tool) + if terminal is None: + return "incomplete", f"terminal tool '{terminal_tool}' never ran" + if terminal.returned: + return "pass", None + if invoke_stop_reason == "paused": + return ( + "unconfirmed", + f"terminal tool '{terminal_tool}' is waiting for approval", + ) + return ( + "unconfirmed", + f"terminal tool '{terminal_tool}' ran but did not return output", + ) + + +# --------------------------------------------------------------------------- +# Registry — the only handlers a reserved call_ref can reach, plus their +# per-handler policy (timeout budget, elevation). The API boundary consults +# ``required_elevated_permission`` before ``dispatch_platform_tool_handler``. +# --------------------------------------------------------------------------- + +PlatformToolHandler = Callable[..., Awaitable[TestRunResponse]] + + +def _arguments_include_delta(arguments: Any) -> bool: + if isinstance(arguments, str): + try: + arguments = json.loads(arguments) + except json.JSONDecodeError: + return False + return isinstance(arguments, dict) and arguments.get("delta") is not None + + +@dataclass(frozen=True) +class PlatformToolHandlerRegistration: + call_ref: str + timeout_ms: int + handler: PlatformToolHandler + # Elevation policy: when ``requires_elevation(arguments)`` is true the caller must + # also hold ``elevated_permission`` (checked at the API boundary, before dispatch). + elevated_permission: Optional[Permission] = None + requires_elevation: Optional[Callable[[Any], bool]] = None + + +PLATFORM_TOOL_HANDLERS: Dict[str, PlatformToolHandlerRegistration] = { + TEST_RUN_CALL_REF: PlatformToolHandlerRegistration( + call_ref=TEST_RUN_CALL_REF, + timeout_ms=TEST_RUN_DEFAULT_TIMEOUT_MS, + handler=handle_test_run, + # An in-memory delta edits the (uncommitted) revision, so it needs the same + # permission as committing one. + elevated_permission=Permission.EDIT_WORKFLOWS, + requires_elevation=_arguments_include_delta, + ) +} + + +def required_elevated_permission( + *, + call_ref: str, + arguments: Any, +) -> Optional[Permission]: + """The extra permission the caller must hold for this call, or ``None``. + + Unknown call_refs return ``None``; dispatch will reject them with a 404 anyway.""" + registration = PLATFORM_TOOL_HANDLERS.get(call_ref) + if registration is None or registration.elevated_permission is None: + return None + if registration.requires_elevation is None or registration.requires_elevation( + arguments + ): + return registration.elevated_permission + return None + + +async def dispatch_platform_tool_handler( + *, + call_ref: str, + arguments: Any, + headers: Any, + project_id: UUID, + user_id: UUID, + workflows_service: Optional[WorkflowsService], + tracing_service: Optional[TracingService], +) -> TestRunResponse: + registration = PLATFORM_TOOL_HANDLERS.get(call_ref) + if registration is None: + raise PlatformToolHandlerNotFound( + f"Unknown reserved Agenta tool handler: {call_ref}" + ) + + return await registration.handler( + arguments=arguments, + headers=headers, + project_id=project_id, + user_id=user_id, + workflows_service=workflows_service, + tracing_service=tracing_service, + timeout_ms=registration.timeout_ms, + ) diff --git a/api/oss/src/core/tools/service.py b/api/oss/src/core/tools/service.py index 0fad6cc9bd..fd359b12b8 100644 --- a/api/oss/src/core/tools/service.py +++ b/api/oss/src/core/tools/service.py @@ -52,7 +52,7 @@ _SLUG_SEGMENT_RE = re.compile(r"^[a-zA-Z0-9-]+(?:_[a-zA-Z0-9-]+)*$") -# Discovery (find_capabilities): cache the tool/schema half, recompute connection +# Discovery (discover_tools): cache the tool/schema half, recompute connection # state fresh (D6). Project-agnostic key — the search is global, only the # connection-state join is project-scoped. _DISCOVERY_CACHE_NAMESPACE = "tools:discover" @@ -481,7 +481,7 @@ async def _resolve_composio_tool( ) # ----------------------------------------------------------------------- - # Tool discovery (find_capabilities) + # Tool discovery (discover_tools) # ----------------------------------------------------------------------- async def discover_capabilities( diff --git a/api/oss/src/core/triggers/dtos.py b/api/oss/src/core/triggers/dtos.py index 81942e49c3..b90f8572cd 100644 --- a/api/oss/src/core/triggers/dtos.py +++ b/api/oss/src/core/triggers/dtos.py @@ -101,7 +101,7 @@ class TriggerCatalogEventsPage(BaseModel): # --------------------------------------------------------------------------- -# Trigger discovery (find_triggers) +# Trigger discovery (discover_triggers) # --------------------------------------------------------------------------- diff --git a/api/oss/src/core/workflows/static_catalog.py b/api/oss/src/core/workflows/static_catalog.py index 3a3ab845ca..16e502c385 100644 --- a/api/oss/src/core/workflows/static_catalog.py +++ b/api/oss/src/core/workflows/static_catalog.py @@ -14,18 +14,14 @@ user cannot author or shadow it, and resolution never falls through to Postgres. """ -from typing import Any, Dict, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple from uuid import UUID, uuid5, NAMESPACE_DNS from agenta.sdk.agents.adapters.agenta_builtins import ( - BUILD_YOUR_FIRST_APP_SKILL, - BUILD_YOUR_FIRST_APP_SLUG, - DISCOVER_AND_WIRE_TOOLS_SKILL, - DISCOVER_AND_WIRE_TOOLS_SLUG, + BUILD_AN_AGENT_SKILL, + BUILD_AN_AGENT_SLUG, GETTING_STARTED_WITH_AGENTA_SKILL, GETTING_STARTED_WITH_AGENTA_SLUG, - SET_UP_TRIGGERS_SKILL, - SET_UP_TRIGGERS_SLUG, ) from agenta.sdk.agents.platform.workflow import ( REQUEST_CONNECTION_TOOL_NAME, @@ -143,22 +139,10 @@ def _client_tool_revision() -> WorkflowRevision: "v1": _client_tool_revision(), }, }, - BUILD_YOUR_FIRST_APP_SLUG: { + BUILD_AN_AGENT_SLUG: { "latest": "v1", "versions": { - "v1": _skill_revision(BUILD_YOUR_FIRST_APP_SKILL), - }, - }, - DISCOVER_AND_WIRE_TOOLS_SLUG: { - "latest": "v1", - "versions": { - "v1": _skill_revision(DISCOVER_AND_WIRE_TOOLS_SKILL), - }, - }, - SET_UP_TRIGGERS_SLUG: { - "latest": "v1", - "versions": { - "v1": _skill_revision(SET_UP_TRIGGERS_SKILL), + "v1": _skill_revision(BUILD_AN_AGENT_SKILL), }, }, } @@ -237,6 +221,13 @@ def _validate_catalog(self) -> None: f"WorkflowRevision with data.uri." ) + def list_slugs(self) -> List[str]: + """Every reserved slug in the catalogue, in declaration order. + + The public way to enumerate the catalogue (each slug resolves through + :meth:`retrieve_revision`); callers must not reach into the backing dict.""" + return list(self._catalog) + def is_static_slug(self, slug: Optional[str]) -> bool: return is_static_workflow_slug(slug) diff --git a/api/oss/tests/manual/tools/tools.http b/api/oss/tests/manual/tools/tools.http index d55d467bf1..3291fa0100 100644 --- a/api/oss/tests/manual/tools/tools.http +++ b/api/oss/tests/manual/tools/tools.http @@ -1,285 +1,263 @@ -@agenta_api_url = {{$dotenv AGENTA_API_URL}} -@agenta_auth_key = {{$dotenv AGENTA_AUTH_KEY}} -@base_url = {{agenta_api_url}}/tools - -@composio_api_key = {{$dotenv COMPOSIO_API_KEY}} -@composio_api_url = https://backend.composio.dev/api/v3 - -### -# @name create_account -POST {{agenta_api_url}}/admin/account -Content-Type: application/json -Authorization: Access {{agenta_auth_key}} - -{ - "user": { - "name": "test_user_{{$guid}}", - "email": "test+{{$guid}}@agenta.ai" - }, - "scope": { - "name": "Agenta (test) {{$guid}}" - } -} - -### -@authorization = {{create_account.response.body.scopes[0].credentials}} -@project_id = {{create_account.response.body.scopes[0].project_id}} - -# ===================================================================== -# CONNECTIONS -# ===================================================================== - -### -# @name create_gmail_connection -# Step 1: Create a connection (initiates OAuth flow). -# Open redirect_url in browser to complete OAuth. -POST {{base_url}}/connections/ -Content-Type: application/json -Authorization: {{authorization}} - -{ - "connection": { - "slug": "my-gmail-{{$guid}}", - "name": "My Gmail Account", - "description": "Personal Gmail for testing", - "provider_key": "composio", - "integration_key": "gmail" - } -} - -### -@gmail_connection_id = {{create_gmail_connection.response.body.connection.id}} -@gmail_connection_slug = {{create_gmail_connection.response.body.connection.slug}} -@gmail_redirect_url = {{create_gmail_connection.response.body.connection.data.redirect_url}} - -### -# @name create_github_connection -POST {{base_url}}/connections/ -Content-Type: application/json -Authorization: {{authorization}} - -{ - "connection": { - "slug": "my-github-{{$guid}}", - "name": "My GitHub Account", - "provider_key": "composio", - "integration_key": "github" - } -} - -### -@github_connection_id = {{create_github_connection.response.body.connection.id}} -@github_connection_slug = {{create_github_connection.response.body.connection.slug}} -@github_redirect_url = {{create_github_connection.response.body.connection.data.redirect_url}} - -### -# @name get_gmail_connection -# Step 2: Poll connection status after completing OAuth in browser. -GET {{base_url}}/connections/{{gmail_connection_id}} -Authorization: {{authorization}} - -### -# @name get_github_connection -# Step 2: Poll connection status after completing OAuth in browser. -GET {{base_url}}/connections/{{github_connection_id}} -Authorization: {{authorization}} - -### -# @name refresh_connection -# Re-initiates OAuth to get fresh tokens (force=true skips token-validity check). -POST {{base_url}}/connections/{{gmail_connection_id}}/refresh?force=false -Authorization: {{authorization}} - -### -# @name revoke_connection -# Marks the connection invalid locally without touching the provider. -POST {{base_url}}/connections/{{gmail_connection_id}}/revoke -Authorization: {{authorization}} - -### -# @name list_connections -# List all connections (optionally filtered by provider / integration). -POST {{base_url}}/connections/query -Authorization: {{authorization}} - -### -# @name list_connections_gmail -# Filter by provider + integration. -POST {{base_url}}/connections/query?provider_key=composio&integration_key=gmail -Authorization: {{authorization}} - -### -# @name delete_connection -# WARNING: This permanently revokes the connection at the provider. -DELETE {{base_url}}/connections/{{gmail_connection_id}} -Authorization: {{authorization}} - -# ===================================================================== -# TOOL DISCOVERY (find_capabilities) -# ===================================================================== - -### -# @name discover_capabilities -# One call -> best-match tool per use case + alternatives + input schemas + -# per-integration connection state (ready / needs_auth / needs_input) + guidance. -# Project scope (hence the Composio user_id) comes from the auth, not the body. -POST {{base_url}}/discover -Content-Type: application/json -Authorization: {{authorization}} - -{ - "use_cases": [ - "search github issues for a matching report", - "create a github issue", - "post a reply in a slack thread with a link", - "listen for new messages in a slack support channel" - ], - "provider": "composio", - "limit_alternatives": 3 -} - -### -# @name call_find_capabilities -# The reserved agent-facing tool, exercised over /tools/call exactly as the runner would relay it -# (call_ref out of the Composio 5-segment namespace). NOTE: the SDK-side declaration that lets an -# agent config carry this tool is still pending; this shows the server-side route works today. -POST {{base_url}}/call -Content-Type: application/json -Authorization: {{authorization}} - -{ - "data": { - "id": "call_{{$guid}}", - "type": "function", - "function": { - "name": "tools.agenta.find_capabilities", - "arguments": { - "use_cases": ["create a github issue"] - } - } - } -} - -# ===================================================================== -# TOOL CALL (action execution) -# ===================================================================== - -### -# @name get_action -# Our API — use stripped key (GMAIL_ prefix removed by our parser) -GET {{agenta_api_url}}/tools/catalog/providers/composio/integrations/gmail/actions/SEND_EMAIL -Authorization: {{authorization}} - -### -# @name call_tool_send_email -# Execute a tool using its full slug: tools.{provider}.{integration}.{action}.{connection}. -POST {{base_url}}/call -Content-Type: application/json -Authorization: {{authorization}} - -{ - "data": { - "id": "call_{{$guid}}", - "type": "function", - "function": { - "name": "tools.composio.gmail.SEND_EMAIL.{{gmail_connection_slug}}", - "arguments": { - "recipient_email": "team@agenta.ai", - "subject": "Test from Agenta", - "body": "This is a test email sent via the Tools Gateway." - } - } - } -} - -### -# @name call_tool_list_pull_requests -POST {{base_url}}/call -Content-Type: application/json -Authorization: {{authorization}} - -{ - "data": { - "id": "call_{{$guid}}", - "type": "function", - "function": { - "name": "tools.composio.github.LIST_PULL_REQUESTS.{{github_connection_slug}}", - "arguments": { - "owner": "Agenta-AI", - "repo": "agenta", - "state": "open" - } - } - } -} - -### -# @name search_github_pr_actions -# Find the exact action key for listing PRs -GET {{agenta_api_url}}/tools/catalog/providers/composio/integrations/github/actions/?search=LIST_PULL_REQUEST -Authorization: {{authorization}} - -### -# @name call_tool_list_prs -# Slug uses the action key WITHOUT the GITHUB_ prefix (adapter adds it back) -POST {{base_url}}/call -Content-Type: application/json -Authorization: {{authorization}} - -{ - "data": { - "id": "call_{{$guid}}", - "type": "function", - "function": { - "name": "tools.composio.github.LIST_PULL_REQUESTS.{{github_connection_slug}}", - "arguments": { - "owner": "Agenta-AI", - "repo": "agenta", - "state": "open" - } - } - } -} - -# ===================================================================== -# DIRECT COMPOSIO (for debugging — compare exact request/response) -# ===================================================================== - -### -@gmail_connected_account_id = {{get_gmail_connection.response.body.connection.data.connected_account_id}} -@github_connected_account_id = {{get_github_connection.response.body.connection.data.connected_account_id}} - -### -# @name composio_list_prs_direct -POST {{composio_api_url}}/tools/execute/GITHUB_LIST_PULL_REQUESTS -Content-Type: application/json -x-api-key: {{composio_api_key}} - -{ - "arguments": { - "owner": "Agenta-AI", - "repo": "agenta", - "state": "open" - }, - "connected_account_id": "{{github_connected_account_id}}", - "user_id": "{{project_id}}" -} - -### -# @name composio_execute_direct -# Direct call matching exactly what our adapter sends: arguments + connected_account_id + user_id -# NOTE: connection must have been created AFTER the entity_id format change (plain UUID, no prefix). -# Old connections used "project_" — create a new connection if you get an entity mismatch error. -POST {{composio_api_url}}/tools/execute/GMAIL_SEND_EMAIL -Content-Type: application/json -x-api-key: {{composio_api_key}} - -{ - "arguments": { - "recipient_email": "team@agenta.ai", - "subject": "Test from Agenta (direct)", - "body": "Direct Composio call." - }, - "connected_account_id": "{{gmail_connected_account_id}}", - "user_id": "{{project_id}}" -} +@agenta_api_url = {{$dotenv AGENTA_API_URL}} +@agenta_auth_key = {{$dotenv AGENTA_AUTH_KEY}} +@base_url = {{agenta_api_url}}/tools + +@composio_api_key = {{$dotenv COMPOSIO_API_KEY}} +@composio_api_url = https://backend.composio.dev/api/v3 + +### +# @name create_account +POST {{agenta_api_url}}/admin/account +Content-Type: application/json +Authorization: Access {{agenta_auth_key}} + +{ + "user": { + "name": "test_user_{{$guid}}", + "email": "test+{{$guid}}@agenta.ai" + }, + "scope": { + "name": "Agenta (test) {{$guid}}" + } +} + +### +@authorization = {{create_account.response.body.scopes[0].credentials}} +@project_id = {{create_account.response.body.scopes[0].project_id}} + +# ===================================================================== +# CONNECTIONS +# ===================================================================== + +### +# @name create_gmail_connection +# Step 1: Create a connection (initiates OAuth flow). +# Open redirect_url in browser to complete OAuth. +POST {{base_url}}/connections/ +Content-Type: application/json +Authorization: {{authorization}} + +{ + "connection": { + "slug": "my-gmail-{{$guid}}", + "name": "My Gmail Account", + "description": "Personal Gmail for testing", + "provider_key": "composio", + "integration_key": "gmail" + } +} + +### +@gmail_connection_id = {{create_gmail_connection.response.body.connection.id}} +@gmail_connection_slug = {{create_gmail_connection.response.body.connection.slug}} +@gmail_redirect_url = {{create_gmail_connection.response.body.connection.data.redirect_url}} + +### +# @name create_github_connection +POST {{base_url}}/connections/ +Content-Type: application/json +Authorization: {{authorization}} + +{ + "connection": { + "slug": "my-github-{{$guid}}", + "name": "My GitHub Account", + "provider_key": "composio", + "integration_key": "github" + } +} + +### +@github_connection_id = {{create_github_connection.response.body.connection.id}} +@github_connection_slug = {{create_github_connection.response.body.connection.slug}} +@github_redirect_url = {{create_github_connection.response.body.connection.data.redirect_url}} + +### +# @name get_gmail_connection +# Step 2: Poll connection status after completing OAuth in browser. +GET {{base_url}}/connections/{{gmail_connection_id}} +Authorization: {{authorization}} + +### +# @name get_github_connection +# Step 2: Poll connection status after completing OAuth in browser. +GET {{base_url}}/connections/{{github_connection_id}} +Authorization: {{authorization}} + +### +# @name refresh_connection +# Re-initiates OAuth to get fresh tokens (force=true skips token-validity check). +POST {{base_url}}/connections/{{gmail_connection_id}}/refresh?force=false +Authorization: {{authorization}} + +### +# @name revoke_connection +# Marks the connection invalid locally without touching the provider. +POST {{base_url}}/connections/{{gmail_connection_id}}/revoke +Authorization: {{authorization}} + +### +# @name list_connections +# List all connections (optionally filtered by provider / integration). +POST {{base_url}}/connections/query +Authorization: {{authorization}} + +### +# @name list_connections_gmail +# Filter by provider + integration. +POST {{base_url}}/connections/query?provider_key=composio&integration_key=gmail +Authorization: {{authorization}} + +### +# @name delete_connection +# WARNING: This permanently revokes the connection at the provider. +DELETE {{base_url}}/connections/{{gmail_connection_id}} +Authorization: {{authorization}} + +# ===================================================================== +# TOOL DISCOVERY (discover_tools) +# ===================================================================== + +### +# @name discover_capabilities +# One call -> best-match tool per use case + alternatives + input schemas + +# per-integration connection state (ready / needs_auth / needs_input) + guidance. +# Project scope (hence the Composio user_id) comes from the auth, not the body. +POST {{base_url}}/discover +Content-Type: application/json +Authorization: {{authorization}} + +{ + "use_cases": [ + "search github issues for a matching report", + "create a github issue", + "post a reply in a slack thread with a link", + "listen for new messages in a slack support channel" + ], + "provider": "composio", + "limit_alternatives": 3 +} + +# ===================================================================== +# TOOL CALL (action execution) +# ===================================================================== + +### +# @name get_action +# Our API — use stripped key (GMAIL_ prefix removed by our parser) +GET {{agenta_api_url}}/tools/catalog/providers/composio/integrations/gmail/actions/SEND_EMAIL +Authorization: {{authorization}} + +### +# @name call_tool_send_email +# Execute a tool using its full slug: tools.{provider}.{integration}.{action}.{connection}. +POST {{base_url}}/call +Content-Type: application/json +Authorization: {{authorization}} + +{ + "data": { + "id": "call_{{$guid}}", + "type": "function", + "function": { + "name": "tools.composio.gmail.SEND_EMAIL.{{gmail_connection_slug}}", + "arguments": { + "recipient_email": "team@agenta.ai", + "subject": "Test from Agenta", + "body": "This is a test email sent via the Tools Gateway." + } + } + } +} + +### +# @name call_tool_list_pull_requests +POST {{base_url}}/call +Content-Type: application/json +Authorization: {{authorization}} + +{ + "data": { + "id": "call_{{$guid}}", + "type": "function", + "function": { + "name": "tools.composio.github.LIST_PULL_REQUESTS.{{github_connection_slug}}", + "arguments": { + "owner": "Agenta-AI", + "repo": "agenta", + "state": "open" + } + } + } +} + +### +# @name search_github_pr_actions +# Find the exact action key for listing PRs +GET {{agenta_api_url}}/tools/catalog/providers/composio/integrations/github/actions/?search=LIST_PULL_REQUEST +Authorization: {{authorization}} + +### +# @name call_tool_list_prs +# Slug uses the action key WITHOUT the GITHUB_ prefix (adapter adds it back) +POST {{base_url}}/call +Content-Type: application/json +Authorization: {{authorization}} + +{ + "data": { + "id": "call_{{$guid}}", + "type": "function", + "function": { + "name": "tools.composio.github.LIST_PULL_REQUESTS.{{github_connection_slug}}", + "arguments": { + "owner": "Agenta-AI", + "repo": "agenta", + "state": "open" + } + } + } +} + +# ===================================================================== +# DIRECT COMPOSIO (for debugging — compare exact request/response) +# ===================================================================== + +### +@gmail_connected_account_id = {{get_gmail_connection.response.body.connection.data.connected_account_id}} +@github_connected_account_id = {{get_github_connection.response.body.connection.data.connected_account_id}} + +### +# @name composio_list_prs_direct +POST {{composio_api_url}}/tools/execute/GITHUB_LIST_PULL_REQUESTS +Content-Type: application/json +x-api-key: {{composio_api_key}} + +{ + "arguments": { + "owner": "Agenta-AI", + "repo": "agenta", + "state": "open" + }, + "connected_account_id": "{{github_connected_account_id}}", + "user_id": "{{project_id}}" +} + +### +# @name composio_execute_direct +# Direct call matching exactly what our adapter sends: arguments + connected_account_id + user_id +# NOTE: connection must have been created AFTER the entity_id format change (plain UUID, no prefix). +# Old connections used "project_" — create a new connection if you get an entity mismatch error. +POST {{composio_api_url}}/tools/execute/GMAIL_SEND_EMAIL +Content-Type: application/json +x-api-key: {{composio_api_key}} + +{ + "arguments": { + "recipient_email": "team@agenta.ai", + "subject": "Test from Agenta (direct)", + "body": "Direct Composio call." + }, + "connected_account_id": "{{gmail_connected_account_id}}", + "user_id": "{{project_id}}" +} diff --git a/api/oss/tests/pytest/unit/applications/test_build_kit_overlay.py b/api/oss/tests/pytest/unit/applications/test_build_kit_overlay.py index 5adc21d864..6225da7ab5 100644 --- a/api/oss/tests/pytest/unit/applications/test_build_kit_overlay.py +++ b/api/oss/tests/pytest/unit/applications/test_build_kit_overlay.py @@ -4,22 +4,51 @@ import pytest -from agenta.sdk.agents.adapters.agenta_builtins import GETTING_STARTED_WITH_AGENTA_SLUG +from agenta.sdk.agents.adapters.agenta_builtins import ( + AGENTA_FORCED_TOOLS, + BUILD_AN_AGENT_SLUG, + GETTING_STARTED_WITH_AGENTA_SLUG, +) from agenta.sdk.agents.dtos import AgentTemplate +from agenta.sdk.agents.platform import AgentaPlatformToolResolver, PlatformConnection from agenta.sdk.agents.platform.op_catalog import PLATFORM_OPS from agenta.sdk.agents.tools.models import ClientToolConfig, PlatformToolConfig from oss.src.apis.fastapi.applications import router as applications_router_module -from oss.src.apis.fastapi.applications.overlay import build_agent_template_overlay +from oss.src.apis.fastapi.applications.overlay import ( + DEFAULT_BUILD_KIT_OPS, + build_agent_template_overlay, +) from oss.src.apis.fastapi.applications.router import SimpleApplicationsRouter from oss.src.core.applications.dtos import SimpleApplication from oss.src.core.embeds.service import EmbedsService from oss.src.core.workflows.dtos import WorkflowRevision, WorkflowRevisionData from oss.src.core.workflows.service import WorkflowsService -from oss.src.core.workflows.static_catalog import ( - STATIC_SLUG_PREFIX, - StaticWorkflowCatalog, - _STATIC_WORKFLOWS, +from oss.src.core.workflows.static_catalog import StaticWorkflowCatalog + +EXPECTED_DEFAULT_BUILD_KIT_OPS = ( + "discover_tools", + "commit_revision", + "annotate_trace", + "query_spans", + "discover_triggers", + "create_schedule", + "create_subscription", + "list_schedules", + "list_deliveries", + "test_subscription", + "remove_schedule", + "remove_subscription", +) + +CUT_BUILD_KIT_OPS = ( + "pause_schedule", + "resume_schedule", + "pause_subscription", + "resume_subscription", + "query_workflows", + "list_connections", + "list_subscriptions", ) @@ -29,7 +58,43 @@ def _embed_slug(entry: dict) -> str | None: return workflow.get("slug") -def test_agent_template_overlay_contains_platform_ops_authoring_skill_and_permissions(): +def test_agent_template_overlay_tools_list_is_pinned_with_builtin_grants_first(): + """Pin the exact overlay tools list: builtin grants, then platform ops, then embeds. + + The leading builtin grants (``{"type": "builtin", "name": "read"/"bash"}`` from + ``AGENTA_FORCED_TOOLS``) are load-bearing: any custom tool on the wire flips Pi's + builtin gating from "Pi defaults" to granted-only, so without an explicit ``read`` + grant the playbook skill is announced but unloadable (live-QA finding 2026-07-05). + ``bash`` keeps skill helper scripts runnable. + """ + overlay = build_agent_template_overlay() + + catalog = StaticWorkflowCatalog() + expected_static_tool_embeds = [] + for slug in catalog.list_slugs(): + revision = catalog.retrieve_revision(slug=slug) + if not revision or not revision.flags or revision.flags.is_skill: + continue + expected_static_tool_embeds.append( + { + "@ag.embed": { + "@ag.references": {"workflow": {"slug": slug}}, + "@ag.selector": {"path": "parameters.tool"}, + }, + "name": revision.name, + } + ) + + assert AGENTA_FORCED_TOOLS == ["read", "bash"] + assert overlay["tools"] == [ + {"type": "builtin", "name": "read"}, + {"type": "builtin", "name": "bash"}, + *[{"type": "platform", "op": op_name} for op_name in DEFAULT_BUILD_KIT_OPS], + *expected_static_tool_embeds, + ] + + +def test_agent_template_overlay_contains_platform_ops_playbook_skill_and_permissions(): overlay = build_agent_template_overlay() platform_tools = [ @@ -37,24 +102,28 @@ def test_agent_template_overlay_contains_platform_ops_authoring_skill_and_permis for tool in overlay["tools"] if isinstance(tool, dict) and tool.get("type") == "platform" ] + assert DEFAULT_BUILD_KIT_OPS == EXPECTED_DEFAULT_BUILD_KIT_OPS + assert set(DEFAULT_BUILD_KIT_OPS) <= set(PLATFORM_OPS) + assert set(DEFAULT_BUILD_KIT_OPS).isdisjoint(CUT_BUILD_KIT_OPS) assert platform_tools == [ - {"type": "platform", "op": op_name} for op_name in PLATFORM_OPS + {"type": "platform", "op": op_name} for op_name in DEFAULT_BUILD_KIT_OPS ] authoring_skill = StaticWorkflowCatalog().retrieve_revision( - slug=GETTING_STARTED_WITH_AGENTA_SLUG + slug=BUILD_AN_AGENT_SLUG ) assert overlay["skills"] == [ { "name": authoring_skill.name, "@ag.embed": { - "@ag.references": { - "workflow": {"slug": GETTING_STARTED_WITH_AGENTA_SLUG} - }, + "@ag.references": {"workflow": {"slug": BUILD_AN_AGENT_SLUG}}, "@ag.selector": {"path": "parameters.skill"}, }, } ] + assert GETTING_STARTED_WITH_AGENTA_SLUG not in { + _embed_slug(skill) for skill in overlay["skills"] + } assert overlay["sandbox"] == { "permissions": {"write_files": "allow", "execute_code": "allow"} } @@ -84,14 +153,9 @@ def test_agent_template_overlay_includes_reserved_static_workflow_tool_embeds(): assert tool.get("name") == revision.name expected_slugs = set() - for slug in _STATIC_WORKFLOWS: + for slug in catalog.list_slugs(): revision = catalog.retrieve_revision(slug=slug) - if ( - slug.startswith(STATIC_SLUG_PREFIX) - and revision - and revision.flags - and not revision.flags.is_skill - ): + if revision and revision.flags and not revision.flags.is_skill: expected_slugs.add(slug) assert tool_embed_slugs == expected_slugs @@ -140,7 +204,15 @@ async def fetch(self, **kwargs): # The overlay is now a typed `AgentTemplateOverlay`; its JSON projection is the wire payload and # must match the platform-built overlay dict byte for byte. assert overlay is not None - assert overlay.model_dump(mode="json") == build_agent_template_overlay() + overlay_payload = overlay.model_dump(mode="json") + response_platform_ops = [ + tool["op"] + for tool in overlay_payload["tools"] + if isinstance(tool, dict) and tool.get("type") == "platform" + ] + assert set(response_platform_ops) == set(DEFAULT_BUILD_KIT_OPS) + assert response_platform_ops == list(DEFAULT_BUILD_KIT_OPS) + assert overlay_payload == build_agent_template_overlay() @pytest.mark.asyncio @@ -185,8 +257,24 @@ async def test_resolved_build_kit_overlay_parses_through_from_params(): client_tools = [ tool for tool in template.tools if isinstance(tool, ClientToolConfig) ] - assert any(tool.op == "find_capabilities" for tool in platform_ops) + assert [tool.op for tool in platform_ops] == list(DEFAULT_BUILD_KIT_OPS) # The request_connection embed must coerce to a client tool, not a builtin. assert [tool.name for tool in client_tools] == ["request_connection"] assert client_tools[0].render == {"kind": "connect"} - assert [skill.name for skill in template.skills] == ["agenta-getting-started"] + assert [skill.name for skill in template.skills] == ["build-an-agent"] + + +@pytest.mark.asyncio +async def test_cut_overlay_ops_still_resolve_when_authored_explicitly(): + resolver = AgentaPlatformToolResolver( + connection=PlatformConnection( + base_url="https://api.example/api", + authorization="Access tok", + ) + ) + + resolution = await resolver.resolve( + [PlatformToolConfig(op=op) for op in CUT_BUILD_KIT_OPS] + ) + + assert {spec.name for spec in resolution.tool_specs} == set(CUT_BUILD_KIT_OPS) diff --git a/api/oss/tests/pytest/unit/tools/test_discovery.py b/api/oss/tests/pytest/unit/tools/test_discovery.py index 2fa3d9f8dd..0db4d1ef11 100644 --- a/api/oss/tests/pytest/unit/tools/test_discovery.py +++ b/api/oss/tests/pytest/unit/tools/test_discovery.py @@ -11,11 +11,8 @@ from oss.src.apis.fastapi.tools.models import CapabilitiesQuery from oss.src.apis.fastapi.tools.router import ToolsRouter from oss.src.core.tools.discovery import ( - FIND_CAPABILITIES_CALL_REF, - FIND_CAPABILITIES_INPUT_SCHEMA, looks_like_trigger, map_guidance_text, - parse_find_capabilities_arguments, referenced_integrations, split_composio_slug, translate_search_result, @@ -28,9 +25,6 @@ ConnectionRequirement, DiscoveredTool, ToolAuthScheme, - ToolCall, - ToolCallData, - ToolCallFunction, ToolConnectionState, ) from oss.src.core.tools.providers.composio.dtos import ( @@ -525,45 +519,7 @@ async def _noop(**_kwargs): # --------------------------------------------------------------------------- -# Reserved tool: parse_find_capabilities_arguments + input schema -# --------------------------------------------------------------------------- - - -def test_find_capabilities_input_schema_shape(): - assert FIND_CAPABILITIES_CALL_REF == "tools.agenta.find_capabilities" - assert FIND_CAPABILITIES_INPUT_SCHEMA["required"] == ["use_cases"] - assert FIND_CAPABILITIES_INPUT_SCHEMA["properties"]["use_cases"]["type"] == "array" - - -def test_parse_find_capabilities_arguments_normalizes(): - use_cases, provider, limit = parse_find_capabilities_arguments( - {"use_cases": ["a", " ", "b"], "provider": "composio", "limit_alternatives": 5} - ) - assert use_cases == ["a", "b"] - assert provider == "composio" - assert limit == 5 - - # Defaults + bad limit fall back cleanly. - use_cases, provider, limit = parse_find_capabilities_arguments( - {"use_cases": ["x"], "limit_alternatives": "oops"} - ) - assert (use_cases, provider, limit) == (["x"], "composio", 3) - - -def test_parse_find_capabilities_arguments_coerces_scalar_string(): - # A bare string is one use_case, not iterated character-by-character. - use_cases, _provider, _limit = parse_find_capabilities_arguments( - {"use_cases": "create a github issue"} - ) - assert use_cases == ["create a github issue"] - - # A non-list, non-string value yields no use_cases. - use_cases, _provider, _limit = parse_find_capabilities_arguments({"use_cases": 42}) - assert use_cases == [] - - -# --------------------------------------------------------------------------- -# Router: POST /tools/discover + the tools.agenta.find_capabilities call branch +# Router: POST /tools/discover # --------------------------------------------------------------------------- @@ -601,15 +557,6 @@ def _request(): ) -def _call(name, arguments) -> ToolCall: - return ToolCall( - data=ToolCallData( - id="call_1", - function=ToolCallFunction(name=name, arguments=arguments), - ) - ) - - async def test_discover_route_returns_capabilities(monkeypatch): captured = {} @@ -668,103 +615,3 @@ def test_capabilities_query_rejects_scalar_string(): # A bare string must be rejected, not iterated into one-char fragments. with pytest.raises(Exception): CapabilitiesQuery(use_cases="create a github issue") - - -async def test_call_agenta_tool_runs_find_capabilities(): - captured = {} - - async def _discover(*, project_id, use_cases, provider_key, limit_alternatives): - captured.update(use_cases=use_cases, provider_key=provider_key) - return _capabilities_result() - - router = _router_with_discover(_discover) - response = await router._call_agenta_tool( - request=_request(), - body=_call( - "tools.agenta.find_capabilities", - {"use_cases": ["create a github issue"]}, - ), - ) - - assert captured["use_cases"] == ["create a github issue"] - assert response.call.status.code == "STATUS_CODE_OK" - payload = json.loads(response.call.data.content) - assert payload["ready"] is True - assert payload["capabilities"][0]["tool"]["action"] == "CREATE_AN_ISSUE" - assert response.call.data.tool_call_id == "call_1" - - -async def test_call_agenta_tool_parses_json_string_arguments(): - async def _discover(*, project_id, use_cases, provider_key, limit_alternatives): - return _capabilities_result() - - response = await _router_with_discover(_discover)._call_agenta_tool( - request=_request(), - body=_call("tools__agenta__find_capabilities", '{"use_cases": ["x"]}'), - ) - assert response.call.status.code == "STATUS_CODE_OK" - - -async def test_call_agenta_tool_unknown_op_404(): - async def _discover(**_kwargs): - return _capabilities_result() - - with pytest.raises(HTTPException) as caught: - await _router_with_discover(_discover)._call_agenta_tool( - request=_request(), - body=_call("tools.agenta.unknown_op", {"use_cases": ["x"]}), - ) - assert caught.value.status_code == 404 - - -async def test_call_agenta_tool_empty_use_cases_400(): - async def _discover(**_kwargs): - return _capabilities_result() - - with pytest.raises(HTTPException) as caught: - await _router_with_discover(_discover)._call_agenta_tool( - request=_request(), - body=_call("tools.agenta.find_capabilities", {"use_cases": []}), - ) - assert caught.value.status_code == 400 - - -async def test_call_agenta_tool_unsupported_provider_422(): - async def _discover(**_kwargs): - raise DiscoveryUnsupportedError("agenta") - - with pytest.raises(HTTPException) as caught: - await _router_with_discover(_discover)._call_agenta_tool( - request=_request(), - body=_call( - "tools.agenta.find_capabilities", - {"use_cases": ["x"], "provider": "agenta"}, - ), - ) - assert caught.value.status_code == 422 - - -async def test_call_tool_agenta_branch_requires_view_tools(monkeypatch): - # The reserved tool exposes per-project connection state, so RUN_TOOLS alone (the - # outer gate) must not reach it: the tools.agenta.* branch also needs VIEW_TOOLS. - from oss.src.core.access.permissions.types import Permission - - async def _discover(**_kwargs): # pragma: no cover - must not be reached - return _capabilities_result() - - async def _access(*, permission, **_kwargs): - return permission != Permission.VIEW_TOOLS - - monkeypatch.setattr( - "oss.src.apis.fastapi.tools.router.check_action_access", _access - ) - - with pytest.raises(HTTPException) as caught: - await _router_with_discover(_discover).call_tool( - _request(), - body=_call( - "tools.agenta.find_capabilities", - {"use_cases": ["create a github issue"]}, - ), - ) - assert caught.value.status_code == 403 diff --git a/api/oss/tests/pytest/unit/tools/test_platform_handlers.py b/api/oss/tests/pytest/unit/tools/test_platform_handlers.py new file mode 100644 index 0000000000..18716549a3 --- /dev/null +++ b/api/oss/tests/pytest/unit/tools/test_platform_handlers.py @@ -0,0 +1,787 @@ +from __future__ import annotations + +import json +from types import SimpleNamespace +from uuid import uuid4 + +import httpx +import pytest +from fastapi import HTTPException + +from agenta.sdk.contexts.running import RunningContext, running_context_manager +from agenta.sdk.contexts.tracing import TracingContext, tracing_context_manager +from agenta.sdk.middlewares.running.resolver import ResolverMiddleware +from agenta.sdk.models.workflows import WorkflowInvokeRequest +from agenta.sdk.utils.types import build_agent_v0_default +from oss.src.apis.fastapi.tools.router import ToolsRouter +from oss.src.core.access.permissions.types import Permission +from oss.src.core.tools.dtos import ToolCall, ToolCallData, ToolCallFunction +from oss.src.core.tools.platform_handlers import ( + TEST_RUN_RECURSION_HEADER, + TEST_RUN_RECURSION_VALUE, + PlatformToolHandlerRefused, + handle_test_run, +) +from oss.src.core.workflows.dtos import WorkflowRevisionData, WorkflowServiceRequestData +from oss.src.core.workflows.service import WorkflowsService + + +_MISSING = object() +_DEFAULT_REVISION_DATA = { + "url": "https://agent.internal", + "parameters": {"agent": {"model": "base"}}, +} + + +class FakeWorkflowsService: + _coerce_invoke_response = staticmethod(WorkflowsService._coerce_invoke_response) + + def __init__( + self, + *, + service_url="https://agent.internal", + delta_data=None, + revision_data=_MISSING, + ): + self.service_url = service_url + self.delta_data = ( + delta_data if delta_data is not None else _DEFAULT_REVISION_DATA + ) + self.revision_data = ( + _DEFAULT_REVISION_DATA if revision_data is _MISSING else revision_data + ) + self.ensure_calls = [] + self.delta_calls = [] + self.prepare_calls = [] + + async def _ensure_request_revision(self, *, project_id, request): + self.ensure_calls.append({"project_id": project_id, "request": request}) + if self.revision_data is None: + return + if request.data is None: + request.data = WorkflowServiceRequestData() + request.data.revision = {"data": self.revision_data} + + async def _resolve_revision_delta(self, *, project_id, workflow_revision_commit): + self.delta_calls.append( + {"project_id": project_id, "commit": workflow_revision_commit} + ) + return SimpleNamespace(data=WorkflowRevisionData(**self.delta_data)) + + async def _prepare_invoke(self, *, project_id, user_id, request): + self.prepare_calls.append( + {"project_id": project_id, "user_id": user_id, "request": request} + ) + return "Secret signed", self.service_url + + +class FakeTracingService: + def __init__(self, *responses): + self.responses = list(responses) or [[]] + self.calls = [] + + async def query_spans(self, *, project_id, query): + self.calls.append({"project_id": project_id, "query": query}) + if len(self.responses) > 1: + return self.responses.pop(0) + return self.responses[0] + + +class HttpxController: + def __init__(self, monkeypatch, *, response=None, raises=None): + self.response = response or httpx.Response( + 200, + json={"data": {"outputs": {"messages": []}}}, + headers={"x-ag-trace-id": "trace-1"}, + ) + self.raises = raises + self.calls = [] + self.client_kwargs = [] + + controller = self + + class FakeAsyncClient: + def __init__(self, **kwargs): + controller.client_kwargs.append(kwargs) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def post(self, url, *, json=None, headers=None): + controller.calls.append({"url": url, "json": json, "headers": headers}) + if controller.raises is not None: + raise controller.raises + return controller.response + + monkeypatch.setattr( + "oss.src.core.tools.platform_handlers.httpx.AsyncClient", + FakeAsyncClient, + ) + + +def _args(*, terminal_tool="slack__SEND_MESSAGE", delta=None): + data = { + "target": {"workflow_variant_id": str(uuid4())}, + "inputs": {"messages": [{"role": "user", "content": "Say hi"}]}, + "expectations": {"terminal_tool": terminal_tool}, + } + if delta is not None: + data["delta"] = delta + return data + + +def _response(outputs, *, trace_id="trace-1", status=200): + return httpx.Response( + status, + json={"data": {"outputs": outputs}}, + headers={"x-ag-trace-id": trace_id}, + ) + + +def _span(name, *, returned=True, error=False, resolved=None): + attrs = {"ag": {"data": {"outputs": {"ok": True} if returned else None}}} + if resolved is not None: + attrs["ag"]["meta"] = {"resolved": resolved} + return SimpleNamespace( + span_name=name, + span_type="tool", + status_code="STATUS_CODE_ERROR" if error else "STATUS_CODE_OK", + attributes=attrs, + ) + + +async def _run(monkeypatch, *, outputs, args=None, tracing=None, raises=None): + http = HttpxController( + monkeypatch, + response=_response(outputs), + raises=raises, + ) + workflows = FakeWorkflowsService() + result = await handle_test_run( + arguments=args or _args(), + headers={}, + project_id=uuid4(), + user_id=uuid4(), + workflows_service=workflows, + tracing_service=tracing or FakeTracingService(), + ) + return result, workflows, http + + +async def test_test_run_happy_path_invokes_child_and_returns_digest(monkeypatch): + outputs = { + "messages": [ + {"role": "assistant", "content": "checking"}, + { + "role": "tool", + "content": "", + "tool_call_id": "call_1", + "tool_name": "github__LIST_COMMITS", + "input": {"repo": "agenta"}, + }, + {"role": "tool", "content": "[]", "tool_call_id": "call_1"}, + { + "role": "tool", + "content": "", + "tool_call_id": "call_2", + "tool_name": "slack__SEND_MESSAGE", + "input": {"text": "hi"}, + }, + {"role": "tool", "content": "ok", "tool_call_id": "call_2"}, + {"role": "assistant", "content": "sent"}, + ], + "stop_reason": "stop", + } + tracing = FakeTracingService( + [ + _span( + "slack__SEND_MESSAGE", + resolved={ + "harness": "claude", + "model": "sonnet", + "provider": "anthropic", + "connection_mode": "self_managed", + }, + ) + ] + ) + + result, workflows, http = await _run(monkeypatch, outputs=outputs, tracing=tracing) + + assert result.verdict == "pass" + assert result.output == "sent" + assert [tool.name for tool in result.tools] == [ + "github__LIST_COMMITS", + "slack__SEND_MESSAGE", + ] + assert all(tool.returned for tool in result.tools) + assert result.resolved.harness == "claude" + assert result.resolved.connection_mode == "self_managed" + assert result.trace_id == "trace-1" + + payload = http.calls[0]["json"] + assert payload["meta"]["run_kind"] == "test" + assert payload["data"]["inputs"] == { + "messages": [{"role": "user", "content": "Say hi"}] + } + assert workflows.ensure_calls + assert workflows.prepare_calls + + +async def test_test_run_parameters_less_agent_revision_succeeds_with_resolver_backed_child( + monkeypatch, +): + expected_parameters = {"agent": build_agent_v0_default()} + http_calls = [] + monkeypatch.setattr( + "agenta.sdk.middlewares.running.resolver.ag.async_api", + None, + raising=False, + ) + + class ResolverBackedAsyncClient: + def __init__(self, **_kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def post(self, url, *, json=None, headers=None): + http_calls.append({"url": url, "json": json, "headers": headers}) + request = WorkflowInvokeRequest.model_validate(json) + + async def _noop_call_next(_request): + return None + + with ( + running_context_manager(RunningContext()), + tracing_context_manager(TracingContext()), + ): + await ResolverMiddleware()(request, _noop_call_next) + + if request.data.parameters != expected_parameters: + return httpx.Response( + 500, + json={ + "status": { + "message": ( + "pi_core: model authentication failed " + "(resolved model= provider=)" + ) + } + }, + ) + + return _response( + { + "messages": [{"role": "assistant", "content": "pong"}], + "stop_reason": "stop", + } + ) + + monkeypatch.setattr( + "oss.src.core.tools.platform_handlers.httpx.AsyncClient", + ResolverBackedAsyncClient, + ) + workflows = FakeWorkflowsService( + revision_data={ + "uri": "agenta:builtin:agent:v0", + "url": "https://agent.internal", + "schemas": {"parameters": {"type": "object"}}, + } + ) + + result = await handle_test_run( + arguments=_args(terminal_tool=None), + headers={}, + project_id=uuid4(), + user_id=uuid4(), + workflows_service=workflows, + tracing_service=FakeTracingService(), + ) + + assert result.verdict == "unconfirmed" + assert result.output == "pong" + revision = http_calls[0]["json"]["data"]["revision"]["data"] + assert "parameters" not in revision + assert workflows.ensure_calls + assert workflows.prepare_calls + + +async def test_test_run_applies_delta_in_memory(monkeypatch): + outputs = {"messages": [{"role": "assistant", "content": "ok"}]} + http = HttpxController(monkeypatch, response=_response(outputs)) + workflows = FakeWorkflowsService( + delta_data={ + "url": "https://agent.internal", + "parameters": {"agent": {"model": "changed"}}, + } + ) + + result = await handle_test_run( + arguments=_args( + terminal_tool=None, + delta={ + "set": {"parameters": {"agent": {"model": "changed"}}}, + "remove": ["parameters.agent.old"], + }, + ), + headers={}, + project_id=uuid4(), + user_id=uuid4(), + workflows_service=workflows, + tracing_service=FakeTracingService(), + ) + + assert result.verdict == "unconfirmed" + assert workflows.ensure_calls + assert workflows.delta_calls + revision = http.calls[0]["json"]["data"]["revision"]["data"] + assert revision["parameters"]["agent"]["model"] == "changed" + + +async def test_test_run_delta_requires_existing_project_scoped_variant(monkeypatch): + async def _allow(**_kwargs): + return True + + monkeypatch.setattr("oss.src.apis.fastapi.tools.router.check_action_access", _allow) + http = HttpxController(monkeypatch) + workflows = FakeWorkflowsService(revision_data=None) + router = ToolsRouter( + tools_service=SimpleNamespace(), + workflows_service=workflows, + tracing_service=FakeTracingService(), + ) + request = SimpleNamespace( + state=SimpleNamespace(project_id=str(uuid4()), user_id=str(uuid4())), + headers={}, + ) + body = ToolCall( + data=ToolCallData( + id="outer_call", + function=ToolCallFunction( + name="tools.agenta.test_run", + arguments=_args( + delta={"set": {"parameters": {"agent": {"model": "changed"}}}} + ), + ), + ) + ) + + with pytest.raises(HTTPException) as exc_info: + await router.call_tool(request, body=body) + + assert exc_info.value.status_code == 400 + assert "target workflow variant revision" in exc_info.value.detail + assert workflows.ensure_calls + assert not workflows.delta_calls + assert not workflows.prepare_calls + assert http.calls == [] + + +@pytest.mark.parametrize( + "delta", + [ + {"set": {"url": "https://evil.example"}}, + {"set": {"uri": "agenta:builtin:agent:v0"}}, + {"set": {"headers": {"X-Exfil": "1"}}}, + {"set": {"parameters": {"agent": {}}, "url": "https://evil.example"}}, + {"remove": ["url"]}, + {"remove": ["parameters_sibling"]}, + ], +) +async def test_test_run_delta_outside_parameters_is_refused(monkeypatch, delta): + # The delta scope guard: anything outside the `parameters` tree could redirect the + # server-side child invoke (url/uri/headers), so it is refused before any resolution. + http = HttpxController(monkeypatch) + workflows = FakeWorkflowsService() + + with pytest.raises(PlatformToolHandlerRefused) as exc_info: + await handle_test_run( + arguments=_args(delta=delta), + headers={}, + project_id=uuid4(), + user_id=uuid4(), + workflows_service=workflows, + tracing_service=FakeTracingService(), + ) + + assert "parameters" in exc_info.value.message + assert not workflows.ensure_calls + assert not workflows.delta_calls + assert not workflows.prepare_calls + assert http.calls == [] + + +async def test_test_run_delta_requires_edit_workflows_permission(monkeypatch): + seen_permissions = [] + + async def _run_tools_only(*, permission, **_kwargs): + seen_permissions.append(permission) + return permission == Permission.RUN_TOOLS + + monkeypatch.setattr( + "oss.src.apis.fastapi.tools.router.check_action_access", + _run_tools_only, + ) + http = HttpxController(monkeypatch) + workflows = FakeWorkflowsService() + router = ToolsRouter( + tools_service=SimpleNamespace(), + workflows_service=workflows, + tracing_service=FakeTracingService(), + ) + request = SimpleNamespace( + state=SimpleNamespace(project_id=str(uuid4()), user_id=str(uuid4())), + headers={}, + ) + body = ToolCall( + data=ToolCallData( + id="outer_call", + function=ToolCallFunction( + name="tools.agenta.test_run", + arguments=_args( + delta={"set": {"parameters": {"agent": {"model": "changed"}}}} + ), + ), + ) + ) + + with pytest.raises(HTTPException) as exc_info: + await router.call_tool(request, body=body) + + assert exc_info.value.status_code == 403 + assert seen_permissions == [Permission.RUN_TOOLS, Permission.EDIT_WORKFLOWS] + assert not workflows.ensure_calls + assert not workflows.delta_calls + assert not workflows.prepare_calls + assert http.calls == [] + + +async def test_test_run_delta_with_edit_workflows_permission_invokes(monkeypatch): + seen_permissions = [] + + async def _allow(*, permission, **_kwargs): + seen_permissions.append(permission) + return True + + monkeypatch.setattr("oss.src.apis.fastapi.tools.router.check_action_access", _allow) + http = HttpxController( + monkeypatch, + response=_response({"messages": [{"role": "assistant", "content": "ok"}]}), + ) + workflows = FakeWorkflowsService( + delta_data={ + "url": "https://agent.internal", + "parameters": {"agent": {"model": "changed"}}, + } + ) + router = ToolsRouter( + tools_service=SimpleNamespace(), + workflows_service=workflows, + tracing_service=FakeTracingService(), + ) + request = SimpleNamespace( + state=SimpleNamespace(project_id=str(uuid4()), user_id=str(uuid4())), + headers={}, + ) + body = ToolCall( + data=ToolCallData( + id="outer_call", + function=ToolCallFunction( + name="tools.agenta.test_run", + arguments=_args( + terminal_tool=None, + delta={"set": {"parameters": {"agent": {"model": "changed"}}}}, + ), + ), + ) + ) + + response = await router.call_tool(request, body=body) + + content = json.loads(response.call.data.content) + assert content["verdict"] == "unconfirmed" + assert seen_permissions == [Permission.RUN_TOOLS, Permission.EDIT_WORKFLOWS] + assert workflows.ensure_calls + assert workflows.delta_calls + assert workflows.prepare_calls + assert len(http.calls) == 1 + + +async def test_test_run_paused_interaction_returns_approval_and_unconfirmed( + monkeypatch, +): + outputs = { + "messages": [ + {"role": "assistant", "content": "need approval"}, + { + "role": "tool", + "content": "", + "tool_call_id": "call_1", + "tool_name": "slack__SEND_MESSAGE", + }, + ], + "stop_reason": "paused", + "pending_interaction": { + "id": "perm_1", + "payload": { + "toolCall": {"toolCallId": "call_1", "name": "slack__SEND_MESSAGE"} + }, + }, + } + + result, _, _ = await _run(monkeypatch, outputs=outputs) + + assert result.approvals == ["slack__SEND_MESSAGE"] + assert result.verdict == "unconfirmed" + assert "waiting for approval" in result.verdict_reason + + +async def test_test_run_terminal_tool_result_wins_over_later_pause(monkeypatch): + outputs = { + "messages": [ + { + "role": "tool", + "content": "", + "tool_call_id": "call_1", + "tool_name": "slack__SEND_MESSAGE", + }, + {"role": "tool", "content": "ok", "tool_call_id": "call_1"}, + ], + "stop_reason": "paused", + } + + result, _, _ = await _run(monkeypatch, outputs=outputs) + + assert result.verdict == "pass" + assert result.verdict_reason is None + + +async def test_test_run_terminal_tool_without_output_is_unconfirmed_when_not_paused( + monkeypatch, +): + outputs = { + "messages": [ + { + "role": "tool", + "content": "", + "tool_call_id": "call_1", + "tool_name": "slack__SEND_MESSAGE", + } + ], + "stop_reason": "stop", + } + + result, _, _ = await _run(monkeypatch, outputs=outputs) + + assert result.verdict == "unconfirmed" + assert "did not return output" in result.verdict_reason + + +async def test_test_run_tool_turn_ending_uses_last_assistant_output(monkeypatch): + outputs = { + "messages": [ + {"role": "assistant", "content": "about to send"}, + { + "role": "tool", + "content": "", + "tool_call_id": "call_1", + "tool_name": "slack__SEND_MESSAGE", + }, + {"role": "tool", "content": "ok", "tool_call_id": "call_1"}, + ], + "stop_reason": "stop", + } + + result, _, _ = await _run(monkeypatch, outputs=outputs) + + assert result.output == "about to send" + assert result.verdict == "pass" + + +async def test_test_run_terminal_tool_missing_is_incomplete(monkeypatch): + outputs = { + "messages": [ + {"role": "assistant", "content": "I stopped early"}, + { + "role": "tool", + "content": "", + "tool_call_id": "call_1", + "tool_name": "github__LIST_COMMITS", + }, + {"role": "tool", "content": "[]", "tool_call_id": "call_1"}, + ], + "stop_reason": "stop", + } + + result, _, _ = await _run(monkeypatch, outputs=outputs) + + assert result.verdict == "incomplete" + assert "slack__SEND_MESSAGE" in result.verdict_reason + + +async def test_test_run_same_tool_twice_keeps_the_earlier_error(monkeypatch): + # Error flags accumulate per tool name: a second, successful call to the same tool + # must not wash out the first call's error. + outputs = { + "messages": [ + { + "role": "tool", + "content": "", + "tool_call_id": "call_1", + "tool_name": "slack__SEND_MESSAGE", + }, + { + "role": "tool", + "content": "boom", + "tool_call_id": "call_1", + "is_error": True, + }, + { + "role": "tool", + "content": "", + "tool_call_id": "call_2", + "tool_name": "slack__SEND_MESSAGE", + }, + {"role": "tool", "content": "ok", "tool_call_id": "call_2"}, + ], + "stop_reason": "stop", + } + + result, _, _ = await _run(monkeypatch, outputs=outputs) + + assert result.verdict == "failed" + assert "slack__SEND_MESSAGE" in result.verdict_reason + + +async def test_test_run_recursion_marker_is_refused(monkeypatch): + HttpxController(monkeypatch) + + with pytest.raises(PlatformToolHandlerRefused): + await handle_test_run( + arguments=_args(), + headers={TEST_RUN_RECURSION_HEADER: TEST_RUN_RECURSION_VALUE}, + project_id=uuid4(), + user_id=uuid4(), + workflows_service=FakeWorkflowsService(), + tracing_service=FakeTracingService(), + ) + + +async def test_test_run_timeout_returns_failed_verdict(monkeypatch): + result, _, _ = await _run( + monkeypatch, + outputs={"messages": []}, + raises=httpx.ReadTimeout("too slow"), + ) + + assert result.verdict == "failed" + assert "timed out" in result.verdict_reason + + +async def test_test_run_retries_spans_for_returned_confirmation(monkeypatch): + outputs = { + "messages": [ + { + "role": "tool", + "content": "", + "tool_call_id": "call_1", + "tool_name": "slack__SEND_MESSAGE", + } + ], + "stop_reason": "stop", + } + tracing = FakeTracingService([], [_span("slack__SEND_MESSAGE")]) + + result, _, _ = await _run(monkeypatch, outputs=outputs, tracing=tracing) + + assert len(tracing.calls) == 2 + assert result.verdict == "pass" + assert result.tools[0].returned is True + + +async def test_registered_test_run_dispatches_through_tools_call(monkeypatch): + async def _allow(**_kwargs): + return True + + monkeypatch.setattr("oss.src.apis.fastapi.tools.router.check_action_access", _allow) + HttpxController( + monkeypatch, + response=_response( + { + "messages": [ + { + "role": "tool", + "content": "", + "tool_call_id": "call_1", + "tool_name": "slack__SEND_MESSAGE", + }, + {"role": "tool", "content": "ok", "tool_call_id": "call_1"}, + ], + "stop_reason": "stop", + } + ), + ) + router = ToolsRouter( + tools_service=SimpleNamespace(), + workflows_service=FakeWorkflowsService(), + tracing_service=FakeTracingService(), + ) + request = SimpleNamespace( + state=SimpleNamespace(project_id=str(uuid4()), user_id=str(uuid4())), + headers={}, + ) + body = ToolCall( + data=ToolCallData( + id="outer_call", + function=ToolCallFunction(name="tools.agenta.test_run", arguments=_args()), + ) + ) + + response = await router.call_tool(request, body=body) + + content = json.loads(response.call.data.content) + assert response.call.data.tool_call_id == "outer_call" + assert content["verdict"] == "pass" + assert response.call.status.code == "STATUS_CODE_OK" + + +async def test_infra_failure_surfaces_as_error_status_on_the_tool_result(monkeypatch): + # A run the child invoke never completed (timeout/5xx) must be distinguishable from a + # business-level failed verdict on the OUTER status, mirroring the adapter path. + async def _allow(**_kwargs): + return True + + monkeypatch.setattr("oss.src.apis.fastapi.tools.router.check_action_access", _allow) + HttpxController(monkeypatch, raises=httpx.ReadTimeout("too slow")) + router = ToolsRouter( + tools_service=SimpleNamespace(), + workflows_service=FakeWorkflowsService(), + tracing_service=FakeTracingService(), + ) + request = SimpleNamespace( + state=SimpleNamespace(project_id=str(uuid4()), user_id=str(uuid4())), + headers={}, + ) + body = ToolCall( + data=ToolCallData( + id="outer_call", + function=ToolCallFunction(name="tools.agenta.test_run", arguments=_args()), + ) + ) + + response = await router.call_tool(request, body=body) + + content = json.loads(response.call.data.content) + assert content["verdict"] == "failed" + assert response.call.status.code == "STATUS_CODE_ERROR" + assert "timed out" in response.call.status.message + # The infra marker itself never rides in the tool-result payload. + assert "infra_failure" not in content 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 96ad87ae26..d2e5ef636a 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 @@ -57,7 +57,8 @@ def _router(workflows_service): def _request(): return SimpleNamespace( - state=SimpleNamespace(project_id=str(uuid4()), user_id=str(uuid4())) + state=SimpleNamespace(project_id=str(uuid4()), user_id=str(uuid4())), + headers={}, ) @@ -70,6 +71,24 @@ def _call(name: str, arguments) -> ToolCall: ) +async def test_unknown_tools_agenta_call_ref_posted_to_tools_call_fails_loud( + monkeypatch, +): + async def _allow(**_kwargs): + return True + + monkeypatch.setattr("oss.src.apis.fastapi.tools.router.check_action_access", _allow) + + with pytest.raises(HTTPException) as caught: + await _router(FakeWorkflowsService(outputs={})).call_tool( + _request(), + body=_call("tools.agenta.unknown", {}), + ) + + assert caught.value.status_code == 404 + assert "Unknown reserved Agenta tool handler" in caught.value.detail + + async def test_invokes_workflow_by_variant_slug_and_returns_outputs(): workflows = FakeWorkflowsService(outputs={"summary": "ok"}) router = _router(workflows) diff --git a/api/oss/tests/pytest/unit/tracing/test_query_spans_op_contract.py b/api/oss/tests/pytest/unit/tracing/test_query_spans_op_contract.py new file mode 100644 index 0000000000..3f20b72b71 --- /dev/null +++ b/api/oss/tests/pytest/unit/tracing/test_query_spans_op_contract.py @@ -0,0 +1,67 @@ +from agenta.sdk.agents.platform.op_catalog import PLATFORM_OPS +from oss.src.apis.fastapi.tracing.models import SpansQueryRequest + + +def _ref_name(ref: str) -> str: + return ref.removeprefix("#/$defs/") + + +def _single_ref_name(node: dict) -> str: + refs = [item["$ref"] for item in node["anyOf"] if "$ref" in item] + assert len(refs) == 1 + return _ref_name(refs[0]) + + +def test_query_spans_op_schema_round_trips_through_endpoint_model(): + schema = PLATFORM_OPS["query_spans"].resolved_input_schema() + endpoint_schema = SpansQueryRequest.model_json_schema() + + assert PLATFORM_OPS["query_spans"].path == "/api/spans/query" + assert set(schema["properties"]) == set(SpansQueryRequest.model_fields) + assert set(schema["properties"]) == set(endpoint_schema["properties"]) + + defs = schema["$defs"] + endpoint_defs = endpoint_schema["$defs"] + for name in ("Filtering", "Condition", "Windowing", "Reference"): + assert set(defs[name]["properties"]) == set(endpoint_defs[name]["properties"]) + + assert _single_ref_name(schema["properties"]["filtering"]) == "Filtering" + assert _single_ref_name(schema["properties"]["windowing"]) == "Windowing" + assert _single_ref_name(schema["properties"]["query_ref"]) == "Reference" + assert _single_ref_name(schema["properties"]["query_variant_ref"]) == "Reference" + assert _single_ref_name(schema["properties"]["query_revision_ref"]) == "Reference" + + payload = { + "filtering": { + "operator": "and", + "conditions": [ + {"field": "trace_id", "operator": "is", "value": "trace-123"} + ], + }, + "windowing": { + "oldest": "2026-07-04T10:00:00Z", + "newest": "2026-07-04T10:05:00Z", + "next": "00000000-0000-0000-0000-000000000001", + "limit": 25, + "order": "descending", + "interval": 60, + "rate": 1.0, + }, + "query_ref": {"slug": "recent-agent-runs"}, + "query_variant_ref": {"slug": "recent-agent-runs", "version": "latest"}, + "query_revision_ref": {"id": "00000000-0000-0000-0000-000000000002"}, + } + + assert set(payload) == set(schema["properties"]) + assert set(payload) <= set(SpansQueryRequest.model_fields) + + validated = SpansQueryRequest.model_validate(payload) + + assert validated.model_fields_set == set(payload) + for key in payload: + assert getattr(validated, key) is not None, f"{key} was silently dropped" + assert key in validated.model_dump(exclude_unset=True) + + assert validated.filtering.conditions[0].field == "trace_id" + assert validated.windowing.limit == 25 + assert validated.query_ref.slug == "recent-agent-runs" diff --git a/api/oss/tests/pytest/unit/triggers/test_triggers_discovery.py b/api/oss/tests/pytest/unit/triggers/test_triggers_discovery.py index 181d972f7d..5e2614dba0 100644 --- a/api/oss/tests/pytest/unit/triggers/test_triggers_discovery.py +++ b/api/oss/tests/pytest/unit/triggers/test_triggers_discovery.py @@ -1,4 +1,4 @@ -"""Unit tests for trigger event discovery (find_triggers).""" +"""Unit tests for trigger event discovery (discover_triggers).""" from types import SimpleNamespace from uuid import uuid4 diff --git a/api/oss/tests/pytest/unit/workflows/test_static_catalog.py b/api/oss/tests/pytest/unit/workflows/test_static_catalog.py index 743b35412a..522efdf080 100644 --- a/api/oss/tests/pytest/unit/workflows/test_static_catalog.py +++ b/api/oss/tests/pytest/unit/workflows/test_static_catalog.py @@ -39,11 +39,26 @@ StaticWorkflowSlug, is_static_workflow_slug, ) -from agenta.sdk.agents.adapters.agenta_builtins import GETTING_STARTED_WITH_AGENTA_SLUG +from agenta.sdk.agents.adapters.agenta_builtins import ( + BUILD_AN_AGENT_SLUG, + GETTING_STARTED_WITH_AGENTA_SLUG, +) -# The default catalogue's one static workflow (the getting-started skill). +# The default catalogue keeps getting-started forced and build-an-agent overlay-resolvable. _STATIC_SLUG = GETTING_STARTED_WITH_AGENTA_SLUG +_PLAYBOOK_SLUG = BUILD_AN_AGENT_SLUG + + +def _old_authoring_slug(name: str) -> str: + return "__ag__" + name + + +_OLD_AUTHORING_SKILL_SLUGS = { + _old_authoring_slug("build_your_first_app"), + _old_authoring_slug("discover_and_wire_tools"), + _old_authoring_slug("set_up_triggers"), +} # A two-version catalogue used to pin that the artifact / variant ids are stable across versions @@ -72,6 +87,16 @@ def _demo_revision(description: str) -> WorkflowRevision: # --------------------------------------------------------------------------- +def test_list_slugs_enumerates_resolvable_reserved_slugs(): + catalog = StaticWorkflowCatalog() + + slugs = catalog.list_slugs() + assert {_STATIC_SLUG, _PLAYBOOK_SLUG} <= set(slugs) + for slug in slugs: + assert slug.startswith(STATIC_SLUG_PREFIX) + assert catalog.retrieve_revision(slug=slug) is not None + + def test_is_static_slug(): catalog = StaticWorkflowCatalog() @@ -82,6 +107,28 @@ def test_is_static_slug(): assert catalog.is_static_slug(None) is False +def test_default_static_skill_catalog_replaces_old_authoring_skills(): + catalog = StaticWorkflowCatalog() + skill_slugs = { + slug + for slug in catalog.list_slugs() + if (revision := catalog.retrieve_revision(slug=slug)) + and revision.flags + and revision.flags.is_skill + } + + assert skill_slugs == {_STATIC_SLUG, _PLAYBOOK_SLUG} + assert _OLD_AUTHORING_SKILL_SLUGS.isdisjoint(catalog.list_slugs()) + for slug in _OLD_AUTHORING_SKILL_SLUGS: + assert catalog.retrieve_revision(slug=slug) is None + + playbook = catalog.retrieve_revision(slug=_PLAYBOOK_SLUG) + skill = playbook.data.parameters["skill"] + assert skill["name"] == "build-an-agent" + assert skill["body"].startswith("# Build an Agenta agent") + assert "query_spans" in skill["body"] + + def test_artifact_level_lookup_resolves_current(): catalog = StaticWorkflowCatalog() @@ -134,6 +181,10 @@ def test_unknown_reserved_slug_returns_none(): catalog = StaticWorkflowCatalog() assert catalog.retrieve_revision(slug=STATIC_SLUG_PREFIX + "does-not-exist") is None + assert ( + catalog.retrieve_revision(slug=_old_authoring_slug("build_your_first_app")) + is None + ) # --------------------------------------------------------------------------- @@ -164,7 +215,7 @@ async def test_fetch_revision_short_circuits_reserved_artifact_ref(): @pytest.mark.asyncio -async def test_default_agent_skill_embed_resolves_through_static_catalog_without_db(): +async def test_build_agent_skill_embed_resolves_through_static_catalog_without_db(): workflows_dao = AsyncMock() workflows_service = WorkflowsService( workflows_dao=workflows_dao, @@ -184,7 +235,9 @@ async def test_default_agent_skill_embed_resolves_through_static_catalog_without "skills": [ { "@ag.embed": { - "@ag.references": {"workflow": {"slug": _STATIC_SLUG}}, + "@ag.references": { + "workflow": {"slug": _PLAYBOOK_SLUG} + }, "@ag.selector": {"path": "parameters.skill"}, } } @@ -203,10 +256,10 @@ async def test_default_agent_skill_embed_resolves_through_static_catalog_without ) skill = resolved_revision.data.parameters["agent"]["skills"][0] - assert skill["name"] == "agenta-getting-started" - assert skill["body"].startswith("# Getting started with Agenta agents") + assert skill["name"] == "build-an-agent" + assert skill["body"].startswith("# Build an Agenta agent") assert resolution_info.embeds_resolved == 1 - # Resolving the static default skill must use the catalogue, not Postgres. + # Resolving the static playbook skill must use the catalogue, not Postgres. workflows_dao.fetch_revision.assert_not_awaited() workflows_dao.fetch_artifact.assert_not_awaited() diff --git a/docs/design/agent-workflows/documentation/agent-configuration.md b/docs/design/agent-workflows/documentation/agent-configuration.md index edf390d224..521b78f6ac 100644 --- a/docs/design/agent-workflows/documentation/agent-configuration.md +++ b/docs/design/agent-workflows/documentation/agent-configuration.md @@ -214,7 +214,7 @@ Legend: (a) catalog/schema, (b) SDK neutral config, (c) runtime. | model / provider | yes, `model: str` | yes, `Optional[str]` | wired to the runner | Loose string. No `ModelRef`, no provider enum. There is no separate provider field. | | tools | yes, strict list | yes, lenient coercion | wired, resolved to builtin names + tool specs | Entries strict, list lenient. | | mcp_servers | yes, strict list | yes | wired, resolved to runner mcp servers | Strict per entry. Gated by `AGENTA_AGENT_ENABLE_MCP` at the service. | -| skills | no | no | wired but forced only | Not author-settable. Only the Agenta harness injects forced skills. See below. | +| skills | yes, embed/inline list | yes | wired | Author-settable (`SkillConfig` inline or `@ag.embed` references). The playground build-kit overlay embeds one skill, the `build-an-agent` playbook; the `pi_agenta` harness additionally force-unions `getting-started`. See below. | | persona | no | no | wired but forced only | Not a config field. The Agenta harness hardcodes an append-system preamble. See below. | | agents_md | yes, `agents_md: str` | yes, as `instructions` | wired to `agentsMd` | The schema names it `agents_md`. The neutral config names it `instructions`. | | harness | yes, enum | yes, on `AgentConfig` | wired, picks the harness class | Enum-enforced. The runtime validates via `make_harness`. | @@ -223,15 +223,19 @@ Legend: (a) catalog/schema, (b) SDK neutral config, (c) runtime. ## Notable gaps and quirks -`skills` and `persona` are not author config. They are runtime injections of the Agenta -harness only. `skills` is a `List[str]` on `AgentaAgentConfig`, force-populated from a fixed -list. `persona` is a forced append-system string. Neither appears in any schema, neither -appears on the neutral config, and the playground renders no control for either. Pi and -Claude harnesses get no forced skills or persona. +`persona` is not author config; it is a runtime injection of the Agenta harness only (a forced +append-system string). `skills` used to work the same way, but is author config now: inline +`SkillConfig` packages or `@ag.embed` references the backend inlines before the runner sees +them. Two platform skills still arrive without the author writing anything: the playground +build-kit overlay embeds the `build-an-agent` playbook, and the `pi_agenta` harness +force-unions the `getting-started` skill (`AGENTA_FORCED_SKILLS` in +`sdks/python/agenta/sdk/agents/adapters/agenta_builtins.py`). Each is delivered exactly once. +Pi (`pi_core`) and Claude harnesses get no forced skills or persona. Per-harness divergence is real in other ways, but not in permission enforcement anymore: the permission policy is now enforced on both Claude and Pi. Builtin tool names are dropped for -Claude with a warning, because builtins are Pi-only. Skills and persona are Agenta-only. Pi's +Claude with a warning, because builtins are Pi-only. Forced skills and persona are +Agenta-only. Pi's `system` and `append_system` overrides come through the `harness_kwargs` escape hatch on the neutral config, which is itself absent from the schema. diff --git a/docs/design/agent-workflows/documentation/tools.md b/docs/design/agent-workflows/documentation/tools.md index b54fc8c36a..0205a776ce 100644 --- a/docs/design/agent-workflows/documentation/tools.md +++ b/docs/design/agent-workflows/documentation/tools.md @@ -35,7 +35,7 @@ shares two fields through `ToolConfigBase`, and then a `type` discriminator pick | `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. | -| `platform` | `op` (a platform-op catalog key), optional `permission` | An existing Agenta endpoint exposed to the agent (e.g. `find_capabilities`, `query_workflows`, `commit_revision`); the runner calls the endpoint directly. See [Platform tools](#platform-tools-existing-agenta-endpoints) below. | +| `platform` | `op` (a platform-op catalog key), optional `permission` | An existing Agenta endpoint exposed to the agent (e.g. `discover_tools`, `query_spans`, `commit_revision`); the runner calls the endpoint directly. See [Platform tools](#platform-tools-existing-agenta-endpoints) below. | A tool can also be a **workflow** referenced as a tool: @@ -430,32 +430,69 @@ Each catalog entry (`PlatformOp`, a typed model validated at import) maps an `op | Field | Meaning | | --- | --- | | `description` | The model-facing description (SDK-owned). | -| `method`, `path` | The existing endpoint to call: `GET`/`POST` and a relative `/api/...` path. | +| `method`, `path` | The existing endpoint to call: `GET`/`POST` and a relative `/api/...` path. Endpoint-mode ops set these; a handler-mode op sets `handler` instead (exactly one of the two targets). | +| `handler` | A reserved `tools.agenta.` call-ref for a server-side handler (see [server-handled ops](#server-handled-ops-handler-mode)). Mutually exclusive with `method`+`path`. | | `input_schema` / `input_schema_ref` | The request input schema — inline JSON Schema, or a `CATALOG_TYPES` key (expanded via `x-ag-type-ref`). Exactly one. | -| `context_bindings` | Self-targeting fields: an endpoint body path → a `$ctx.` run-context token. Stripped from the model schema; emitted as `call.context`. | +| `context_bindings` | Self-targeting fields: an endpoint body path → a `$ctx.` run-context token. Stripped from the model schema; emitted as `call.context` (endpoint mode) or spec-level `contextBindings` (handler mode). | +| `timeout_ms` | Optional per-op execution budget, emitted as `timeoutMs` on the resolved spec. Used by long-running handler ops (`test_run` sets 120s). | | `read_only` | A bool hint, not a gate value. Under the `allow_reads` policy default it decides the op's effective permission: `true` runs without asking, anything else asks. The tool's own explicit `permission` (`allow`/`ask`/`deny`) always overrides this hint. | -Each op has a stable reserved id, `tools.agenta.` (the same namespace as the original -`find_capabilities`). The first, minimal-useful set of ops: +Each op has a stable reserved id, `tools.agenta.`. The catalog holds every exposable op +(discovery, workflow reads/writes, tracing reads, and the trigger/schedule/subscription +lifecycle). The playground **build-kit overlay** embeds an explicit default subset, +`DEFAULT_BUILD_KIT_OPS` in `api/oss/src/apis/fastapi/applications/overlay.py`: `discover_tools`, +`commit_revision`, `annotate_trace`, `query_spans`, `discover_triggers`, `create_schedule`, +`create_subscription`, `list_schedules`, `list_deliveries`, `test_subscription`, +`remove_schedule`, and `remove_subscription` (12 ops, plus the `request_connection` client +tool and the build-an-agent playbook skill). Every other catalog op (the pause/resume +lifecycle, `query_workflows`, `list_connections`, `list_subscriptions`) stays a catalog +opt-in: an author adds `{type:"platform", op}` explicitly. The rationale for the cut list +lives in the [build-kit-tools-cleanup workspace](../projects/build-kit-tools-cleanup/research.md). + +A few ops worth naming: | Op | Endpoint | Gate | Notes | | --- | --- | --- | --- | -| `find_capabilities` | `POST /api/tools/discover` | read (auto-allow) | Tool discovery; makes the discover endpoint agent-usable end to end (see below). | -| `query_workflows` | `POST /api/workflows/query` | read (auto-allow) | List the project's workflow artifacts with optional filters. | +| `discover_tools` | `POST /api/tools/discover` | read (auto-allow) | Tool discovery; turns plain-language use cases into Agenta-shaped tools (see below). Renamed from `find_capabilities` (hard migrate, no alias). | +| `discover_triggers` | `POST /api/triggers/discover` | read (auto-allow) | Trigger discovery. Renamed from `find_triggers` (hard migrate, no alias). | +| `query_spans` | `POST /api/spans/query` | read (auto-allow) | Read spans from past runs, so the builder can verify its own work. The op schema mirrors `SpansQueryRequest`; a drift contract test pins the two together. | | `commit_revision` | `POST /api/workflows/revisions/commit` | mutating (approval) | "Update yourself": binds `workflow_revision.workflow_variant_id` ← `$ctx.workflow.variant.id`, so the agent can only ever commit a revision to its own variant. | - -This mirrors two existing patterns: the reserved `tools.agenta.*` tool (PR #4884, -`find_capabilities`) and the evaluators catalog -(`api/oss/src/resources/evaluators/evaluators.py`, a code-defined table of named ops). Multi-step -operations (e.g. create-then-commit) are composed by the harness across several endpoint-wrapper -calls, guided by a skill — not collapsed into a new convenience endpoint. We expose the endpoints -we have; we do not add new ones. - -## Tool discovery: `find_capabilities` - -Before an agent can attach a tool it has to find the right one. `find_capabilities` turns a set +| `test_run` | handler `tools.agenta.test_run` | mutating (approval) | Run the agent's own variant once and return a digest + verdict. Handler mode, flag-gated off, not in the overlay yet (see below). | + +This mirrors the evaluators catalog pattern (`api/oss/src/resources/evaluators/evaluators.py`, +a code-defined table of named ops). Multi-step operations (e.g. create-then-commit) are composed +by the harness across several endpoint-wrapper calls, guided by a skill, not collapsed into a +new convenience endpoint. We expose the endpoints we have; a handler op is the one exception, +reserved for logic that cannot be a thin endpoint wrapper. + +### Server-handled ops (handler mode) + +Most platform ops are thin wrappers over an existing endpoint. A **handler-mode** op carries +real server-side logic instead: the catalog entry sets `handler` (a reserved `tools.agenta.` +call-ref) rather than `method`+`path`, and the resolver emits a `CallbackToolSpec` with that +`call_ref` plus spec-level `contextBindings` and `timeoutMs`. The call routes through +`POST /tools/call`, where a reserved-ref registry +(`api/oss/src/core/tools/platform_handlers.py`) dispatches it to a registered Python handler. +An unknown reserved ref fails loud with a 404. + +The first handler op is `test_run`: it hydrates the bound variant's revision, applies an +optional in-memory `delta` (which requires `EDIT_WORKFLOWS`), invokes the workflow headless +with a server-minted token, digests the transcript and spans, and returns a verdict (the +terminal result wins). It carries a recursion marker (inert until the runner half lands) and a +120s ceiling. + +**Status:** the server half only. Resolution of handler-mode ops is gated off by +`AGENTA_AGENT_ENABLE_PLATFORM_HANDLERS` (default off) until the runner learns to dispatch a +reserved `call_ref` with spec-level context injection and `timeoutMs`; `test_run` joins the +overlay when that flips. Contract and slice plan: +[build-kit-tools-cleanup api-design](../projects/build-kit-tools-cleanup/api-design.md). + +## Tool discovery: `discover_tools` + +Before an agent can attach a tool it has to find the right one. `discover_tools` (named +`find_capabilities` until the build-kit cleanup hard-migrated it, no alias) turns a set of plain-language use cases into Agenta-shaped tools, each integration's connection state, and -operating guidance — in one call, so a builder agent never guesses slugs or learns Composio. +operating guidance, in one call, so a builder agent never guesses slugs or learns Composio. - **Endpoint:** `POST /tools/discover` (project-scoped via caller auth, `VIEW_TOOLS`). Request: `{use_cases: string[], provider?: "composio", limit_alternatives?: 3}`. Response: the @@ -470,17 +507,19 @@ operating guidance — in one call, so a builder agent never guesses slugs or le - **Scope (v1):** action tools only. A use case that reads like a trigger ("listen for…") is flagged in `notes` and on the capability; event listening is a separate trigger subscription (a follow-up). Composio has no semantic trigger search. -- **Agent-facing tool:** `find_capabilities` is the first [platform tool](#platform-tools-existing-agenta-endpoints). - An agent config declares it as `{type:"platform", op:"find_capabilities"}`, and +- **Agent-facing tool:** `discover_tools` is the first [platform tool](#platform-tools-existing-agenta-endpoints). + An agent config declares it as `{type:"platform", op:"discover_tools"}`, and `platform.resolve_tools` emits a `CallbackToolSpec` with a direct `call` to - `POST /api/tools/discover` — so the model can call it end to end (no `/tools/call` hop). The - server-side `/tools/call` `tools.agenta.*` dispatch (the original delivery path) still exists for - now and is removed in a later phase once nothing routes through it. The runner needs no change. + `POST /api/tools/discover`, so the model calls it end to end (no `/tools/call` hop). The + original server-side `/tools/call` `tools.agenta.find_capabilities` dispatch is deleted; the + reserved `tools.agenta.*` namespace now belongs to the + [handler registry](#server-handled-ops-handler-mode). The runner needs no change. The contract and the field-by-field Composio→Agenta mapping live in the -[tool-discovery design](../../projects/tool-discovery/design.md). The setup-agent loop -(discover → resolve connections → create → test) is the -[discover-and-wire-tools skill](../../projects/tool-discovery/skills/discover-and-wire-tools/SKILL.md). +[tool-discovery design](../projects/tool-discovery/design.md). The setup loop +(discover → wire connections → build → test → schedule) is one ordered playbook skill, +`build-an-agent` (slug `__ag__build_an_agent`), which replaced the three earlier authoring +skills; see the [skills port](../projects/build-kit-tools-cleanup/skills-port.md). ## The whole picture @@ -511,7 +550,9 @@ The contract and the field-by-field Composio→Agenta mapping live in the | Named-secret resolution (`/secrets/resolve`) | `sdks/python/agenta/sdk/agents/platform/secrets.py` (shim: `services/oss/src/agent/tools/secrets.py`) | | API resolve + execute | `api/oss/src/core/tools/service.py`, `api/oss/src/apis/fastapi/tools/router.py` | | Tool discovery (search + Composio→Agenta translation) | `api/oss/src/core/tools/discovery.py`, `service.py` (`discover_capabilities`) | -| Discovery endpoint + reserved-tool route | `api/oss/src/apis/fastapi/tools/router.py` (`/tools/discover`, `_call_agenta_tool`) | +| Discovery endpoint + reserved-handler dispatch | `api/oss/src/apis/fastapi/tools/router.py` (`/tools/discover`, `_call_reserved_agenta_tool`) | +| Server-side platform-op handlers (reserved-ref registry, `test_run`) | `api/oss/src/core/tools/platform_handlers.py` | +| Build-kit overlay defaults (`DEFAULT_BUILD_KIT_OPS` + skill/tool embeds) | `api/oss/src/apis/fastapi/applications/overlay.py` | | Wire contract | `services/agent/src/protocol.ts`, `sdks/python/agenta/sdk/agents/utils/wire.py` | | Tool-delivery fork (branch on `mcpTools`) | `services/agent/src/engines/sandbox_agent/mcp.ts` | | Runtime dispatch (branch on `kind`) | `services/agent/src/tools/dispatch.ts` | @@ -545,15 +586,24 @@ The contract and the field-by-field Composio→Agenta mapping live in the - **Code tools are standard-library-only.** The image ships `python3` and `node`, but the child env has no package install and no module path to the runner's dependencies, so a tool cannot import third-party packages. -- **Tool discovery is now agent-usable** as the `find_capabilities` platform tool: an agent config - declares `{type:"platform", op:"find_capabilities"}` and the model calls - `POST /api/tools/discover` directly. The old server-side `/tools/call` `tools.agenta.*` dispatch - is left in place during the migration and removed in a later phase. v1 discovery covers action - tools, not triggers. -- **Platform tools are SDK-resolved; the first set is small** (`find_capabilities`, - `query_workflows`, `commit_revision`). More ops are a data add to the catalog. The reference - tool still executes through the `/tools/call` `workflow.*` route; moving it to a direct `call` - and removing that route is a later phase. +- **Tool discovery is agent-usable** as the `discover_tools` platform tool: an agent config + declares `{type:"platform", op:"discover_tools"}` and the model calls + `POST /api/tools/discover` directly. The legacy server-side `/tools/call` + `tools.agenta.find_capabilities` dispatch is deleted; the reserved namespace now serves the + handler registry. Trigger discovery is its own read op, `discover_triggers`. +- **Platform tools are SDK-resolved from a catalog of ~20 ops**; the playground build-kit + overlay embeds an explicit 12-op default (`DEFAULT_BUILD_KIT_OPS`), and the rest stay + catalog opt-ins. More ops are a data add to the catalog. The reference tool still executes + through the `/tools/call` `workflow.*` route; moving it to a direct `call` and removing that + route is a later phase. +- **Handler-mode ops are server-half only.** `test_run` exists behind + `AGENTA_AGENT_ENABLE_PLATFORM_HANDLERS` (default off) and is not in the overlay; the runner + half (reserved `call_ref` dispatch, spec-level context injection, `timeoutMs`) is deferred. + See the [build-kit-tools-cleanup workspace](../projects/build-kit-tools-cleanup/status.md). +- **Old op names are gone, hard.** `find_capabilities` and `find_triggers` no longer resolve; + a committed revision that still carries them fails loud (`UnknownPlatformOpError`) until the + [revision sweep script](../projects/build-kit-tools-cleanup/scripts/sweep_platform_op_renames.py) + runs against that database. - **Harness capabilities are probed but not consumed.** The runner probes `HarnessCapabilities` per run (`engines/sandbox_agent/capabilities.ts`), uses them only for the internal `mcpTools` delivery branch, and returns them on the `/run` result. The result field is parsed into diff --git a/docs/design/agent-workflows/interfaces/README.md b/docs/design/agent-workflows/interfaces/README.md index 2c2a16efb1..57048a960a 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,direct}.ts`, `apis/fastapi/tools/router.py` (`/tools/call`, `/tools/discover`, `_call_agenta_tool`), `core/tools/{discovery,service}.py`, `agent/tools/resolver.py` | evolving (the `call` descriptor is wired; `find_capabilities` is now a platform tool emitting a direct `call`; the legacy `tools.agenta.*` `/tools/call` route is retained during migration) | `services/agent/tests/unit/{code-tool,extension-tools}.test.ts`, `api unit/tools/{test_workflow_tool_call,test_discovery}.py`, `unit/agents/platform/test_op_catalog.py` | +| [Runner to tool callback](cross-service/runner-to-tool-callback.md) | cross-service | `tools/{callback,dispatch,direct}.ts`, `apis/fastapi/tools/router.py` (`/tools/call`, `/tools/discover`, `_call_reserved_agenta_tool`), `core/tools/{discovery,service,platform_handlers}.py`, `agent/tools/resolver.py` | evolving (the `call` descriptor is wired and platform ops emit it; the legacy `tools.agenta.find_capabilities` route is deleted; reserved refs now dispatch server handlers, resolution flag-gated off until the runner half lands) | `services/agent/tests/unit/{code-tool,extension-tools}.test.ts`, `api unit/tools/{test_workflow_tool_call,test_discovery,test_platform_handlers}.py`, `unit/agents/platform/test_op_catalog.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 7a75c2b70e..597731bb1e 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 @@ -56,15 +56,18 @@ The same `POST /tools/call` serves three kinds of callback tool, routed by the ` `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. -- **`tools.agenta.{op}`** — a reserved Agenta platform tool (out of the Composio 5-segment - namespace). v1 op: `tools.agenta.find_capabilities`, routed by `_call_agenta_tool` to - `ToolsService.discover_capabilities` (same logic as the `POST /tools/discover` endpoint). The - `CapabilitiesResult` is serialized into `call.data.content`. **Status:** this server-side - `/tools/call` route still exists, but agents no longer reach it through this `call_ref` — - `find_capabilities` is now a [platform tool](../in-service/tool-models-and-resolution.md) whose - SDK resolution emits a direct `call` to `POST /api/tools/discover` (the `call` descriptor below), - bypassing `/tools/call`. The `tools.agenta.*` route is retained during migration and removed in a - later phase once nothing routes through it. +- **`tools.agenta.{op}`**: a reserved Agenta server-side handler (out of the Composio 5-segment + namespace). The router (`_call_reserved_agenta_tool`) dispatches a registered ref through the + handler registry in `api/oss/src/core/tools/platform_handlers.py`; an unregistered + `tools.agenta.*` ref fails loud with a 404. The first registered handler is + `tools.agenta.test_run` (run the agent's own variant once, digest + verdict; an argument + `delta` requires `EDIT_WORKFLOWS`). **Status:** the legacy v1 dispatch + (`tools.agenta.find_capabilities` via `_call_agenta_tool`) is deleted; discovery is the + `discover_tools` [platform tool](../in-service/tool-models-and-resolution.md), whose SDK + resolution emits a direct `call` to `POST /api/tools/discover` (the `call` descriptor below), + bypassing `/tools/call`. Handler-mode resolution is flag-gated off + (`AGENTA_AGENT_ENABLE_PLATFORM_HANDLERS`) until the runner dispatches reserved refs with + spec-level context injection. The runner is unchanged for all three: it relays a `callback` spec with whatever `call_ref` the resolver put on it. Only the router's prefix dispatch is aware of the grammars. @@ -87,18 +90,23 @@ deep-sets LAST — so a self-targeting tool's own trace/variant is filled server can never set or override a bound field. A token that does not resolve is skipped (the field stays unset); deep-set is prototype-pollution-safe. -**Status (direct-call tools):** Phase 1 added the `call` field (plumbing), Phase 2 added the -runner dispatch branch (host-direct via the relay path, with the SSRF guardrails), and Phase 3a -added the `runContext` wire field + the `call.context` binding in `assembleBody`. Live behavior is -still unchanged because **no resolver emits `call` or `call.context` yet** — gateway and reference -tools still route through `/tools/call`. The platform-op catalog that emits them is Phase 3b. Full -spec: `docs/design/agent-workflows/projects/direct-call-tools/`. +**Status (direct-call tools):** wired end to end for endpoint-mode platform ops. The SDK +platform-op resolver emits `call` (and `call.context` for self-targeting ops such as +`commit_revision`); the runner dispatches it host-direct with the SSRF guardrails. Gateway and +reference tools still route through `/tools/call`. Handler-mode platform ops add a second wire +shape: a reserved `tools.agenta.{op}` `call_ref` plus spec-level `contextBindings` and +`timeoutMs` (SDK side only so far: `tools/models.py` + `wire_models.py`); the resolver +only emits it behind `AGENTA_AGENT_ENABLE_PLATFORM_HANDLERS` (default off) because the runner +(`protocol.ts` included) does not yet carry or dispatch the new spec fields. Full spec: +`docs/design/agent-workflows/projects/direct-call-tools/` and +`docs/design/agent-workflows/projects/build-kit-tools-cleanup/api-design.md`. ## Owned by - `services/agent/src/tools/callback.ts`: the runner caller (sends the envelope, reads `content`). - `services/agent/src/tools/dispatch.ts`: runner-side dispatch. - `api/oss/src/apis/fastapi/tools/router.py`: the endpoint that parses, executes, and serializes. +- `api/oss/src/core/tools/platform_handlers.py`: the reserved-ref handler registry (`test_run`). - `services/oss/src/agent/tools/resolver.py`: re-exports the SDK resolver; no service-layer logic. ## Watch for when changing @@ -107,16 +115,21 @@ spec: `docs/design/agent-workflows/projects/direct-call-tools/`. reference, the `workflow.{axis}.*` reference (`workflow.variant.{slug}[.{version}]` / `workflow.environment.{environment}.{slug}`), the reserved `tools.agenta.{op}` reference, and the `__`/`.` normalization are a paired contract across runner and router. The router - dispatches by prefix: `workflow.` → `_call_workflow_tool`, `tools.agenta.` → `_call_agenta_tool`, - else the 5-segment Composio parse. Keep the SDK resolvers and the router parser in agreement. + dispatches by prefix: a registered reserved ref → `_call_reserved_agenta_tool` (the handler + registry), `workflow.` → `_call_workflow_tool`, else the 5-segment Composio parse. Keep the + SDK resolvers and the router parser in agreement. - **The `call` descriptor (direct path) and `runContext` binding.** A callback spec carries `call` XOR `call_ref`; the descriptor (`method`/`path`/`body`/`context`/`args_into`) must stay mirrored across `protocol.ts`, the SDK `CallbackToolSpec`, `wire_models.py`, and the golden fixtures. The `call.context` binding reads the per-turn `runContext` blob (also mirrored across `protocol.ts` / `wire_models.py` / `wire.py` / the goldens); its inner keys are the snake_case - `$ctx.` namespace, not camelCase. The runner now dispatches `call` and fills `call.context` - (`tools/direct.ts` `assembleBody`), but no resolver EMITS `call`/`context` yet (the platform-op - catalog is Phase 3b), so live behavior is unchanged. + `$ctx.` namespace, not camelCase. The runner dispatches `call` and fills `call.context` + (`tools/direct.ts` `assembleBody`); the platform-op catalog emits them for endpoint-mode ops. +- **Handler-mode spec fields.** A handler-mode op emits a reserved `call_ref` plus spec-level + `contextBindings` and `timeoutMs` (today only on the SDK side, `tools/models.py` + + `wire_models.py`). The runner does not carry or read them yet; keep the flag + (`AGENTA_AGENT_ENABLE_PLATFORM_HANDLERS`) off until `protocol.ts` mirrors them and the relay + injects them, and move the mirrors and the goldens together when it does. - **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 c3d33f2445..174f893126 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 @@ -32,7 +32,7 @@ policy) and `render` (optional), discriminated by `type`: // platform: an existing Agenta endpoint exposed to the agent. `op` names a platform-op catalog // entry; the catalog owns the description, endpoint, schema, context bindings, and per-op gate defaults. // `permission` is optional here (null = use the catalog default). -{ "type": "platform", "op": "find_capabilities", "permission": null } +{ "type": "platform", "op": "discover_tools", "permission": null } ``` A tool can also be a **workflow** referenced as a tool (`type: "reference"`, above) or a @@ -54,11 +54,17 @@ config (not markers); `resolve_tools` owns the tool-specific mapping. // callback (direct): a type:"platform" tool. Instead of call_ref it carries a `call` descriptor — // the runner calls the endpoint directly (no /tools/call hop). `context` carries the run-context bindings. // A callback spec carries exactly one of `call_ref` (gateway) or `call` (direct). -{ "kind": "callback", "name": "find_capabilities", "description": "...", "input_schema": {}, +{ "kind": "callback", "name": "discover_tools", "description": "...", "input_schema": {}, "call": { "method": "POST", "path": "/api/tools/discover" } } // e.g. self-update commit_revision: // "call": { "method": "POST", "path": "/api/workflows/revisions/commit", // "context": { "workflow_revision.workflow_variant_id": "$ctx.workflow.variant.id" } } +// callback (handler mode, flag-gated): a handler-backed platform op (e.g. test_run). Carries a +// reserved call_ref plus spec-level contextBindings/timeoutMs; routes through /tools/call to the +// server-side handler registry. Only emitted when AGENTA_AGENT_ENABLE_PLATFORM_HANDLERS is on. +// { "kind": "callback", "name": "test_run", "call_ref": "tools.agenta.test_run", +// "contextBindings": { "target.workflow_variant_id": "$ctx.workflow.variant.id" }, +// "timeoutMs": 120000 } // code: sandboxed code with its named secrets injected into env { "kind": "code", "name": "...", "runtime": "python", "code": "...", "env": { "API_KEY": "..." } } @@ -111,20 +117,20 @@ secret; their provider key stays server-side and the call routes back through `/ callback spec carrying a direct `call`. - `sdks/python/agenta/sdk/agents/platform/_schema.py`: `expand_type_refs` (resolve `x-ag-type-ref` against `CATALOG_TYPES`) used for platform-op input schemas. -- `api/oss/src/apis/fastapi/tools/router.py`: `/tools/call` routes a `workflow.*` call_ref to - `WorkflowsService.invoke_workflow`, and a `tools.agenta.*` call_ref (the reserved - `find_capabilities` discovery tool) to `ToolsService.discover_capabilities` (server-side execute - paths). The `tools.agenta.*` server route is retained during migration and removed once platform - tools (the direct `call`) fully supersede it. +- `api/oss/src/apis/fastapi/tools/router.py`: `/tools/call` routes a registered reserved + `tools.agenta.*` call_ref to the handler registry (`_call_reserved_agenta_tool`) and a + `workflow.*` call_ref to `WorkflowsService.invoke_workflow` (server-side execute paths). The + legacy `tools.agenta.find_capabilities` dispatch is deleted; an unregistered reserved ref + fails loud with a 404. +- `api/oss/src/core/tools/platform_handlers.py`: the reserved-ref handler registry and the + `test_run` handler (hydrate + optional in-memory delta + headless invoke + digest/verdict). - `services/oss/src/agent/tools/resolver.py`: the service entrypoint (re-exports the SDK). -`find_capabilities` is now the first **platform tool**: an agent config declares -`{type:"platform", op:"find_capabilities"}` and the SDK resolver emits a `CallbackToolSpec` with a -direct `call` to `POST /api/tools/discover`, so the model can call it end to end. The server-side -`/tools/call` `tools.agenta.*` route still exists (removed in a later phase). The canonical -reserved-tool spec (call_ref, input_schema, description) still lives in -`api/oss/src/core/tools/discovery.py`; the SDK-side description + schema for the platform op live -in `op_catalog.py` (the SDK must not import the API). +`discover_tools` (renamed from `find_capabilities`, hard migrate, no alias) is the canonical +endpoint-mode **platform tool**: an agent config declares +`{type:"platform", op:"discover_tools"}` and the SDK resolver emits a `CallbackToolSpec` with a +direct `call` to `POST /api/tools/discover`, so the model calls it end to end. The SDK-side +description + schema live in `op_catalog.py` (the SDK must not import the API). ## Watch for when changing @@ -149,6 +155,12 @@ in `op_catalog.py` (the SDK must not import the API). run-context token). Keep the catalog `path` pointing at an existing endpoint and the `context_bindings` token names in step with the `runContext` shape. - **Reserved platform call references.** `tools.agenta.{op}` is reserved for Agenta platform - tools (v1: `find_capabilities`, `query_workflows`, `commit_revision`). The model-visible tool - name is the bare `op`; the namespaced id is `PlatformOp.reserved_id`. Keep the reserved prefix - out of the Composio 5-segment namespace. + tools. The model-visible tool name is the bare `op`; the namespaced id is + `PlatformOp.reserved_id`. Handler-mode ops reuse the reserved id as their live `call_ref` + (`tools.agenta.test_run`), so the prefix now routes at `/tools/call`. Keep it out of the + Composio 5-segment namespace. +- **Handler mode (`handler` XOR `method`+`path`).** A `PlatformOp` targets exactly one of an + existing endpoint or an allowlisted server handler. Handler ops emit `call_ref` + + spec-level `contextBindings`/`timeoutMs` instead of `call`, and only resolve when + `AGENTA_AGENT_ENABLE_PLATFORM_HANDLERS` is on (the runner half is pending). See the + [build-kit-tools-cleanup api-design](../../projects/build-kit-tools-cleanup/api-design.md). 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 85f9702e88..ffad27746f 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 @@ -27,7 +27,7 @@ The fields and the full schema follow. | `sandbox` | `"local" \| "daytona"` | `"local"` | Where it runs. | | `permissions` | `{default: "allow" \| "ask" \| "deny" \| "allow_reads", rules?: [...]}` | `{default: "allow_reads"}` | The agent-wide policy. `allow_reads` runs read-hinted tools and asks for everything else; `allow` runs everything; `ask` asks for everything; `deny` runs nothing unless a tool explicitly allows it. `rules` are optional authored patterns (for example `Bash(rm:*)`) that override the default for matching harness builtins. | | `sandbox_permission` | `SandboxPermission \| null` | `null` (form pre-fills one) | The declared network and filesystem boundary. See [Sandbox permission](../in-service/sandbox-permission.md). | -| `skills` | `(SkillConfig \| EmbedRef)[]` | one embedded default skill | Inline SKILL.md packages, or `@ag.embed` references the backend inlines before the runner sees them. | +| `skills` | `(SkillConfig \| EmbedRef)[]` | `[]` (the playground overlay embeds the `build-an-agent` playbook) | Inline SKILL.md packages, or `@ag.embed` references the backend inlines before the runner sees them. | Note that `harness`, `sandbox`, and `permissions` are the run-selection fields. They live on `AgentConfig` itself, under `data.parameters.agent`, and the handler reads them in the @@ -84,7 +84,7 @@ every field in its default state: "skills": [ { "@ag.embed": { - "@ag.references": { "workflow": { "slug": "_agenta.agenta-getting-started" } }, + "@ag.references": { "workflow": { "slug": "__ag__build_an_agent" } }, "@ag.selector": { "path": "parameters.skill" } } } @@ -92,15 +92,21 @@ every field in its default state: } ``` -The default skill is referenced by the reserved `_agenta.` slug, served from code by the -platform catalog, never the database. The embed must reference the artifact (`workflow.slug`), -which resolves to the latest revision; a bare revision slug with no version returns 500. - -This default has one source: `build_agent_v0_default(...)` in +The skill embed above comes from the playground **build-kit overlay** +(`build_agent_template_overlay` in `api/oss/src/apis/fastapi/applications/overlay.py`), not +from the bare default: the overlay adds the default platform ops (`DEFAULT_BUILD_KIT_OPS`), +the `request_connection` client tool, and exactly one skill, the `build-an-agent` playbook, +referenced by its reserved `__ag__` slug and served from code by the platform catalog, never +the database. The embed must reference the artifact (`workflow.slug`), which resolves to the +latest revision; a bare revision slug with no version returns 500. The `getting-started` +skill is not embedded here; the `pi_agenta` harness force-unions it at run time +(`AGENTA_FORCED_SKILLS`), so it is delivered once, not twice. + +The bare default has one source: `build_agent_v0_default(...)` in `sdks/python/agenta/sdk/utils/types.py`. The SDK builtin interface -(`agenta:builtin:agent:v0`) calls it bare; the service calls it with the two service-only -choices as named args (`skill_slug` for the platform default skill, `include_sandbox_permission` -for the declared Layer-2 boundary). A new default field changes the builder, not three copies. +(`agenta:builtin:agent:v0`) and the service both call it without a skill; +`include_sandbox_permission` adds the declared Layer-2 boundary. A new default field changes +the builder, not three copies. ## Appendix: the full schema, all cases @@ -153,7 +159,7 @@ Either form is valid: // platform: an existing Agenta endpoint exposed to the agent. `op` names a platform-op catalog // entry (the catalog owns description/endpoint/schema/bind/gate defaults). `permission` is // optional (null means inherit the policy; commit_revision's catalog default resolves to `ask`). -{ "type": "platform", "op": "find_capabilities", "permission": null } +{ "type": "platform", "op": "discover_tools", "permission": 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 diff --git a/docs/design/agent-workflows/projects/build-kit-tools-cleanup/scripts/sweep_platform_op_renames.py b/docs/design/agent-workflows/projects/build-kit-tools-cleanup/scripts/sweep_platform_op_renames.py new file mode 100644 index 0000000000..4c1e3ea7c8 --- /dev/null +++ b/docs/design/agent-workflows/projects/build-kit-tools-cleanup/scripts/sweep_platform_op_renames.py @@ -0,0 +1,442 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "psycopg[binary]>=3.2", +# ] +# /// +"""Rewrite committed build-kit references in workflow revisions. + +Hard-migrates workflow_revisions.data at covered tool paths: + data.parameters.agent.tools[*].op + data.parameters.tools[*].op + data.parameters.prompt.llm_config.tools[*].op + data.parameters.prompt.tools[*].op + +Renames: + find_capabilities -> discover_tools + find_triggers -> discover_triggers + +Hard-migrates workflow_revisions.data at covered skill embed paths: + data.parameters.agent.skills[*]["@ag.embed"]["@ag.references"].workflow.slug + +Skill embeds: + __ag__build_your_first_app -> __ag__build_an_agent + __ag__discover_and_wire_tools -> __ag__build_an_agent + __ag__set_up_triggers -> __ag__build_an_agent + +If a skill list contains several old authoring embeds, the first becomes the playbook and the +rest are removed so the resulting list carries one playbook embed. + +Usage: + uv run --script docs/design/agent-workflows/projects/build-kit-tools-cleanup/scripts/sweep_platform_op_renames.py + +By default the script is a dry run and reads POSTGRES_URI_CORE, falling back to +DATABASE_URL. Use --database-url to point it at a specific dev DB, --project-id to +limit the sweep, and --apply to commit updates. +""" + +from __future__ import annotations + +import argparse +import json +import os +from copy import deepcopy +from typing import Any + +import psycopg +from psycopg.types.json import Json + +RENAMES = { + "find_capabilities": "discover_tools", + "find_triggers": "discover_triggers", +} + +OLD_SKILL_SLUGS = { + "__ag__build_your_first_app", + "__ag__discover_and_wire_tools", + "__ag__set_up_triggers", +} +PLAYBOOK_SKILL_SLUG = "__ag__build_an_agent" + +_TOOL_PATHS = ( + ("parameters.agent.tools", ("parameters", "agent", "tools")), + ("parameters.tools", ("parameters", "tools")), + ( + "parameters.prompt.llm_config.tools", + ("parameters", "prompt", "llm_config", "tools"), + ), + ("parameters.prompt.tools", ("parameters", "prompt", "tools")), +) + +_SKILL_PATHS = (("parameters.agent.skills", ("parameters", "agent", "skills")),) + + +def _sync_psycopg_url(url: str) -> str: + return ( + url.replace("postgresql+asyncpg://", "postgresql://", 1) + .replace("postgres+asyncpg://", "postgresql://", 1) + .replace("postgres://", "postgresql://", 1) + ) + + +def _json_data(value: Any) -> Any: + if isinstance(value, str): + return json.loads(value) + return value + + +def _get_path(data: Any, keys: tuple[str, ...]) -> Any: + node = data + for key in keys: + if not isinstance(node, dict): + return None + node = node.get(key) + return node + + +def _set_path(data: Any, keys: tuple[str, ...], value: Any) -> None: + parent = _get_path(data, keys[:-1]) + if isinstance(parent, dict): + parent[keys[-1]] = value + + +def _tool_collections(data: Any) -> list[tuple[str, tuple[str, ...], list[Any]]]: + collections: list[tuple[str, tuple[str, ...], list[Any]]] = [] + for path, keys in _TOOL_PATHS: + tools = _get_path(data, keys) + if isinstance(tools, list): + collections.append((path, keys, tools)) + return collections + + +def _skill_collections(data: Any) -> list[tuple[str, tuple[str, ...], list[Any]]]: + collections: list[tuple[str, tuple[str, ...], list[Any]]] = [] + for path, keys in _SKILL_PATHS: + skills = _get_path(data, keys) + if isinstance(skills, list): + collections.append((path, keys, skills)) + return collections + + +def _embed_reference(entry: Any) -> dict[str, Any] | None: + if not isinstance(entry, dict): + return None + embed = entry.get("@ag.embed") + if not isinstance(embed, dict): + return None + refs = embed.get("@ag.references") + if not isinstance(refs, dict): + return None + workflow = refs.get("workflow") + if isinstance(workflow, dict): + return workflow + return None + + +def _skill_embed_slug(entry: Any) -> str | None: + workflow = _embed_reference(entry) + slug = workflow.get("slug") if workflow else None + return slug if isinstance(slug, str) else None + + +def _set_skill_embed_slug(entry: Any, slug: str) -> None: + workflow = _embed_reference(entry) + if workflow is not None: + workflow["slug"] = slug + + +def old_op_paths(data: Any) -> list[tuple[str, int, str]]: + paths: list[tuple[str, int, str]] = [] + for path, _keys, tools in _tool_collections(data): + for index, tool in enumerate(tools): + if isinstance(tool, dict) and tool.get("op") in RENAMES: + paths.append((path, index, str(tool["op"]))) + return paths + + +def old_skill_embed_paths(data: Any) -> list[tuple[str, int, str]]: + paths: list[tuple[str, int, str]] = [] + for path, _keys, skills in _skill_collections(data): + for index, skill in enumerate(skills): + slug = _skill_embed_slug(skill) + if slug in OLD_SKILL_SLUGS: + paths.append((path, index, slug)) + return paths + + +def rewrite_ops(data: Any) -> tuple[Any, list[tuple[str, int, str, str]]]: + changes: list[tuple[str, int, str, str]] = [] + for path, _keys, tools in _tool_collections(data): + for index, tool in enumerate(tools): + if isinstance(tool, dict) and tool.get("op") in RENAMES: + changes.append((path, index, str(tool["op"]), RENAMES[str(tool["op"])])) + + if not changes: + return data, [] + + rewritten = deepcopy(data) + for path, keys in _TOOL_PATHS: + rewritten_tools = _get_path(rewritten, keys) + if not isinstance(rewritten_tools, list): + continue + for change_path, index, _old, new in changes: + if change_path == path and isinstance(rewritten_tools[index], dict): + rewritten_tools[index]["op"] = new + return rewritten, changes + + +def _planned_skill_embed_changes(data: Any) -> list[tuple[str, int, str, str | None]]: + changes: list[tuple[str, int, str, str | None]] = [] + for path, _keys, skills in _skill_collections(data): + seen_playbook = False + for index, skill in enumerate(skills): + slug = _skill_embed_slug(skill) + if slug == PLAYBOOK_SKILL_SLUG: + if seen_playbook: + changes.append((path, index, slug, None)) + else: + seen_playbook = True + continue + if slug not in OLD_SKILL_SLUGS: + continue + if seen_playbook: + changes.append((path, index, slug, None)) + else: + changes.append((path, index, slug, PLAYBOOK_SKILL_SLUG)) + seen_playbook = True + return changes + + +def rewrite_skill_embeds( + data: Any, +) -> tuple[Any, list[tuple[str, int, str, str | None]]]: + changes = _planned_skill_embed_changes(data) + if not changes: + return data, [] + + rewritten = deepcopy(data) + for _path, keys, skills in _skill_collections(rewritten): + rewritten_skills: list[Any] = [] + seen_playbook = False + for skill in skills: + slug = _skill_embed_slug(skill) + if slug == PLAYBOOK_SKILL_SLUG: + if seen_playbook: + continue + seen_playbook = True + rewritten_skills.append(skill) + continue + if slug in OLD_SKILL_SLUGS: + if seen_playbook: + continue + _set_skill_embed_slug(skill, PLAYBOOK_SKILL_SLUG) + seen_playbook = True + rewritten_skills.append(skill) + continue + rewritten_skills.append(skill) + _set_path(rewritten, keys, rewritten_skills) + return rewritten, changes + + +def rewrite_revision_data( + data: Any, +) -> tuple[ + Any, + list[tuple[str, int, str, str]], + list[tuple[str, int, str, str | None]], +]: + rewritten, op_changes = rewrite_ops(data) + rewritten, skill_changes = rewrite_skill_embeds(rewritten) + return rewritten, op_changes, skill_changes + + +def _select_candidates( + conn: psycopg.Connection[Any], project_id: str | None +) -> list[tuple[str, str, Any]]: + match_terms = { + "find_capabilities": "%find_capabilities%", + "find_triggers": "%find_triggers%", + "build_your_first_app": "%__ag__build_your_first_app%", + "discover_and_wire_tools": "%__ag__discover_and_wire_tools%", + "set_up_triggers": "%__ag__set_up_triggers%", + } + where = [ + "data IS NOT NULL", + "(" + " OR ".join(f"data::text LIKE %({key})s" for key in match_terms) + ")", + ] + params: dict[str, Any] = dict(match_terms) + if project_id is not None: + where.append("project_id = %(project_id)s::uuid") + params["project_id"] = project_id + + query = f""" + SELECT project_id::text, id::text, data + FROM workflow_revisions + WHERE {" AND ".join(where)} + ORDER BY project_id, id + """ + with conn.cursor() as cur: + cur.execute(query, params) + return [(row[0], row[1], _json_data(row[2])) for row in cur.fetchall()] + + +def sweep( + conn: psycopg.Connection[Any], *, project_id: str | None, dry_run: bool +) -> int: + updated_revisions = 0 + updated_tools = 0 + updated_skill_embeds = 0 + dropped_skill_embeds = 0 + + for row_project_id, revision_id, data in _select_candidates(conn, project_id): + rewritten, op_changes, skill_changes = rewrite_revision_data(data) + if not op_changes and not skill_changes: + print( + f"{row_project_id}/{revision_id}: " + "matched but not rewritten (uncovered path)" + ) + continue + + updated_revisions += 1 + updated_tools += len(op_changes) + updated_skill_embeds += sum( + 1 for *_rest, new in skill_changes if new is not None + ) + dropped_skill_embeds += sum(1 for *_rest, new in skill_changes if new is None) + + for path, index, old, new in op_changes: + print(f"{row_project_id}/{revision_id}: {path}[{index}].op {old} -> {new}") + for path, index, old, new in skill_changes: + if new is None: + print( + f"{row_project_id}/{revision_id}: " + f"dropped duplicate {path}[{index}] embed {old}" + ) + else: + print( + f"{row_project_id}/{revision_id}: " + f"{path}[{index}] embed {old} -> {new}" + ) + + if not dry_run: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE workflow_revisions + SET data = %(data)s + WHERE project_id = %(project_id)s::uuid + AND id = %(revision_id)s::uuid + """, + { + "data": Json(rewritten), + "project_id": row_project_id, + "revision_id": revision_id, + }, + ) + + if dry_run: + conn.rollback() + print( + f"Dry run: would update {updated_tools} tool op reference(s), " + f"rewrite {updated_skill_embeds} skill embed reference(s), and drop " + f"{dropped_skill_embeds} duplicate skill embed(s) across " + f"{updated_revisions} workflow revision(s)." + ) + return updated_tools + updated_skill_embeds + dropped_skill_embeds + + conn.commit() + print( + f"Updated {updated_tools} tool op reference(s), rewrote " + f"{updated_skill_embeds} skill embed reference(s), and dropped " + f"{dropped_skill_embeds} duplicate skill embed(s) across " + f"{updated_revisions} workflow revision(s)." + ) + return updated_tools + updated_skill_embeds + dropped_skill_embeds + + +def assert_clean(conn: psycopg.Connection[Any], *, project_id: str | None) -> None: + leftovers: list[str] = [] + uncovered: list[str] = [] + for row_project_id, revision_id, data in _select_candidates(conn, project_id): + op_paths = old_op_paths(data) + skill_paths = old_skill_embed_paths(data) + if op_paths or skill_paths: + for path, index, old in op_paths: + leftovers.append( + f"{row_project_id}/{revision_id}: {path}[{index}].op = {old}" + ) + for path, index, old in skill_paths: + leftovers.append( + f"{row_project_id}/{revision_id}: {path}[{index}] embed = {old}" + ) + else: + uncovered.append( + f"{row_project_id}/{revision_id}: " + "matched but not rewritten (uncovered path)" + ) + + if leftovers: + details = "\n".join(leftovers[:20]) + more = "" if len(leftovers) <= 20 else f"\n... and {len(leftovers) - 20} more" + raise SystemExit( + f"Sweep incomplete; old build-kit references remain:\n{details}{more}" + ) + + if uncovered: + details = "\n".join(uncovered[:20]) + more = "" if len(uncovered) <= 20 else f"\n... and {len(uncovered) - 20} more" + print(details + more) + raise SystemExit( + "Sweep incomplete; LIKE-matched rows remain outside covered paths." + ) + + covered_tool_paths = ", ".join(f"{path}[*].op" for path, _keys in _TOOL_PATHS) + covered_skill_paths = ", ".join(f"{path}[*]" for path, _keys in _SKILL_PATHS) + print( + "Verified zero old platform op keys and skill embeds at covered paths " + f"({covered_tool_paths}; {covered_skill_paths}) and zero uncovered LIKE matches." + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--database-url", + default=os.environ.get("POSTGRES_URI_CORE") or os.environ.get("DATABASE_URL"), + help="Core Postgres URL. Defaults to POSTGRES_URI_CORE, then DATABASE_URL.", + ) + parser.add_argument("--project-id", help="Optional project UUID to sweep.") + mode = parser.add_mutually_exclusive_group() + mode.add_argument( + "--dry-run", + dest="dry_run", + action="store_true", + default=True, + help="Print matching revisions without committing updates (default).", + ) + mode.add_argument( + "--apply", + dest="dry_run", + action="store_false", + help="Commit rewritten workflow revisions, then verify.", + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + if not args.database_url: + raise SystemExit( + "Missing database URL: set POSTGRES_URI_CORE or pass --database-url." + ) + + database_url = _sync_psycopg_url(args.database_url) + with psycopg.connect(database_url) as conn: + sweep(conn, project_id=args.project_id, dry_run=args.dry_run) + if not args.dry_run: + assert_clean(conn, project_id=args.project_id) + + +if __name__ == "__main__": + main() diff --git a/sdks/python/agenta/sdk/agents/adapters/agenta_builtins.py b/sdks/python/agenta/sdk/agents/adapters/agenta_builtins.py index db8472051a..42cdb841ec 100644 --- a/sdks/python/agenta/sdk/agents/adapters/agenta_builtins.py +++ b/sdks/python/agenta/sdk/agents/adapters/agenta_builtins.py @@ -52,22 +52,19 @@ You are an Agenta agent. Be precise, cite what your tools and skills return, and do not fabricate results.""" -# Built-in tools the Agenta harness records as forced, unioned with the agent's resolved -# tools. NOTE: this list is config-level metadata only today. The runner never reads -# ``request.tools`` / ``builtin_names`` to grant builtins, and Pi already gets ``read`` (and -# ``bash``) from its own DEFAULTS, so skills render regardless of this list. The intent is that -# ``read`` is needed for Pi to render the skills section and ``bash`` lets skills run helper -# scripts; wiring the forced set to actually deliver builtins over the wire is deferred (it -# works via Pi defaults today). +# Built-in tools the Agenta harness forces, unioned with the agent's resolved tools. These +# grants are load-bearing on the wire: once ANY custom tool ships in ``request.tools``, the +# runner flips Pi's builtin gating from "Pi defaults" to granted-only. So ``read`` and +# ``bash`` must be granted explicitly wherever build-kit tools ship (e.g. the playground +# overlay), or Pi loses them — skills are then announced but unloadable (``read`` loads +# SKILL.md; ``bash`` runs skill helper scripts). AGENTA_FORCED_TOOLS: List[str] = ["read", "bash"] # Reserved slug of the platform default skill. The default agent config template embeds the # skill by this slug; the server-side StaticWorkflowCatalog resolves the slug to the # SkillTemplate below. Kept here so the catalogue and the forced path share one slug constant. GETTING_STARTED_WITH_AGENTA_SLUG = "__ag__getting_started_with_agenta" -BUILD_YOUR_FIRST_APP_SLUG = "__ag__build_your_first_app" -DISCOVER_AND_WIRE_TOOLS_SLUG = "__ag__discover_and_wire_tools" -SET_UP_TRIGGERS_SLUG = "__ag__set_up_triggers" +BUILD_AN_AGENT_SLUG = "__ag__build_an_agent" # Canonical SKILL.md body for the platform "getting started" skill. Single source of the body # text: the server-side StaticWorkflowCatalog imports this constant rather than redeclaring it. @@ -102,217 +99,120 @@ body=_GETTING_STARTED_BODY, ) -_BUILD_YOUR_FIRST_APP_BODY = """\ -# Build your first app +_BUILD_AN_AGENT_BODY = """\ +# Build an Agenta agent -You are helping the user turn a plain-language goal into a working app. You are building -yourself: the app you configure is you. This skill is the map. It names the order and the -points where you stop for the user. Read the focused skill for a step before you act on it. +You turn a plain-language request into a working, verified Agenta agent. You are configuring +yourself: the committed template you edit is the agent that will keep running. Optimize for the +fewest calls and the least time. A simple no-tool ask is two actions: write better +`instructions.agents_md`, then call `commit_revision`. ## When to use -Use this when the user asks you to build, set up, or automate something, and the app does not -exist yet. - -## The flow - -1. Clarify the goal. Ask what the app should do, what should start it (a message, a schedule, - an outside event), and what tools or data it needs. Do not guess. -2. See what exists. Call `query_workflows` to check the project for work you can reuse. -3. Find the tools. Follow the `discover-and-wire-tools` skill. It calls `find_capabilities` - and reports which integrations need a connection. -4. Connect the integrations. Hand the user the connection link, wait for them to finish, then - re-check. You never connect on their behalf. -5. Configure yourself. Edit your own instructions and attach the tools, then commit with - `commit_revision`. This stops for the user's approval. -6. Set the trigger. Follow the `set-up-triggers` skill for a cron job or an event trigger. - Each one stops for the user's approval. -7. Test. Run once against a sample, then confirm the result with the user. -8. Report. Tell the user what you became, what is connected, and what is now scheduled. - -## Stop points - -You pause for the user at every connection, every commit, every schedule, and every -subscription. These are approval gates by design. Say what you are about to do, then wait. -""" +Use this when the user asks you to build, set up, configure, or automate an agent. -BUILD_YOUR_FIRST_APP_SKILL = SkillTemplate( - name="build-your-first-app", - description=( - "Guide the user through building their first Agenta app end to end. Use at the start " - "of a build conversation to plan the work, find and wire tools, set a trigger, and " - "commit. This skill is the map. Read the focused skill for each step." - ), - body=_BUILD_YOUR_FIRST_APP_BODY, -) +## The shape of your config -_DISCOVER_AND_WIRE_TOOLS_BODY = """\ -# Discover and wire tools with `find_capabilities` +You decide four things under `parameters.agent`: -You are configuring yourself as an app. Before you can act in the world, you need tools: -the right integration actions, working connections, and the schemas your model will call. -`find_capabilities` does the discovery in one step so you do not guess slugs or stitch the -catalog by hand. +- `instructions.agents_md`: who you are and what you do. +- `tools`: integration actions and platform ops you can call. +- `skills`: reusable know-how packaged as skill templates. +- A trigger: either a schedule or an event subscription, only when the user asked for one. -This skill is the discover -> resolve-connections -> configure -> test loop. It pairs with -the configure step in the `build-your-first-app` skill: once the tools are chosen, commit -the tools and instructions onto this agent. +Everything else is fixed unless the user explicitly asks to change it. Configure yourself with +`commit_revision` by setting `parameters.agent` fields; do not create a separate app. -> **Availability (2026-06-27):** the server side is live, but the SDK does not yet declare -> `find_capabilities` as a tool the model can call directly (that lands in Workstream A). Until -> then, reach the same discovery from setup code: `POST /tools/discover` with -> `{"use_cases": [...]}`, or `POST /tools/call` with call_ref `tools.agenta.find_capabilities`. -> The response below is identical either way. +## Decision table -## When to use it +| The ask... | Needs | What to add | +|---|---|---| +| transform text the user pastes, such as summarize, rewrite, classify | nothing extra | `instructions.agents_md` only | +| apply reusable know-how, such as a style guide or review rubric | a skill | one `skills` entry | +| read or write in an outside tool, such as GitHub or Slack | gateway tools | `discover_tools`, then `tools` entries | +| run on a clock | a schedule | `create_schedule` after committing | +| react to an outside event | a subscription | `discover_triggers`, then `create_subscription` | -Use it whenever you are wiring tools for this agent and the task is described in plain -language ("listen in Slack and file GitHub issues") rather than as exact tool slugs. One call -returns the best-match tool per use case, alternatives the one-line request omitted, the input -schemas, the connection state per integration, and operating guidance. +Do not discover tools or triggers for an ask that does not need them. ## The loop -### 1. Discover - -Call `find_capabilities` with one short fragment per capability the agent needs. Keep each -fragment to a single action ("create a github issue"), not a whole workflow. - -```jsonc -find_capabilities({ - "use_cases": [ - "search github issues for a matching report", - "create a github issue", - "post a reply in a slack thread with a link" - ] -}) -``` - -Project scope comes from your run's caller auth, so the connection state you get back is your -project's real state. You do not pass a project id or a Composio user id. - -### 2. Read the response (it is already in Agenta terms) - -You never see Composio. Each capability is Agenta-shaped: - -- `capability.tool` — a `gateway` tool config (`provider` / `integration` / `action`), ready to - drop into this agent's `tools`. It also carries the `input_schema` and `description` the - model needs, plus `provider_action` (opaque, debugging only — do not show it). -- `capability.tool.connection` — filled **only** when the integration is `ready`. If it is - missing, the connection is not set up yet (see step 3). -- `capability.alternatives` — companion or prerequisite actions the one-line request omitted - (for example `slack.FIND_CHANNELS` before `slack.SEND_MESSAGE`). Add the ones the task needs. -- `capability.connection.state` — `ready`, `needs_auth`, or `needs_input`. -- `connections[]` — one entry per integration, deduped, with what to do when it is not ready. -- `guidance` — `plan_steps` and `pitfalls` you compose into this agent's - `instructions.agents_md`. -- `ready` — `true` only when every primary connection is ready (you can configure and run now). -- `notes` — scope notes, e.g. a use case that looks like a trigger (see "Triggers" below). - -### 3. Resolve connections (a human approves; you never auto-connect) - -For each integration in `connections[]`: - -- **`ready`** — reuse it. The `slug` is already on `capability.tool.connection`. Nothing to do. -- **`needs_auth`** (OAuth) — run the returned `connect` affordance - (`POST /tools/connections/` with the given `body`). It returns a `redirect_url`. Surface that - link to the human and **pause** until they finish authorizing. Then re-run `find_capabilities` - (or check the connection) to confirm the integration flipped to `ready`. -- **`needs_input`** (API key) — ask the human for the secret the integration needs, then create - the connection with the `connect` affordance. - -Do not create connections silently. A human approves OAuth and supplies secrets. - -### 4. Configure this agent - -Once the tools are chosen and their connections are `ready`, build this agent's template: - -- Put each chosen `capability.tool` (and any needed `alternatives`, shaped as gateway tools - with a `connection`) into `tools` on the agent template. -- Compose `instructions.agents_md` from `guidance`: turn `plan_steps` into the operating - procedure and `pitfalls` into "things to avoid". The guidance already uses friendly - `integration.action` names, so it reads cleanly. - -Then return to the `build-your-first-app` configure step: edit this agent's own template and -commit it with `commit_revision`. If a tool call fails on a missing connection, return to -step 3. - -## Triggers (listening for events) are a separate step - -`find_capabilities` covers **action** tools (do a thing). It does not discover triggers -(listen for an event), because the engine has no semantic trigger search. If a use case reads -like a trigger ("listen for new messages...", "when a new issue is created..."), the response -flags it in `notes` and on that `capability.note`. Treat the listening half as a trigger -subscription with the `set-up-triggers` skill, and wire the action tools as usual. - -## Good habits - -- One capability per `use_case` fragment; let discovery return the alternatives. -- Always check `connection.state` before assuming a tool will run; `ready` means it will - resolve at invoke time. -- Never surface `provider_action` or any raw provider slug to the user — speak Agenta. -- Re-run discovery after a human finishes a connection to confirm `ready` before creating. -""" - -DISCOVER_AND_WIRE_TOOLS_SKILL = SkillTemplate( - name="discover-and-wire-tools", - description=( - "Use find_capabilities to discover the right Agenta tools for an agent you are " - "configuring, report what each integration needs to connect, and wire the tools into " - "this agent's template. Use when a setup/builder agent must turn a plain-language task " - "into attached, connected, ready-to-run tools." - ), - body=_DISCOVER_AND_WIRE_TOOLS_BODY, -) - -_SET_UP_TRIGGERS_BODY = """\ -# Set up triggers - -A trigger makes the app run on its own. There are two kinds. A schedule runs on a clock. A -subscription runs when an outside event arrives. Either way, the trigger targets you: it is -set on this agent automatically, and you never name a destination. - -## When to use - -Use this when the user says the app should run on a timer, on a cron, or whenever something -happens in a connected tool. - -## Schedules (cron) - -1. Get the cron expression right. Five fields, UTC, one-minute floor. Confirm the user's - timezone and convert to UTC. -2. Set the optional window if the job should only run between two dates. -3. Map the inputs the job passes to the app on each run. -4. Create it with `create_schedule`. This stops for the user's approval. - -## Subscriptions (events) - -1. Find the event. Call `find_triggers` with a short keyword for the event you want. -2. Make sure the connection exists. A subscription needs a connected integration. If it is - missing, run the connection round-trip first and wait. -3. Map the event into the run inputs. -4. Create it with `create_subscription`. This stops for the user's approval. - -## Confirm it works - -Test before you go live. If the catalog has a sample event, map the sample and run yourself -on it, with no connection. To prove the live wiring, call `test_subscription`, then read the -delivery with `list_deliveries`. Tell the user what fired and what it produced. +1. Clarify the ask. Get the missing timezone, channel, repo, account, output style, and success + criteria. Do not guess concrete destinations. +2. Decide from the table. Most agents need only instructions. If the ask needs outside actions, + call `discover_tools` with one short fragment per capability, such as "list github issues" or + "post a slack message". +3. Read discovery as a search result, not an oracle. Confirm the matched integration and the + action are both right. If a "new telegram message" search returns a Slack event, reword the + fragment or choose the right alternative. +4. If a needed connection is not ready, call `request_connection` for that integration and stop. + Give the user the connection request and wait for them. Re-run `discover_tools` after they + connect; do not silently create, fake, or skip connections. +5. Configure yourself. Put the chosen `capability.tool` entries and needed alternatives in + `tools`, write `instructions.agents_md`, and call `commit_revision`. This is an approval stop. + If the commit is denied or fails, earlier connections or triggers are not undone. +6. Verify with `query_spans`. Ask the user to send the agreed test message in this chat. Then + call `query_spans` with `windowing.oldest` and `windowing.newest` bracketing the test, plus a + sensible `windowing.limit`; add `filtering.conditions` on `trace_id` if you know it. Find the + run's spans, read the tool-call spans in order, and pass only when the terminal tool span is + present, ordered correctly, and has no error status; a 200 response or empty assistant output is + not proof. If the terminal action is missing, rewrite the instructions as a blunter numbered + procedure and commit again. +7. Add a trigger only if asked. For schedules, cron is UTC, five fields, with a one-minute floor; + convert the user's timezone yourself, then stop for approval before `create_schedule`: say what + you are about to create and wait for the gate. After approval, call `create_schedule`, then + confirm with `list_schedules`. For events, call `discover_triggers`, ensure the integration is + connected, then stop for approval before `create_subscription`: say what you are about to create + and wait for the gate. After approval, call `create_subscription`, and confirm with + `list_deliveries`. `test_subscription` waits for a real event, so warn the user before using it + in a chat turn. Use `remove_schedule` or `remove_subscription` only when cleaning up a wrong + trigger. +8. Report short: what you became, what is connected, what is scheduled, what you verified, and + what still needs the human. + +## Writing instructions for multi-tool and scheduled agents + +When you write `instructions.agents_md` for a multi-tool or scheduled agent, write an explicit +numbered procedure that names the exact tools in order, pins concrete ids, and ends on the +terminal action. + +Example: + +> Every run, do exactly these steps and nothing else: (1) call LIST_REPOSITORY_ISSUES for +> owner/repo X; (2) call LIST_COMMITS for X; (3) write a 3-bullet digest; (4) call SEND_MESSAGE to +> channel C0XXXX with that digest. Do not check triggers, do not stop before step 4. + +- Pin concrete ids, such as channel id and repo, instead of telling the agent to re-resolve them. +- Make the final numbered step the terminal side effect, such as the post or write. +- Say "finish by doing step N" so the run does not stop after the early read steps. + +## Prefer wired tools + +Prefer your wired tools (`discover_tools`, `request_connection`, `commit_revision`, +`query_spans`, `create_schedule`, `list_schedules`, `discover_triggers`, +`create_subscription`, `test_subscription`, `list_deliveries`, `remove_schedule`, +`remove_subscription`) over harness builtins. Touch Terminal, RemoteTrigger, File tools, or raw +HTTP only when your wired tools cannot do the job, and say so when you do. ## Footguns -- Cron is UTC. Always convert from the user's timezone. -- A subscription with no connection never fires. Connect first. -- The inputs must match what the app expects, or the run starts empty. +- Empty output with a healthy tool sequence is not failure; inspect the spans before judging. +- Never surface raw provider slugs such as `provider_action` to the user; speak in Agenta terms. +- Re-run discovery after the user connects an integration so the committed tool gets the concrete + connection id. +- A subscription without a ready connection never fires. +- Trigger inputs must match what the instructions expect, or the run starts empty. """ -SET_UP_TRIGGERS_SKILL = SkillTemplate( - name="set-up-triggers", +# Slice 5 seam: replace the query_spans verification paragraph above with the test_run paragraph +# when that platform op ships. +BUILD_AN_AGENT_SKILL = SkillTemplate( + name="build-an-agent", description=( - "Set up a cron job (a schedule) or an event trigger (a subscription) for the app. Use " - "when the user wants the app to run on a timer or react to an outside event." + "Build or configure an Agenta agent end to end. Use when the user asks to set up, " + "automate, connect tools for, schedule, or subscribe an agent." ), - body=_SET_UP_TRIGGERS_BODY, + body=_BUILD_AN_AGENT_BODY, ) # Platform skills every pi_agenta run carries, regardless of the author's config. These are the diff --git a/sdks/python/agenta/sdk/agents/platform/op_catalog.py b/sdks/python/agenta/sdk/agents/platform/op_catalog.py index f9fdc3621c..1ccf3b1239 100644 --- a/sdks/python/agenta/sdk/agents/platform/op_catalog.py +++ b/sdks/python/agenta/sdk/agents/platform/op_catalog.py @@ -7,7 +7,7 @@ It mirrors two patterns already in the codebase: -- the reserved ``tools.agenta.*`` namespace (PR #4884, ``find_capabilities``): a reserved op id +- the reserved ``tools.agenta.*`` namespace: a reserved op id under ``tools.agenta.`` with a code-defined description + input schema; - the evaluators catalog (``api/oss/src/resources/evaluators/evaluators.py``): a code-defined table of named ops with metadata, validated at import. @@ -44,7 +44,7 @@ "get_platform_op", ] -# Reserved namespace for a platform op's stable id (mirrors ``tools.agenta.find_capabilities``). +# Reserved namespace for a platform op's stable id. # The model-visible tool name is the bare ``op``; ``reserved_id`` is the stable namespaced id. PLATFORM_OP_NAMESPACE = "tools.agenta." @@ -52,6 +52,11 @@ # (e.g. ``$ctx.workflow.variant.id``); the runner resolves it at dispatch (see ``RunContext``). _CTX_TOKEN_PREFIX = "$ctx." +# Exact allowlist of handler call-refs a handler-mode op may target. Must match the +# server's registered handlers (``PLATFORM_TOOL_HANDLERS`` in the API's +# ``core/tools/platform_handlers.py``); an op naming anything else fails at import. +_HANDLER_CALL_REFS = frozenset({f"{PLATFORM_OP_NAMESPACE}test_run"}) + class PlatformOp(BaseModel): """One catalog entry: an existing Agenta endpoint exposed to the agent as a tool. @@ -68,10 +73,13 @@ class PlatformOp(BaseModel): description: str = Field( min_length=1, description="Model-facing description (SDK-owned)." ) - method: Literal["GET", "POST", "DELETE"] + method: Optional[Literal["GET", "POST", "DELETE"]] = None # An EXISTING Agenta endpoint, as a path relative to the API origin (e.g. ``/api/tools/discover``). # The runner binds the origin to the run's own Agenta and confines the path to the API mount. - path: str = Field(min_length=1) + path: Optional[str] = Field(default=None, min_length=1) + # A server-side handler call-ref in the reserved Agenta namespace. Handler-mode ops still go + # through `/tools/call`, but the business logic lives behind the registered Python handler. + handler: Optional[str] = Field(default=None, min_length=1) # Exactly one of the two below. ``input_schema`` is an inline JSON Schema (SDK-owned); it MAY # carry ``x-ag-type-ref`` markers, expanded against ``CATALOG_TYPES`` at resolve time. # ``input_schema_ref`` names a whole catalog type by key (the endpoint's request schema). @@ -85,6 +93,8 @@ class PlatformOp(BaseModel): args_into: Optional[str] = None # Catalog hint for the runner's ``allow_reads`` policy; no hint counts as a write. read_only: bool = False + # Per-op execution budget for long-running server-side handlers. Emitted as `timeoutMs`. + timeout_ms: Optional[int] = Field(default=None, gt=0) @model_validator(mode="after") def _check(self) -> "PlatformOp": @@ -101,11 +111,31 @@ def _check(self) -> "PlatformOp": f"platform op '{self.op}' input_schema_ref '{self.input_schema_ref}' " "is not a known CATALOG_TYPES key" ) - if not self.path.startswith("/") or self.path.startswith("//"): + + has_direct_target = self.method is not None or self.path is not None + has_handler_target = self.handler is not None + if has_direct_target == has_handler_target: + raise ValueError( + f"platform op '{self.op}' must set exactly one of " + "`method` + `path` or `handler`" + ) + if has_direct_target and (self.method is None or self.path is None): + raise ValueError( + f"platform op '{self.op}' must set both `method` and `path` " + "for endpoint mode" + ) + if self.path is not None and ( + not self.path.startswith("/") or self.path.startswith("//") + ): raise ValueError( f"platform op '{self.op}' path '{self.path}' must be a relative path " "starting with a single '/'" ) + if self.handler is not None and self.handler not in _HANDLER_CALL_REFS: + raise ValueError( + f"platform op '{self.op}' handler '{self.handler}' is not allowlisted" + ) + for field, token in self.context_bindings.items(): if not field: raise ValueError( @@ -120,7 +150,7 @@ def _check(self) -> "PlatformOp": @property def reserved_id(self) -> str: - """The stable reserved id, ``tools.agenta.`` (the ``find_capabilities`` precedent).""" + """The stable reserved id, ``tools.agenta.``.""" return f"{PLATFORM_OP_NAMESPACE}{self.op}" def resolved_input_schema(self) -> Dict[str, Any]: @@ -143,9 +173,9 @@ def resolved_input_schema(self) -> Dict[str, Any]: return schema def to_call(self) -> ToolCall: - """The direct ``call`` descriptor: the endpoint to hit, where args land, and the - context bindings (emitted as ``context`` so the runner fills bound fields from run - context at dispatch).""" + """The direct ``call`` descriptor for endpoint-mode ops.""" + if self.method is None or self.path is None or self.handler is not None: + raise ValueError(f"platform op '{self.op}' is not an endpoint-mode op") return ToolCall( method=self.method, path=self.path, @@ -153,16 +183,26 @@ def to_call(self) -> ToolCall: args_into=self.args_into, ) + def to_call_ref(self) -> str: + """The reserved ``callRef`` for handler-mode ops.""" + if self.handler is None: + raise ValueError(f"platform op '{self.op}' is not a handler-mode op") + return self.handler + def _strip_field(schema: Dict[str, Any], dotted_path: str) -> None: """Delete the property at ``dotted_path`` from a JSON Schema's ``properties`` tree, in place, - and drop it from the enclosing ``required`` list. A path that does not resolve is a no-op.""" + and drop it from the enclosing ``required`` list. Ancestor objects left empty by the removal + are pruned too, so the model never sees a hollow required container (e.g. ``target`` once its + only field is context-bound). A path that does not resolve is a no-op.""" parts = dotted_path.split(".") node: Any = schema + stack: list[tuple[Dict[str, Any], str]] = [] for part in parts[:-1]: properties = node.get("properties") if isinstance(node, dict) else None if not isinstance(properties, dict) or part not in properties: return + stack.append((node, part)) node = properties[part] if not isinstance(node, dict): return @@ -176,22 +216,40 @@ def _strip_field(schema: Dict[str, Any], dotted_path: str) -> None: if not required: node.pop("required", None) + while stack and _is_empty_object_schema(node): + parent, part = stack.pop() + parent_props = parent.get("properties") + if isinstance(parent_props, dict): + parent_props.pop(part, None) + parent_required = parent.get("required") + if isinstance(parent_required, list) and part in parent_required: + parent_required.remove(part) + if not parent_required: + parent.pop("required", None) + node = parent + + +def _is_empty_object_schema(node: Any) -> bool: + if not isinstance(node, dict): + return False + properties = node.get("properties") + return isinstance(properties, dict) and not properties and not node.get("required") + # --------------------------------------------------------------------------- # The catalog — the first, minimal-useful set of ops (more are a data add). # --------------------------------------------------------------------------- -# Discovery (read). Migrates ``find_capabilities`` off the server-side ``/tools/call`` -# ``tools.agenta.*`` dispatch onto a direct call to ``POST /api/tools/discover`` (PR #4884 built -# the server side). Description + input schema mirror ``api/oss/src/core/tools/discovery.py`` -# (copied here because the SDK must not import from the API). -_FIND_CAPABILITIES_DESCRIPTION = ( +# Discovery (read). Direct call to ``POST /api/tools/discover``. Description + input schema +# mirror ``api/oss/src/core/tools/discovery.py`` (copied here because the SDK must not import +# from the API). +_DISCOVER_TOOLS_DESCRIPTION = ( "Discover the Agenta tools that fit a set of plain-language use cases. Returns the " "best-match tool per use case (with its input schema), companion/alternative tools, " "each integration's connection state and how to connect it, and operating guidance. " "Use it while wiring tools for an agent you are building." ) -_FIND_CAPABILITIES_INPUT_SCHEMA: Dict[str, Any] = { +_DISCOVER_TOOLS_INPUT_SCHEMA: Dict[str, Any] = { "type": "object", "properties": { "use_cases": { @@ -245,6 +303,266 @@ def _strip_field(schema: Dict[str, Any], dotted_path: str) -> None: }, } +# Spans query (read): inspect trace/span records for this project. Project scope comes from the +# caller credential on the existing endpoint; there is no model-supplied project id and no $ctx +# target field to bind. +_QUERY_SPANS_DESCRIPTION = ( + "Query span records in this project. Use it to verify a past run or scheduled trigger fire " + "actually executed its tools. Returns `{count, spans}` with flat spans, including span names, " + "status, attributes, events, and Agenta metrics. Filter by `trace_id` when you know it, or " + "bracket the test with `windowing.oldest`/`windowing.newest`; then read the tool-call spans " + "in order and confirm the terminal span completed without an error status." +) +_QUERY_SPANS_INPUT_SCHEMA: Dict[str, Any] = { + "type": "object", + "additionalProperties": False, + "$defs": { + "ComparisonOperator": { + "type": "string", + "enum": ["is", "is_not"], + }, + "NumericOperator": { + "type": "string", + "enum": ["eq", "neq", "gt", "lt", "gte", "lte", "btwn"], + }, + "StringOperator": { + "type": "string", + "enum": ["startswith", "endswith", "contains", "matches", "like"], + }, + "DictOperator": { + "type": "string", + "enum": ["has", "has_not"], + }, + "ListOperator": { + "type": "string", + "enum": ["in", "not_in"], + }, + "ExistenceOperator": { + "type": "string", + "enum": ["exists", "not_exists"], + }, + "LogicalOperator": { + "type": "string", + "enum": ["and", "or", "not", "nand", "nor"], + }, + "TextOptions": { + "type": "object", + "additionalProperties": False, + "properties": { + "case_sensitive": { + "anyOf": [{"type": "boolean"}, {"type": "null"}], + "default": False, + }, + "exact_match": { + "anyOf": [{"type": "boolean"}, {"type": "null"}], + "default": False, + }, + }, + }, + "ListOptions": { + "type": "object", + "additionalProperties": False, + "properties": { + "all": { + "anyOf": [{"type": "boolean"}, {"type": "null"}], + "default": False, + } + }, + }, + "Condition": { + "type": "object", + "additionalProperties": False, + "properties": { + "field": { + "type": "string", + "description": ( + "Span field to filter, such as `trace_id`, `span_name`, " + "`span_type`, `status_code`, `attributes`, or `content`." + ), + }, + "key": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": None, + "description": ( + "Optional nested key when filtering dictionary fields like " + "`attributes`." + ), + }, + "value": { + "anyOf": [ + {"type": "string"}, + {"type": "integer"}, + {"type": "number"}, + {"type": "boolean"}, + {"type": "array", "items": {}}, + {"type": "object", "additionalProperties": True}, + {"type": "null"}, + ], + "default": None, + "description": "Comparison value for the condition.", + }, + "operator": { + "anyOf": [ + {"$ref": "#/$defs/ComparisonOperator"}, + {"$ref": "#/$defs/NumericOperator"}, + {"$ref": "#/$defs/StringOperator"}, + {"$ref": "#/$defs/ListOperator"}, + {"$ref": "#/$defs/DictOperator"}, + {"$ref": "#/$defs/ExistenceOperator"}, + {"type": "null"}, + ], + "default": "is", + }, + "options": { + "anyOf": [ + {"$ref": "#/$defs/TextOptions"}, + {"$ref": "#/$defs/ListOptions"}, + {"type": "null"}, + ], + "default": None, + }, + }, + "required": ["field"], + }, + "Filtering": { + "type": "object", + "additionalProperties": False, + "properties": { + "operator": { + "$ref": "#/$defs/LogicalOperator", + "default": "and", + "description": "How to combine conditions.", + }, + "conditions": { + "type": "array", + "items": { + "anyOf": [ + {"$ref": "#/$defs/Condition"}, + {"$ref": "#/$defs/Filtering"}, + ] + }, + "default": [], + "description": ( + "Filter objects, for example " + '`[{"field": "trace_id", "operator": "is", "value": "..."}]`.' + ), + }, + }, + }, + "Windowing": { + "type": "object", + "additionalProperties": False, + "properties": { + "newest": { + "anyOf": [ + {"type": "string", "format": "date-time"}, + {"type": "null"}, + ], + "default": None, + "description": "Window end time as an ISO timestamp.", + }, + "oldest": { + "anyOf": [ + {"type": "string", "format": "date-time"}, + {"type": "null"}, + ], + "default": None, + "description": "Window start time as an ISO timestamp.", + }, + "next": { + "anyOf": [ + {"type": "string", "format": "uuid"}, + {"type": "null"}, + ], + "default": None, + "description": "Cursor token returned by a prior query page.", + }, + "limit": { + "anyOf": [{"type": "integer"}, {"type": "null"}], + "default": None, + "description": "Maximum spans to return.", + }, + "order": { + "anyOf": [ + {"type": "string", "enum": ["ascending", "descending"]}, + {"type": "null"}, + ], + "default": None, + "description": "Sort order for the window.", + }, + "interval": { + "anyOf": [{"type": "integer"}, {"type": "null"}], + "default": None, + "description": "Positive bucket interval for aggregate query windows.", + }, + "rate": { + "anyOf": [{"type": "number"}, {"type": "null"}], + "default": None, + "description": "Optional sampling rate between 0.0 and 1.0.", + }, + }, + }, + "Reference": { + "type": "object", + "additionalProperties": False, + "properties": { + "version": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": None, + }, + "slug": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": None, + }, + "id": { + "anyOf": [ + {"type": "string", "format": "uuid"}, + {"type": "null"}, + ], + "default": None, + }, + }, + }, + }, + "properties": { + "filtering": { + "anyOf": [{"$ref": "#/$defs/Filtering"}, {"type": "null"}], + "default": None, + "description": ( + "Span-level conditions. For verification, filter on " + '`{"field": "trace_id", "operator": "is", "value": ""}` ' + "when the trace id is known." + ), + }, + "windowing": { + "anyOf": [{"$ref": "#/$defs/Windowing"}, {"type": "null"}], + "default": None, + "description": ( + "Cursor pagination and time range. Bracket manual verification with " + "`oldest`/`newest` and set a sensible `limit`." + ), + }, + "query_ref": { + "anyOf": [{"$ref": "#/$defs/Reference"}, {"type": "null"}], + "default": None, + "description": "Resolve filtering/windowing from a saved query.", + }, + "query_variant_ref": { + "anyOf": [{"$ref": "#/$defs/Reference"}, {"type": "null"}], + "default": None, + "description": "Resolve from the latest revision of a specific query variant.", + }, + "query_revision_ref": { + "anyOf": [{"$ref": "#/$defs/Reference"}, {"type": "null"}], + "default": None, + "description": ( + "Resolve from a specific query revision. Returns `409` when the stored query " + "has `formatting.focus=trace`." + ), + }, + }, +} + # Commit revision (mutating, self-targeting): commit a new revision to the agent's OWN workflow # variant — "update myself". ``workflow_revision.workflow_variant_id`` is bound from run context # and stripped from the model-visible schema, so the agent can only ever target itself, never a @@ -372,12 +690,12 @@ def _strip_field(schema: Dict[str, Any], dotted_path: str) -> None: "required": ["references", "data"], } -_FIND_TRIGGERS_DESCRIPTION = ( +_DISCOVER_TRIGGERS_DESCRIPTION = ( "Discover trigger events that fit plain-language use cases. Returns the best-match " "event per use case with event_key, trigger_config schema, sample payload, connection " "state and connection instructions, alternatives, and setup guidance." ) -_FIND_TRIGGERS_INPUT_SCHEMA: Dict[str, Any] = { +_DISCOVER_TRIGGERS_INPUT_SCHEMA: Dict[str, Any] = { "type": "object", "properties": { "use_cases": { @@ -473,7 +791,7 @@ def _strip_field(schema: Dict[str, Any], dotted_path: str) -> None: "properties": { "event_key": { "type": "string", - "description": "Provider event key returned by find_triggers.", + "description": "Provider event key returned by discover_triggers.", }, "trigger_config": { "type": "object", @@ -504,7 +822,7 @@ def _strip_field(schema: Dict[str, Any], dotted_path: str) -> None: "properties": { "event_key": { "type": "string", - "description": "Provider event key returned by find_triggers.", + "description": "Provider event key returned by discover_triggers.", }, "trigger_config": { "type": "object", @@ -517,6 +835,70 @@ def _strip_field(schema: Dict[str, Any], dotted_path: str) -> None: "required": ["connection_id", "data"], } +_TEST_RUN_DESCRIPTION = ( + "Run this agent headlessly once against test messages and return its output, tools, " + "approval gates, resolved execution metadata, trace id, and verdict. The target workflow " + "variant is filled automatically from the current run context and cannot be retargeted. " + "This is a real run: external write tools may perform their action if approved." +) +_TEST_RUN_INPUT_SCHEMA: Dict[str, Any] = { + "type": "object", + "additionalProperties": False, + "properties": { + "target": { + "type": "object", + "additionalProperties": False, + "properties": { + "workflow_variant_id": { + "type": "string", + "description": "Server-bound current workflow variant id.", + } + }, + "required": ["workflow_variant_id"], + }, + "inputs": { + "type": "object", + "additionalProperties": False, + "properties": { + "messages": { + "x-ag-type-ref": "messages", + "description": "Test conversation messages for the headless run.", + } + }, + "required": ["messages"], + }, + "delta": { + "type": "object", + "additionalProperties": False, + "description": "Optional uncommitted revision delta to apply in memory before the test.", + "properties": { + "set": { + "type": "object", + "additionalProperties": True, + "description": "Partial revision data tree deep-merged onto the committed revision.", + }, + "remove": { + "type": "array", + "items": {"type": "string"}, + "description": "Dotted paths to delete from the revision data tree.", + }, + }, + }, + "expectations": { + "type": "object", + "additionalProperties": False, + "description": "Optional checks that define a passing test run.", + "properties": { + "terminal_tool": { + "type": "string", + "description": "Expected final/terminal tool name that must run and return.", + } + }, + }, + }, + "required": ["target", "inputs"], +} + _EMPTY_INPUT_SCHEMA: Dict[str, Any] = {"type": "object", "properties": {}} _TRIGGER_ID_INPUT_SCHEMA: Dict[str, Any] = { "type": "object", @@ -534,11 +916,11 @@ def _strip_field(schema: Dict[str, Any], dotted_path: str) -> None: op.op: op for op in ( PlatformOp( - op="find_capabilities", - description=_FIND_CAPABILITIES_DESCRIPTION, + op="discover_tools", + description=_DISCOVER_TOOLS_DESCRIPTION, method="POST", path="/api/tools/discover", - input_schema=_FIND_CAPABILITIES_INPUT_SCHEMA, + input_schema=_DISCOVER_TOOLS_INPUT_SCHEMA, read_only=True, ), PlatformOp( @@ -549,6 +931,23 @@ def _strip_field(schema: Dict[str, Any], dotted_path: str) -> None: input_schema=_QUERY_WORKFLOWS_INPUT_SCHEMA, read_only=True, ), + PlatformOp( + op="query_spans", + description=_QUERY_SPANS_DESCRIPTION, + method="POST", + path="/api/spans/query", + input_schema=_QUERY_SPANS_INPUT_SCHEMA, + read_only=True, + ), + PlatformOp( + op="test_run", + description=_TEST_RUN_DESCRIPTION, + handler="tools.agenta.test_run", + input_schema=_TEST_RUN_INPUT_SCHEMA, + context_bindings={"target.workflow_variant_id": "$ctx.workflow.variant.id"}, + read_only=False, + timeout_ms=120000, + ), PlatformOp( op="commit_revision", description=_COMMIT_REVISION_DESCRIPTION, @@ -574,11 +973,11 @@ def _strip_field(schema: Dict[str, Any], dotted_path: str) -> None: read_only=False, ), PlatformOp( - op="find_triggers", - description=_FIND_TRIGGERS_DESCRIPTION, + op="discover_triggers", + description=_DISCOVER_TRIGGERS_DESCRIPTION, method="POST", path="/api/triggers/discover", - input_schema=_FIND_TRIGGERS_INPUT_SCHEMA, + input_schema=_DISCOVER_TRIGGERS_INPUT_SCHEMA, read_only=True, ), PlatformOp( diff --git a/sdks/python/agenta/sdk/agents/platform/platform_tools.py b/sdks/python/agenta/sdk/agents/platform/platform_tools.py index 0b27f7e504..89cd1f835b 100644 --- a/sdks/python/agenta/sdk/agents/platform/platform_tools.py +++ b/sdks/python/agenta/sdk/agents/platform/platform_tools.py @@ -16,6 +16,7 @@ from __future__ import annotations +import os from typing import Optional, Sequence from agenta.sdk.agents.tools import ( @@ -32,6 +33,16 @@ log = get_module_logger(__name__) +_ENABLE_PLATFORM_HANDLERS_ENV = "AGENTA_AGENT_ENABLE_PLATFORM_HANDLERS" +_TRUTHY_ENV_VALUES = {"1", "true", "t", "y", "yes", "on", "enable", "enabled"} + + +def _platform_handlers_enabled() -> bool: + return ( + os.getenv(_ENABLE_PLATFORM_HANDLERS_ENV, "").strip().lower() + in _TRUTHY_ENV_VALUES + ) + class AgentaPlatformToolResolver: """:class:`PlatformToolResolver` backed by the platform-op catalog + ``PlatformConnection``.""" @@ -69,15 +80,35 @@ async def resolve( raise error seen.add(op.op) + # Both modes share the whole spec except the target: handler-mode ops carry a + # gateway ``call_ref`` (with spec-level bindings the relay injects); endpoint-mode + # ops carry a direct ``call`` descriptor (bindings ride inside ``call.context``). + if op.handler is not None: + if not _platform_handlers_enabled(): + error = GatewayToolResolutionError( + f"Platform handler-mode op '{op.op}' requires " + f"{_ENABLE_PLATFORM_HANDLERS_ENV}=true before it can resolve.", + reference=op.reserved_id, + ) + log.warning("agent: %s", error) + raise error + target: dict = { + "call_ref": op.to_call_ref(), + "context_bindings": dict(op.context_bindings) or None, + } + else: + target = {"call": op.to_call()} + tool_specs.append( CallbackToolSpec( name=op.op, description=op.description, input_schema=op.resolved_input_schema(), - call=op.to_call(), + timeout_ms=op.timeout_ms, render=tool_config.render, permission=tool_config.permission, read_only=op.read_only, + **target, ) ) diff --git a/sdks/python/agenta/sdk/agents/tools/models.py b/sdks/python/agenta/sdk/agents/tools/models.py index 2283c04492..24f119d8f1 100644 --- a/sdks/python/agenta/sdk/agents/tools/models.py +++ b/sdks/python/agenta/sdk/agents/tools/models.py @@ -239,7 +239,7 @@ class PlatformToolConfig(ToolConfigBase): type: Literal["platform"] = "platform" op: str = Field( min_length=1, - description="Which catalog op (existing endpoint) to expose, e.g. 'find_capabilities'.", + description="Which catalog op (existing endpoint) to expose, e.g. 'discover_tools'.", ) @@ -363,6 +363,18 @@ class CallbackToolSpec(ToolSpecBase): # Direct-call descriptor (direct-call tools, Phase 1). When set the runner calls the endpoint # directly instead of the gateway. Plumbing only: nothing emits or dispatches it yet. call: Optional[ToolCall] = None + # Handler-mode callback specs use the same gateway executor as `call_ref`, but carry run-context + # bindings at the spec level so the relay can inject them before posting to `/tools/call`. + context_bindings: Optional[Dict[str, str]] = Field( + default=None, + validation_alias=AliasChoices("context_bindings", "contextBindings"), + serialization_alias="contextBindings", + ) + timeout_ms: Optional[int] = Field( + default=None, + validation_alias=AliasChoices("timeout_ms", "timeoutMs"), + serialization_alias="timeoutMs", + ) @model_validator(mode="after") def _check_call_target(self) -> "CallbackToolSpec": @@ -375,6 +387,13 @@ def _check_call_target(self) -> "CallbackToolSpec": "a callback tool spec must carry exactly one of `call_ref` (gateway) " "or `call` (direct)" ) + # Spec-level bindings are applied by the gateway relay before it posts to + # ``/tools/call``; a direct call has no relay, so the combination is invalid + # (direct-call bindings belong inside ``call.context``). + if self.context_bindings is not None and self.call_ref is None: + raise ValueError( + "`context_bindings` is only valid with `call_ref` (gateway dispatch)" + ) return self diff --git a/sdks/python/agenta/sdk/agents/wire_models.py b/sdks/python/agenta/sdk/agents/wire_models.py index 9069e1288b..b00abcfab2 100644 --- a/sdks/python/agenta/sdk/agents/wire_models.py +++ b/sdks/python/agenta/sdk/agents/wire_models.py @@ -254,6 +254,10 @@ class WireResolvedToolSpec(_WireModel): kind: Optional[str] = None call_ref: Optional[str] = Field(default=None, alias="callRef") call: Optional[WireToolCall] = None + context_bindings: Optional[Dict[str, str]] = Field( + default=None, alias="contextBindings" + ) + timeout_ms: Optional[int] = Field(default=None, alias="timeoutMs") runtime: Optional[str] = None code: Optional[str] = None env: Optional[Dict[str, str]] = None diff --git a/sdks/python/agenta/sdk/engines/running/utils.py b/sdks/python/agenta/sdk/engines/running/utils.py index da6cd29aef..dc9797d278 100644 --- a/sdks/python/agenta/sdk/engines/running/utils.py +++ b/sdks/python/agenta/sdk/engines/running/utils.py @@ -527,6 +527,19 @@ def retrieve_configuration(uri: Optional[str] = None) -> Optional[dict]: return _get_with_latest(CONFIGURATION_REGISTRY, provider, kind, key, version) +def seed_empty_parameters_from_configuration( + revision: Optional[WorkflowRevisionData], +) -> Optional[WorkflowRevisionData]: + """Seed missing/empty parameters from the URI's registered default configuration.""" + if revision is None or revision.parameters: + return revision + + configuration = retrieve_configuration(revision.uri) + if configuration and configuration.parameters: + revision.parameters = configuration.parameters + return revision + + def register_meta(meta: dict, uri: str) -> str: """Register (or OVERRIDE) the interface ``meta`` for a URI in the meta registry. diff --git a/sdks/python/agenta/sdk/middlewares/running/resolver.py b/sdks/python/agenta/sdk/middlewares/running/resolver.py index 49a19269da..f13f94b33d 100644 --- a/sdks/python/agenta/sdk/middlewares/running/resolver.py +++ b/sdks/python/agenta/sdk/middlewares/running/resolver.py @@ -14,6 +14,7 @@ from agenta.sdk.engines.running.utils import ( retrieve_handler, parse_uri, + seed_empty_parameters_from_configuration, ) from agenta.sdk.engines.running.handlers import remote_forward_v0 from agenta.sdk.engines.running.errors import ( @@ -567,7 +568,9 @@ async def __call__( call_next: Callable[[WorkflowInvokeRequest], Any], ): ctx = RunningContext.get() - revision = await resolve_revision(request=request) + revision = seed_empty_parameters_from_configuration( + await resolve_revision(request=request) + ) request_has_parameters = bool(request.data and request.data.parameters) needs_reference_hydration = bool( @@ -590,6 +593,7 @@ async def __call__( _merge_tracing_references(retrieval_references) _merge_tracing_selector(retrieval_selector) revision = hydrated_revision or existing_revision + revision = seed_empty_parameters_from_configuration(revision) if not request.data: request.data = WorkflowRequestData() diff --git a/sdks/python/oss/tests/pytest/unit/agents/platform/conftest.py b/sdks/python/oss/tests/pytest/unit/agents/platform/conftest.py index c51711a6cf..9a73c29291 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/platform/conftest.py +++ b/sdks/python/oss/tests/pytest/unit/agents/platform/conftest.py @@ -19,6 +19,7 @@ _ENV_VARS = ( "AGENTA_API_URL", "AGENTA_API_KEY", + "AGENTA_AGENT_ENABLE_PLATFORM_HANDLERS", ) diff --git a/sdks/python/oss/tests/pytest/unit/agents/platform/test_op_catalog.py b/sdks/python/oss/tests/pytest/unit/agents/platform/test_op_catalog.py index d4c3683957..677990bcd3 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/platform/test_op_catalog.py +++ b/sdks/python/oss/tests/pytest/unit/agents/platform/test_op_catalog.py @@ -6,7 +6,7 @@ config into a ``CallbackToolSpec`` carrying a direct ``call`` descriptor (no ``/tools/call`` hop). These tests cover: the catalog model's import-time validation, the resolver emitting a direct -``call`` (find_capabilities), the self-update ``context_bindings`` stripping its bound field from the +``call`` (discover_tools), the self-update ``context_bindings`` stripping its bound field from the model-visible schema, the catalog's permission/approval defaults and the config override, and the error paths (unknown op, missing API base). """ @@ -36,11 +36,13 @@ def _resolver(connection): def test_catalog_ships_platform_builder_ops(): assert set(PLATFORM_OPS) == { - "find_capabilities", + "discover_tools", "query_workflows", + "query_spans", + "test_run", "commit_revision", "annotate_trace", - "find_triggers", + "discover_triggers", "create_schedule", "create_subscription", "list_schedules", @@ -58,9 +60,9 @@ def test_catalog_ships_platform_builder_ops(): def test_reserved_id_uses_the_tools_agenta_namespace(): - # Mirrors the reserved `tools.agenta.find_capabilities` precedent (PR #4884). - assert get_platform_op("find_capabilities").reserved_id == ( - "tools.agenta.find_capabilities" + # Mirrors the reserved `tools.agenta.discover_tools` precedent (PR #4884). + assert get_platform_op("discover_tools").reserved_id == ( + "tools.agenta.discover_tools" ) @@ -89,6 +91,46 @@ def test_op_input_schema_ref_must_be_a_known_catalog_key(): ) +def test_op_requires_exactly_one_target_mode(): + with pytest.raises(ValidationError, match="method.*path.*handler"): + PlatformOp(op="x", description="d", input_schema={"type": "object"}) + with pytest.raises(ValidationError, match="method.*path.*handler"): + PlatformOp( + op="x", + description="d", + method="POST", + path="/api/x", + handler="tools.agenta.test_run", + input_schema={"type": "object"}, + ) + with pytest.raises(ValidationError, match="method.*path"): + PlatformOp( + op="x", + description="d", + method="POST", + input_schema={"type": "object"}, + ) + + +def test_op_handler_must_be_allowlisted(): + with pytest.raises(ValidationError, match="allowlisted"): + PlatformOp( + op="x", + description="d", + handler="tools.agenta.unknown", + input_schema={"type": "object"}, + ) + # The allowlist is an exact match, not a prefix match: an extension of an + # allowlisted ref is still rejected. + with pytest.raises(ValidationError, match="allowlisted"): + PlatformOp( + op="x", + description="d", + handler="tools.agenta.test_run_extra", + input_schema={"type": "object"}, + ) + + def test_op_input_schema_ref_resolves_against_the_catalog(): # A whole-op schema named by a CATALOG_TYPES key expands to that concrete type (no marker left). op = PlatformOp( @@ -140,22 +182,22 @@ def test_unknown_op_raises_typed_error(): get_platform_op("does_not_exist") assert caught.value.op == "does_not_exist" # The available ops are listed so the message is actionable. - assert "find_capabilities" in str(caught.value) + assert "discover_tools" in str(caught.value) -# --- resolver: find_capabilities emits a direct call -------------------------- +# --- resolver: discover_tools emits a direct call -------------------------- -async def test_find_capabilities_emits_a_direct_call(connection): - # THE deferred item (PR #4884): find_capabilities becomes agent-usable as a direct call to +async def test_discover_tools_emits_a_direct_call(connection): + # THE deferred item (PR #4884): discover_tools becomes agent-usable as a direct call to # POST /api/tools/discover, instead of the server-side /tools/call tools.agenta.* dispatch. resolution = await _resolver(connection).resolve( - [PlatformToolConfig(op="find_capabilities")] + [PlatformToolConfig(op="discover_tools")] ) assert len(resolution.tool_specs) == 1 spec = resolution.tool_specs[0] assert spec.kind == "callback" - assert spec.name == "find_capabilities" + assert spec.name == "discover_tools" # A direct call, NOT a gateway call_ref (the `call` XOR `call_ref` rule). assert spec.call_ref is None assert spec.call is not None @@ -176,9 +218,9 @@ async def test_find_capabilities_emits_a_direct_call(connection): assert resolution.tool_callback.authorization == "Access tok" -async def test_find_capabilities_wire_carries_call_not_call_ref(connection): +async def test_discover_tools_wire_carries_call_not_call_ref(connection): resolution = await _resolver(connection).resolve( - [PlatformToolConfig(op="find_capabilities")] + [PlatformToolConfig(op="discover_tools")] ) wire = resolution.tool_specs[0].to_wire() assert wire["kind"] == "callback" @@ -186,6 +228,112 @@ async def test_find_capabilities_wire_carries_call_not_call_ref(connection): assert wire["call"]["path"] == "/api/tools/discover" +async def test_test_run_handler_call_ref_requires_platform_handlers_flag(connection): + with pytest.raises( + GatewayToolResolutionError, + match="AGENTA_AGENT_ENABLE_PLATFORM_HANDLERS", + ): + await _resolver(connection).resolve([PlatformToolConfig(op="test_run")]) + + +async def test_test_run_emits_handler_call_ref_with_bindings_and_timeout( + connection, monkeypatch +): + monkeypatch.setenv("AGENTA_AGENT_ENABLE_PLATFORM_HANDLERS", "true") + resolution = await _resolver(connection).resolve( + [PlatformToolConfig(op="test_run")] + ) + spec = resolution.tool_specs[0] + + assert spec.kind == "callback" + assert spec.name == "test_run" + assert spec.call is None + assert spec.call_ref == "tools.agenta.test_run" + assert spec.context_bindings == { + "target.workflow_variant_id": "$ctx.workflow.variant.id" + } + assert spec.timeout_ms == 120000 + assert spec.read_only is False + assert spec.effective_permission() is None + assert "target" not in spec.input_schema["properties"] + assert set(spec.input_schema["properties"]) == { + "inputs", + "delta", + "expectations", + } + assert spec.input_schema["required"] == ["inputs"] + assert spec.input_schema["properties"]["inputs"]["required"] == ["messages"] + + wire = spec.to_wire() + assert wire["callRef"] == "tools.agenta.test_run" + assert wire["contextBindings"] == { + "target.workflow_variant_id": "$ctx.workflow.variant.id" + } + assert wire["timeoutMs"] == 120000 + assert "call" not in wire + + +async def test_query_spans_emits_project_scoped_read_call(connection): + # Project scoping comes from the caller credential on the endpoint; there is no target field + # for the model to supply and no run-context binding to inject. + resolution = await _resolver(connection).resolve( + [PlatformToolConfig(op="query_spans")] + ) + spec = resolution.tool_specs[0] + assert spec.kind == "callback" + assert spec.name == "query_spans" + assert spec.call_ref is None + assert spec.call.method == "POST" + assert spec.call.path == "/api/spans/query" + assert spec.call.context is None + assert spec.read_only is True + assert spec.effective_permission() is None + + assert set(spec.input_schema["properties"]) == { + "filtering", + "windowing", + "query_ref", + "query_variant_ref", + "query_revision_ref", + } + assert "required" not in spec.input_schema + assert { + "focus", + "format", + "filter", + "oldest", + "newest", + "limit", + "rate", + }.isdisjoint(spec.input_schema["properties"]) + + defs = spec.input_schema["$defs"] + filtering_schema = defs["Filtering"] + assert set(filtering_schema["properties"]) == {"operator", "conditions"} + condition_ref = filtering_schema["properties"]["conditions"]["items"]["anyOf"][0] + assert condition_ref == {"$ref": "#/$defs/Condition"} + condition_schema = defs["Condition"] + assert set(condition_schema["properties"]) == { + "field", + "key", + "value", + "operator", + "options", + } + assert condition_schema["required"] == ["field"] + assert "trace_id" in condition_schema["properties"]["field"]["description"] + + assert set(defs["Windowing"]["properties"]) == { + "newest", + "oldest", + "next", + "limit", + "order", + "interval", + "rate", + } + + # --- resolver: commit_revision self-update binds + strips --------------------- @@ -260,7 +408,7 @@ async def test_annotate_trace_is_not_read_only(connection): async def test_trigger_builder_ops_have_expected_paths_and_defaults(connection): expected_paths = { - "find_triggers": ("POST", "/api/triggers/discover"), + "discover_triggers": ("POST", "/api/triggers/discover"), "create_schedule": ("POST", "/api/triggers/schedules/"), "create_subscription": ("POST", "/api/triggers/subscriptions/"), "list_schedules": ("GET", "/api/triggers/schedules/"), @@ -276,7 +424,7 @@ async def test_trigger_builder_ops_have_expected_paths_and_defaults(connection): "resume_subscription": ("POST", "/api/triggers/subscriptions/{id}/start"), } read_only = { - "find_triggers", + "discover_triggers", "list_schedules", "list_subscriptions", "list_deliveries", @@ -342,14 +490,14 @@ async def test_unknown_op_in_config_raises(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([PlatformToolConfig(op="find_capabilities")]) + await resolver.resolve([PlatformToolConfig(op="discover_tools")]) async def test_duplicate_platform_tool_rejected(connection): with pytest.raises(GatewayToolResolutionError, match="Duplicate platform tool"): await _resolver(connection).resolve( [ - PlatformToolConfig(op="find_capabilities"), - PlatformToolConfig(op="find_capabilities"), + PlatformToolConfig(op="discover_tools"), + PlatformToolConfig(op="discover_tools"), ] ) 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 789edceeed..781790cfd0 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 @@ -58,9 +58,9 @@ def test_reference_tool_discriminator_is_reference(): def test_platform_tool_discriminator(): - config = PlatformToolConfig(op="find_capabilities") + config = PlatformToolConfig(op="discover_tools") assert config.type == "platform" - assert config.op == "find_capabilities" + assert config.op == "discover_tools" def test_platform_tool_requires_op(): @@ -111,6 +111,24 @@ def test_callback_spec_has_stable_typed_contract(): assert "call" not in spec.to_wire() +def test_callback_spec_call_ref_can_carry_context_bindings_and_timeout(): + spec = CallbackToolSpec( + name="test_run", + description="Test run", + call_ref="tools.agenta.test_run", + context_bindings={"target.workflow_variant_id": "$ctx.workflow.variant.id"}, + timeout_ms=120000, + ) + + wire = spec.to_wire() + assert wire["callRef"] == "tools.agenta.test_run" + assert wire["contextBindings"] == { + "target.workflow_variant_id": "$ctx.workflow.variant.id" + } + assert wire["timeoutMs"] == 120000 + assert coerce_tool_spec(wire) == spec + + def test_callback_spec_direct_call_round_trips_on_the_wire(): # A direct-call callback spec carries a `call` descriptor instead of `call_ref` (the # `call` XOR `call_ref` rule). The descriptor round-trips through the wire keeping its @@ -153,6 +171,18 @@ def test_callback_spec_requires_exactly_one_call_target(): ) +def test_callback_spec_rejects_context_bindings_on_a_direct_call(): + # Spec-level bindings are injected by the gateway relay; a direct call has no relay, + # so the combination is invalid (direct-call bindings belong in `call.context`). + with pytest.raises(ValidationError, match="context_bindings"): + CallbackToolSpec( + name="t", + description="t", + call={"method": "GET", "path": "/api/ping"}, + context_bindings={"target.id": "$ctx.workflow.variant.id"}, + ) + + def test_secret_values_are_hidden_from_repr(): spec = CodeToolSpec( name="private", 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 503806d6f8..109add896b 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 @@ -191,9 +191,9 @@ def test_typed_reference_without_slug_raises(): def test_typed_platform_config_round_trips(): - tool = parse_tool_config({"type": "platform", "op": "find_capabilities"}) + tool = parse_tool_config({"type": "platform", "op": "discover_tools"}) assert isinstance(tool, PlatformToolConfig) - assert tool.op == "find_capabilities" + assert tool.op == "discover_tools" def test_compat_parser_accepts_platform_type_and_ignores_legacy_fields(): 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 1901b8fea9..b58e9878bd 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 @@ -266,19 +266,19 @@ async def test_platform_tool_resolves_to_callback_spec_with_direct_call(): # A type:"platform" tool becomes a callback spec carrying a direct `call` (no call_ref), plus # the shared ToolCallback that gives the runner the origin to resolve the relative path against. resolved = await ToolResolver(platform_resolver=FakePlatformResolver()).resolve( - [PlatformToolConfig(op="find_capabilities")] + [PlatformToolConfig(op="discover_tools")] ) assert len(resolved.tool_specs) == 1 spec = resolved.tool_specs[0] assert isinstance(spec, CallbackToolSpec) assert spec.call_ref is None - assert spec.call.path == "/api/find_capabilities" + assert spec.call.path == "/api/discover_tools" assert resolved.tool_callback.endpoint == "https://example/tools/call" async def test_platform_tool_requires_injected_resolver(): with pytest.raises(UnsupportedToolProviderError): - await ToolResolver().resolve([PlatformToolConfig(op="find_capabilities")]) + await ToolResolver().resolve([PlatformToolConfig(op="discover_tools")]) async def test_reference_and_gateway_share_one_callback(): diff --git a/sdks/python/oss/tests/pytest/unit/test_skill_template_catalog.py b/sdks/python/oss/tests/pytest/unit/test_skill_template_catalog.py index 4065ef8a03..2dd6e0542b 100644 --- a/sdks/python/oss/tests/pytest/unit/test_skill_template_catalog.py +++ b/sdks/python/oss/tests/pytest/unit/test_skill_template_catalog.py @@ -168,7 +168,7 @@ def test_agent_template_with_platform_tool_validates(): agent_template = CATALOG_TYPES["agent-template"] config = _base_agent_template() - config["tools"] = [{"type": "platform", "op": "find_capabilities"}] + config["tools"] = [{"type": "platform", "op": "discover_tools"}] jsonschema.validate(config, agent_template) diff --git a/sdks/python/oss/tests/pytest/unit/test_workflow_shapes_running.py b/sdks/python/oss/tests/pytest/unit/test_workflow_shapes_running.py index afb79c561f..db7a6122d0 100644 --- a/sdks/python/oss/tests/pytest/unit/test_workflow_shapes_running.py +++ b/sdks/python/oss/tests/pytest/unit/test_workflow_shapes_running.py @@ -225,3 +225,76 @@ def wf(value: str): assert isinstance(response, WorkflowServiceBatchResponse) assert response.data.outputs == "inner:e" + + +async def _noop_revision_default_call_next(_request): + return "ok" + + +@pytest.mark.asyncio +async def test_inline_agent_revision_without_parameters_uses_default_template(): + from agenta.sdk.contexts.running import RunningContext, running_context_manager + from agenta.sdk.contexts.tracing import TracingContext, tracing_context_manager + from agenta.sdk.middlewares.running.resolver import ResolverMiddleware + from agenta.sdk.models.workflows import WorkflowInvokeRequest, WorkflowRequestData + from agenta.sdk.utils.types import build_agent_v0_default + + request = WorkflowInvokeRequest( + data=WorkflowRequestData( + revision={ + "data": { + "uri": "agenta:builtin:agent:v0", + "url": "https://agent.internal", + "schemas": {"parameters": {"type": "object"}}, + } + } + ) + ) + + with ( + running_context_manager(RunningContext()), + tracing_context_manager(TracingContext()), + ): + await ResolverMiddleware()(request, _noop_revision_default_call_next) + revision = RunningContext.get().revision + + expected_parameters = {"agent": build_agent_v0_default()} + assert request.data.parameters == expected_parameters + assert revision["data"]["parameters"] == expected_parameters + + +@pytest.mark.asyncio +async def test_inline_agent_revision_with_parameters_stays_authoritative(): + from agenta.sdk.contexts.running import RunningContext, running_context_manager + from agenta.sdk.contexts.tracing import TracingContext, tracing_context_manager + from agenta.sdk.middlewares.running.resolver import ResolverMiddleware + from agenta.sdk.models.workflows import WorkflowInvokeRequest, WorkflowRequestData + + explicit_parameters = { + "agent": { + "instructions": {"agents_md": "Use only this template."}, + "llm": {"provider": "openai", "model": "gpt-custom"}, + "tools": [{"type": "builtin", "name": "read_file"}], + } + } + request = WorkflowInvokeRequest( + data=WorkflowRequestData( + revision={ + "data": { + "uri": "agenta:builtin:agent:v0", + "url": "https://agent.internal", + "parameters": explicit_parameters, + } + } + ) + ) + + with ( + running_context_manager(RunningContext()), + tracing_context_manager(TracingContext()), + ): + await ResolverMiddleware()(request, _noop_revision_default_call_next) + revision = RunningContext.get().revision + + assert request.data.parameters == explicit_parameters + assert revision["data"]["parameters"] == explicit_parameters