Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions api/oss/src/apis/fastapi/tools/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
from typing import List, Optional, Union
from typing import Any, List, Optional, Union

from pydantic import BaseModel
from agenta.sdk.agents.tools import (
BuiltinToolConfig,
GatewayToolConfig,
ToolConfigurationError,
coerce_tool_configs,
)
from pydantic import BaseModel, Field, field_validator

from oss.src.core.tools.dtos import (
# Tool Catalog
Expand All @@ -15,6 +21,9 @@
ToolConnectionCreate,
# Tool Calls
ToolResult,
# Agent tools
AgentToolReference,
ResolvedAgentTool,
)


Expand Down Expand Up @@ -87,3 +96,34 @@ class ToolConnectionsResponse(BaseModel):

class ToolCallResponse(BaseModel):
call: ToolResult


# ---------------------------------------------------------------------------
# Agent tool resolution
# ---------------------------------------------------------------------------


class ToolResolveRequest(BaseModel):
tools: List[AgentToolReference] = Field(default_factory=list)

@field_validator("tools", mode="before")
@classmethod
def _coerce_tools(cls, value: Any) -> List[AgentToolReference]:
try:
configs = coerce_tool_configs(value or []).tool_configs
except ToolConfigurationError as exc:
Comment on lines +111 to +114

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Avoid falsy-coercing invalid payloads to an empty tools list

Using value or [] makes invalid falsy payloads (e.g. {}, 0, False) pass as [], so malformed requests can be silently accepted.

Suggested fix
     def _coerce_tools(cls, value: Any) -> List[AgentToolReference]:
+        if value is None:
+            raw_values = []
+        elif isinstance(value, list):
+            raw_values = value
+        else:
+            raise ValueError("tools must be an array")
         try:
-            configs = coerce_tool_configs(value or []).tool_configs
+            configs = coerce_tool_configs(raw_values).tool_configs
         except ToolConfigurationError as exc:
             raise ValueError(str(exc)) from exc

raise ValueError(str(exc)) from exc
unsupported = [
config
for config in configs
if not isinstance(config, (BuiltinToolConfig, GatewayToolConfig))
]
if unsupported:
raise ValueError("/tools/resolve accepts only builtin and gateway tools")
return configs


class ToolResolveResponse(BaseModel):
count: int = 0
builtins: List[str] = Field(default_factory=list)
custom: List[ResolvedAgentTool] = Field(default_factory=list)
89 changes: 60 additions & 29 deletions api/oss/src/apis/fastapi/tools/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
ToolConnectionsResponse,
#
ToolCallResponse,
#
ToolResolveRequest,
ToolResolveResponse,
)

from oss.src.core.shared.dtos import Status
Expand All @@ -42,10 +45,12 @@
ToolResultData,
)
from oss.src.core.tools.exceptions import (
ActionNotFoundError,
AdapterError,
ConnectionInactiveError,
ConnectionInvalidError,
ConnectionNotFoundError,
ToolSlugInvalidError,
)
from oss.src.core.tools.service import (
ToolsService,
Expand Down Expand Up @@ -208,6 +213,14 @@ def __init__(
)

# --- Tool operations ---
self.router.add_api_route(
"/resolve",
self.resolve_tools,
methods=["POST"],
operation_id="resolve_agent_tools",
response_model=ToolResolveResponse,
response_model_exclude_none=True,
)
self.router.add_api_route(
"/call",
self.call_tool,
Expand Down Expand Up @@ -886,6 +899,51 @@ async def callback_connection(
# Tool Calls
# -----------------------------------------------------------------------

@intercept_exceptions()
@handle_adapter_exceptions()
async def resolve_tools(
self,
request: Request,
*,
body: ToolResolveRequest,
) -> ToolResolveResponse:
"""Resolve an agent's tool references into model-ready specs.

Validates Composio connections up front and enriches each action from the
catalog, so a running agent (e.g. Pi) gets ``customTools`` whose ``execute``
routes back through ``POST /tools/call`` — provider keys stay server-side.
"""
if is_ee():
has_permission = await check_action_access(
user_uid=request.state.user_id,
project_id=request.state.project_id,
permission=Permission.VIEW_TOOLS,
)
if not has_permission:
raise FORBIDDEN_EXCEPTION

try:
resolution = await self.tools_service.resolve_agent_tools(
project_id=UUID(request.state.project_id),
tools=body.tools,
)
except ConnectionNotFoundError as e:
raise HTTPException(status_code=404, detail=e.message) from e
except ConnectionInactiveError as e:
raise HTTPException(status_code=400, detail=e.message) from e
except ConnectionInvalidError as e:
raise HTTPException(status_code=400, detail=e.message) from e
except ToolSlugInvalidError as e:
raise HTTPException(status_code=400, detail=e.message) from e
except ActionNotFoundError as e:
raise HTTPException(status_code=404, detail=e.message) from e

return ToolResolveResponse(
count=len(resolution.builtins) + len(resolution.custom),
builtins=resolution.builtins,
custom=resolution.custom,
)

@intercept_exceptions()
@handle_adapter_exceptions()
async def call_tool(
Expand Down Expand Up @@ -931,39 +989,12 @@ async def call_tool(
connection_slug = slug_parts[4]

try:
connections = await self.tools_service.query_connections(
connection = await self.tools_service.resolve_connection_by_slug(
project_id=UUID(request.state.project_id),
provider_key=provider_key,
integration_key=integration_key,
connection_slug=connection_slug,
)

connection = next(
(c for c in connections if c.slug == connection_slug), None
)

if not connection:
raise ConnectionNotFoundError(
connection_slug=connection_slug,
provider_key=provider_key,
integration_key=integration_key,
)

if not connection.is_active:
raise ConnectionInactiveError(connection_id=connection_slug)

if not connection.is_valid:
raise ConnectionInvalidError(
connection_slug=connection_slug,
detail="Please refresh the connection.",
)

if not connection.provider_connection_id:
raise ConnectionNotFoundError(
connection_slug=connection_slug,
provider_key=provider_key,
integration_key=integration_key,
)

except ConnectionNotFoundError as e:
raise HTTPException(status_code=404, detail=e.message) from e
except ConnectionInactiveError as e:
Expand Down
44 changes: 42 additions & 2 deletions api/oss/src/core/tools/dtos.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from enum import Enum
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Union

from agenta.sdk.agents.tools import BuiltinToolConfig, GatewayToolConfig
from agenta.sdk.models.workflows import JsonSchemas
from pydantic import BaseModel
from pydantic import BaseModel, Field

from oss.src.core.shared.dtos import (
Header,
Expand Down Expand Up @@ -238,3 +239,42 @@ class ToolExecutionResponse(BaseModel):
data: Optional[Json] = None
error: Optional[str] = None
successful: bool = False


# ---------------------------------------------------------------------------
# Agent tools (config references + resolution)
# ---------------------------------------------------------------------------

# A provider-agnostic list of tool references lives under an agent revision's
# ``parameters["tools"]``. Each entry is a discriminated union on ``type``: config
# holds references and display metadata only, never secrets. The backend resolves
# them into model-ready specs at invoke time (see ToolsService.resolve_agent_tools).


AgentBuiltinTool = BuiltinToolConfig
AgentComposioTool = GatewayToolConfig
AgentToolReference = Union[BuiltinToolConfig, GatewayToolConfig]


class ResolvedAgentTool(BaseModel):
"""A runnable reference resolved into a model-ready tool spec.

``call_ref`` is the ``tools.{provider}.{integration}.{action}.{connection}`` slug
the execution bridge sends back to ``POST /tools/call``.
"""

name: str
description: Optional[str] = None
input_schema: Optional[Dict[str, Any]] = None
call_ref: str


class AgentToolsResolution(BaseModel):
"""Outcome of resolving an agent's ``tools`` list.

``builtins`` pass straight into Pi's ``tools: string[]``; ``custom`` become Pi
``customTools`` whose ``execute`` routes through ``/tools/call``.
"""

builtins: List[str] = Field(default_factory=list)
custom: List[ResolvedAgentTool] = Field(default_factory=list)
18 changes: 18 additions & 0 deletions api/oss/src/core/tools/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,24 @@ def __init__(
super().__init__(msg)


class ActionNotFoundError(ToolsError):
"""Raised when a catalog action cannot be found for an integration."""

def __init__(
self,
*,
provider_key: str,
integration_key: str,
action_key: str,
):
self.provider_key = provider_key
self.integration_key = integration_key
self.action_key = action_key
super().__init__(
f"Action not found: {provider_key}/{integration_key}/{action_key}"
)


class ConnectionSlugConflictError(ToolsError):
"""Raised when a connection slug already exists for the integration."""

Expand Down
Loading
Loading