Skip to content
Merged
1 change: 1 addition & 0 deletions api/entrypoints/routers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
40 changes: 28 additions & 12 deletions api/oss/src/apis/fastapi/applications/overlay.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, ...] = (
Comment thread
mmabrouk marked this conversation as resolved.
"discover_tools",
"commit_revision",
"annotate_trace",
"query_spans",
"discover_triggers",
"create_schedule",
"create_subscription",
"list_schedules",
"list_deliveries",
"test_subscription",
"remove_schedule",
"remove_subscription",
)


Expand Down Expand Up @@ -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
Expand All @@ -67,19 +79,23 @@ 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",
)
)

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,
Expand Down
2 changes: 1 addition & 1 deletion api/oss/src/apis/fastapi/tools/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ class ToolResolveResponse(BaseModel):


# ---------------------------------------------------------------------------
# Tool discovery (find_capabilities)
# Tool discovery (discover_tools)
# ---------------------------------------------------------------------------


Expand Down
111 changes: 50 additions & 61 deletions api/oss/src/apis/fastapi/tools/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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(".")
Expand Down Expand Up @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@staticmethod
def _validate_slug_segments(*, segments: List[str]) -> None:
Expand Down
75 changes: 3 additions & 72 deletions api/oss/src/core/tools/discovery.py
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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,
Expand All @@ -22,82 +22,13 @@
DiscoveredAlternative,
DiscoveredTool,
ToolConnectionState,
ToolProviderKind,
)
from oss.src.core.tools.providers.composio.dtos import (
ComposioSearchQueryResult,
ComposioSearchResult,
)


# ---------------------------------------------------------------------------
# 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).
Expand All @@ -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."
)
Expand Down
Loading
Loading