789 lines
28 KiB
Python
789 lines
28 KiB
Python
"""Custom tools and toolkits for tool router sessions.
|
|
|
|
Decorator API for defining custom tools that run in-process alongside
|
|
remote Composio tools. Accessed via ``composio.experimental``.
|
|
|
|
Usage::
|
|
|
|
from pydantic import BaseModel, Field
|
|
from composio import Composio
|
|
|
|
composio = Composio()
|
|
|
|
class GrepInput(BaseModel):
|
|
pattern: str = Field(description="Pattern to search for")
|
|
|
|
@composio.experimental.tool()
|
|
def grep(input: GrepInput, ctx):
|
|
\"\"\"Search for a pattern in local files.\"\"\"
|
|
return {"matches": []}
|
|
|
|
dev_tools = composio.experimental.Toolkit(
|
|
slug="DEV_TOOLS",
|
|
name="Dev Tools",
|
|
description="Local dev utilities",
|
|
)
|
|
|
|
@dev_tools.tool()
|
|
def search_code(input: GrepInput, ctx):
|
|
\"\"\"Search developer resources.\"\"\"
|
|
return {"results": []}
|
|
|
|
session = composio.create(
|
|
user_id="default",
|
|
experimental={
|
|
"custom_tools": [grep],
|
|
"custom_toolkits": [dev_tools],
|
|
},
|
|
)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import inspect
|
|
import typing as t
|
|
|
|
from pydantic import BaseModel
|
|
|
|
from composio.exceptions import ValidationError
|
|
|
|
from .custom_tool_types import (
|
|
LOCAL_TOOL_PREFIX,
|
|
MAX_SLUG_LENGTH,
|
|
SLUG_REGEX,
|
|
CustomTool,
|
|
CustomToolExecuteFn,
|
|
CustomToolkitWireDefinition,
|
|
CustomToolsMap,
|
|
CustomToolsMapEntry,
|
|
CustomToolWireDefinition,
|
|
)
|
|
from .tool_router_constants import PRELOAD_TOOLS_ALL
|
|
|
|
if t.TYPE_CHECKING:
|
|
from composio_client.types.tool_router.session_attach_response import (
|
|
Experimental as SessionAttachResponseExperimental,
|
|
)
|
|
from composio_client.types.tool_router.session_create_response import (
|
|
Experimental as SessionCreateResponseExperimental,
|
|
)
|
|
from composio_client.types.tool_router.session_retrieve_response import (
|
|
Experimental as SessionRetrieveResponseExperimental,
|
|
)
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
# Slug validation helpers
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _validate_slug(slug: str, context: str) -> str:
|
|
"""Validate a custom tool or toolkit slug."""
|
|
if not slug:
|
|
raise ValidationError(f"{context}: slug is required")
|
|
|
|
if not SLUG_REGEX.match(slug):
|
|
raise ValidationError(
|
|
f"{context}: slug must only contain alphanumeric characters, "
|
|
f"underscores, and hyphens"
|
|
)
|
|
|
|
upper = slug.upper()
|
|
if upper.startswith("LOCAL_"):
|
|
raise ValidationError(
|
|
f'{context}: slug must not start with "LOCAL_" — '
|
|
f"this prefix is reserved for internal routing."
|
|
)
|
|
if upper.startswith("COMPOSIO_"):
|
|
raise ValidationError(
|
|
f'{context}: slug must not start with "COMPOSIO_" — '
|
|
f"this prefix is reserved for Composio meta tools."
|
|
)
|
|
|
|
return slug
|
|
|
|
|
|
def _compute_final_slug_length(tool_slug: str, toolkit_slug: t.Optional[str]) -> int:
|
|
"""Compute the final slug length: LOCAL_[TOOLKIT_]SLUG."""
|
|
length = len(LOCAL_TOOL_PREFIX) + len(tool_slug)
|
|
if toolkit_slug:
|
|
length += len(toolkit_slug) + 1 # +1 for underscore separator
|
|
return length
|
|
|
|
|
|
def _validate_slug_length(
|
|
tool_slug: str, toolkit_slug: t.Optional[str], context: str
|
|
) -> None:
|
|
"""Validate that the final slug won't exceed the max length."""
|
|
final_length = _compute_final_slug_length(tool_slug, toolkit_slug)
|
|
if final_length > MAX_SLUG_LENGTH:
|
|
prefix = LOCAL_TOOL_PREFIX + (
|
|
f"{toolkit_slug.upper()}_" if toolkit_slug else ""
|
|
)
|
|
available = MAX_SLUG_LENGTH - len(prefix)
|
|
raise ValidationError(
|
|
f'{context}: slug "{tool_slug}" is too long. '
|
|
f'With prefix "{prefix}", the final slug would be {final_length} '
|
|
f"characters (max {MAX_SLUG_LENGTH}). "
|
|
f"Shorten the slug to at most {available} characters."
|
|
)
|
|
|
|
|
|
def _build_final_slug(tool_slug: str, toolkit_slug: t.Optional[str] = None) -> str:
|
|
"""Build the final slug: LOCAL_[TOOLKIT_]SLUG."""
|
|
upper = tool_slug.upper()
|
|
if toolkit_slug:
|
|
return f"{LOCAL_TOOL_PREFIX}{toolkit_slug.upper()}_{upper}"
|
|
return f"{LOCAL_TOOL_PREFIX}{upper}"
|
|
|
|
|
|
def _get_input_json_schema(model: t.Type[BaseModel]) -> t.Dict[str, t.Any]:
|
|
"""Convert a Pydantic model class to a JSON Schema dict suitable for the backend."""
|
|
full_schema = model.model_json_schema()
|
|
schema: t.Dict[str, t.Any] = {"type": "object"}
|
|
if "properties" in full_schema:
|
|
schema["properties"] = full_schema["properties"]
|
|
if "required" in full_schema:
|
|
schema["required"] = full_schema["required"]
|
|
if "$defs" in full_schema:
|
|
schema["$defs"] = full_schema["$defs"]
|
|
return schema
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
# Internal tool creation (used by decorator API)
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _create_tool(
|
|
slug: str,
|
|
*,
|
|
name: str,
|
|
description: str,
|
|
input_params: t.Type[BaseModel],
|
|
execute: CustomToolExecuteFn,
|
|
extends_toolkit: t.Optional[str] = None,
|
|
output_params: t.Optional[t.Type[BaseModel]] = None,
|
|
preload: t.Optional[bool] = None,
|
|
) -> CustomTool:
|
|
"""Internal: create and validate a CustomTool."""
|
|
context = "experimental.tool"
|
|
|
|
_validate_slug(slug, context)
|
|
|
|
if not name:
|
|
raise ValidationError(f"{context}: name is required")
|
|
if not description:
|
|
raise ValidationError(f"{context}: description is required")
|
|
|
|
if not isinstance(input_params, type) or not issubclass(input_params, BaseModel):
|
|
raise ValidationError(
|
|
f"{context}: input_params must be a Pydantic BaseModel subclass. "
|
|
f"Tool input parameters are always an object with named properties."
|
|
)
|
|
|
|
try:
|
|
from pydantic import RootModel
|
|
|
|
if issubclass(input_params, RootModel):
|
|
raise ValidationError(
|
|
f"{context}: input_params must be a regular BaseModel with named fields, "
|
|
f"not a RootModel. Tool input parameters are always an object with "
|
|
f"named properties."
|
|
)
|
|
except ImportError:
|
|
pass
|
|
|
|
if not callable(execute):
|
|
raise ValidationError(f"{context}: execute must be a callable")
|
|
|
|
if asyncio.iscoroutinefunction(execute):
|
|
raise ValidationError(
|
|
f"{context}: execute must be a synchronous function, not async. "
|
|
f"The Composio Python SDK is synchronous — use a regular "
|
|
f"'def fn(input, ctx)' instead of 'async def'."
|
|
)
|
|
|
|
_validate_slug_length(slug, extends_toolkit, context)
|
|
|
|
input_schema = _get_input_json_schema(input_params)
|
|
|
|
output_schema: t.Optional[t.Dict[str, t.Any]] = None
|
|
if output_params is not None:
|
|
if not isinstance(output_params, type) or not issubclass(
|
|
output_params, BaseModel
|
|
):
|
|
raise ValidationError(
|
|
f"{context}: output_params must be a Pydantic BaseModel subclass"
|
|
)
|
|
output_schema = output_params.model_json_schema()
|
|
|
|
return CustomTool(
|
|
slug=slug,
|
|
name=name,
|
|
description=description,
|
|
extends_toolkit=extends_toolkit,
|
|
input_schema=input_schema,
|
|
output_schema=output_schema,
|
|
input_params=input_params,
|
|
execute=execute,
|
|
preload=preload,
|
|
)
|
|
|
|
|
|
def _get_caller_locals(depth: int = 2) -> t.Optional[t.Mapping[str, t.Any]]:
|
|
"""Best-effort lookup of a caller frame's locals."""
|
|
frame = inspect.currentframe()
|
|
try:
|
|
caller = frame
|
|
for _ in range(depth):
|
|
caller = caller.f_back if caller is not None else None
|
|
return caller.f_locals if caller is not None else None
|
|
finally:
|
|
del frame
|
|
|
|
|
|
def _resolve_function_annotations(
|
|
fn: t.Callable[..., t.Any],
|
|
*,
|
|
localns: t.Optional[t.Mapping[str, t.Any]] = None,
|
|
) -> t.Dict[str, t.Any]:
|
|
"""Resolve annotations, including postponed string annotations when possible."""
|
|
try:
|
|
return t.get_type_hints(
|
|
fn,
|
|
globalns=getattr(fn, "__globals__", {}),
|
|
localns=dict(localns) if localns is not None else None,
|
|
include_extras=True,
|
|
)
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def _infer_tool_from_function(
|
|
fn: t.Callable[..., t.Any],
|
|
*,
|
|
slug: t.Optional[str] = None,
|
|
name: t.Optional[str] = None,
|
|
description: t.Optional[str] = None,
|
|
extends_toolkit: t.Optional[str] = None,
|
|
output_params: t.Optional[t.Type[BaseModel]] = None,
|
|
preload: t.Optional[bool] = None,
|
|
annotation_locals: t.Optional[t.Mapping[str, t.Any]] = None,
|
|
) -> CustomTool:
|
|
"""Create a CustomTool by inferring metadata from a decorated function.
|
|
|
|
- slug: from ``fn.__name__.upper()``
|
|
- name: from ``fn.__name__`` humanized
|
|
- description: from ``fn.__doc__``
|
|
- input_params: from the first parameter with a BaseModel type annotation
|
|
"""
|
|
# Infer slug
|
|
actual_slug = slug or fn.__name__.upper()
|
|
actual_name = name or fn.__name__.replace("_", " ").title()
|
|
actual_description = description or inspect.cleandoc(fn.__doc__ or "")
|
|
|
|
if not actual_description:
|
|
raise ValidationError(
|
|
f"experimental.tool: description is required. "
|
|
f'Add a docstring to "{fn.__name__}" or pass description=...'
|
|
)
|
|
|
|
# Validate and infer function signature.
|
|
# Accepted shapes (consistent with TS CustomToolExecuteFn):
|
|
# (input: BaseModel) — no session context needed
|
|
# (input: BaseModel, ctx) — with session context
|
|
# The first param MUST be annotated with a BaseModel subclass.
|
|
# Reject async before wrapping (wrapper would hide it from _create_tool)
|
|
if asyncio.iscoroutinefunction(fn):
|
|
raise ValidationError(
|
|
f'experimental.tool: "{fn.__name__}" is async. '
|
|
f"The Composio Python SDK is synchronous — use a regular "
|
|
f"'def {fn.__name__}(input, ctx)' instead of 'async def'."
|
|
)
|
|
|
|
sig = inspect.signature(fn)
|
|
params = list(sig.parameters.values())
|
|
resolved_annotations = _resolve_function_annotations(
|
|
fn,
|
|
localns=annotation_locals,
|
|
)
|
|
|
|
if not params:
|
|
raise ValidationError(
|
|
f'experimental.tool: "{fn.__name__}" must accept at least one parameter '
|
|
f"annotated with a Pydantic BaseModel subclass, e.g. "
|
|
f"def {fn.__name__}(input: MyInput, ctx): ..."
|
|
)
|
|
|
|
if len(params) > 2:
|
|
raise ValidationError(
|
|
f'experimental.tool: "{fn.__name__}" accepts {len(params)} parameters, '
|
|
f"but custom tools accept at most 2: (input: BaseModel, ctx). "
|
|
f"Extra parameters will not be populated at runtime."
|
|
)
|
|
|
|
# First param must be the input model
|
|
first = params[0]
|
|
first_annotation = resolved_annotations.get(first.name, first.annotation)
|
|
if first_annotation is inspect.Parameter.empty or not (
|
|
isinstance(first_annotation, type) and issubclass(first_annotation, BaseModel)
|
|
):
|
|
raise ValidationError(
|
|
f'experimental.tool: first parameter of "{fn.__name__}" must be annotated '
|
|
f"with a Pydantic BaseModel subclass. Got: {first_annotation!r}. "
|
|
f"Expected: def {fn.__name__}(input: MyInput, ctx): ..."
|
|
)
|
|
input_params: t.Type[BaseModel] = first_annotation
|
|
|
|
# Wrap function to match CustomToolExecuteFn: (input, ctx) -> dict
|
|
if len(params) == 1:
|
|
|
|
def execute(input: t.Any, ctx: t.Any) -> t.Dict[str, t.Any]:
|
|
return fn(input)
|
|
else:
|
|
execute = fn
|
|
|
|
return _create_tool(
|
|
slug=actual_slug,
|
|
name=actual_name,
|
|
description=actual_description,
|
|
input_params=input_params,
|
|
execute=execute,
|
|
extends_toolkit=extends_toolkit,
|
|
output_params=output_params,
|
|
preload=preload,
|
|
)
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
# ExperimentalToolkit — custom toolkit with .tool() decorator
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class ExperimentalToolkit:
|
|
"""Custom toolkit that groups related tools under one namespace.
|
|
|
|
Use ``@toolkit.tool()`` to add tools. Pass the toolkit to
|
|
``composio.create(experimental={"custom_toolkits": [toolkit]})``.
|
|
|
|
Tools added to a toolkit must NOT use ``extends_toolkit`` — they
|
|
inherit the toolkit identity instead.
|
|
|
|
Example::
|
|
|
|
dev_tools = composio.experimental.Toolkit(
|
|
slug="DEV_TOOLS",
|
|
name="Dev Tools",
|
|
description="Local dev utilities",
|
|
)
|
|
|
|
@dev_tools.tool()
|
|
def search_code(input: SearchInput, ctx):
|
|
\"\"\"Search developer resources.\"\"\"
|
|
return {"results": []}
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
slug: str,
|
|
name: str,
|
|
description: str,
|
|
preload: t.Optional[bool] = None,
|
|
) -> None:
|
|
context = "experimental.Toolkit"
|
|
_validate_slug(slug, context)
|
|
if not name:
|
|
raise ValidationError(f"{context}: name is required")
|
|
if not description:
|
|
raise ValidationError(f"{context}: description is required")
|
|
|
|
self.slug = slug
|
|
self.name = name
|
|
self.description = description
|
|
self.preload = preload
|
|
self._tools: t.List[CustomTool] = []
|
|
|
|
@property
|
|
def tools(self) -> t.Tuple[CustomTool, ...]:
|
|
return tuple(self._tools)
|
|
|
|
@t.overload
|
|
def tool(self, fn: t.Callable[..., t.Any], /) -> CustomTool: ...
|
|
|
|
@t.overload
|
|
def tool(
|
|
self,
|
|
*,
|
|
slug: t.Optional[str] = None,
|
|
name: t.Optional[str] = None,
|
|
description: t.Optional[str] = None,
|
|
output_params: t.Optional[t.Type[BaseModel]] = None,
|
|
preload: t.Optional[bool] = None,
|
|
) -> t.Callable[[t.Callable[..., t.Any]], CustomTool]: ...
|
|
|
|
def tool(
|
|
self,
|
|
fn: t.Optional[t.Callable[..., t.Any]] = None,
|
|
*,
|
|
slug: t.Optional[str] = None,
|
|
name: t.Optional[str] = None,
|
|
description: t.Optional[str] = None,
|
|
output_params: t.Optional[t.Type[BaseModel]] = None,
|
|
preload: t.Optional[bool] = None,
|
|
) -> t.Union[CustomTool, t.Callable[[t.Callable[..., t.Any]], CustomTool]]:
|
|
"""Decorator to add a tool to this toolkit.
|
|
|
|
Infers slug, name, description, and input_params from the function
|
|
if not explicitly provided.
|
|
"""
|
|
|
|
def decorator(f: t.Callable[..., t.Any]) -> CustomTool:
|
|
annotation_locals = _get_caller_locals()
|
|
custom_tool = _infer_tool_from_function(
|
|
f,
|
|
slug=slug,
|
|
name=name,
|
|
description=description,
|
|
output_params=output_params,
|
|
preload=preload,
|
|
annotation_locals=annotation_locals,
|
|
# No extends_toolkit for toolkit tools
|
|
)
|
|
_validate_slug_length(
|
|
custom_tool.slug, self.slug, f'experimental.Toolkit("{self.slug}").tool'
|
|
)
|
|
self._tools.append(custom_tool)
|
|
return custom_tool
|
|
|
|
if fn is not None:
|
|
custom_tool = _infer_tool_from_function(
|
|
fn,
|
|
slug=slug,
|
|
name=name,
|
|
description=description,
|
|
output_params=output_params,
|
|
preload=preload,
|
|
annotation_locals=_get_caller_locals(),
|
|
)
|
|
_validate_slug_length(
|
|
custom_tool.slug,
|
|
self.slug,
|
|
f'experimental.Toolkit("{self.slug}").tool',
|
|
)
|
|
self._tools.append(custom_tool)
|
|
return custom_tool
|
|
return decorator
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
# Serialization (for backend API payload)
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _serialized_preload_value(
|
|
preload: t.Optional[bool],
|
|
inherited_preload: t.Optional[bool],
|
|
default_preload: bool,
|
|
) -> t.Optional[bool]:
|
|
inherited_or_default = (
|
|
inherited_preload if inherited_preload is not None else default_preload
|
|
)
|
|
if preload is not None:
|
|
return preload if preload or inherited_or_default else None
|
|
if inherited_preload is not None:
|
|
return inherited_preload if inherited_preload or default_preload else None
|
|
return True if default_preload else None
|
|
|
|
|
|
def serialize_custom_tools(
|
|
tools: t.List[CustomTool],
|
|
*,
|
|
default_preload: bool = False,
|
|
) -> t.List[CustomToolWireDefinition]:
|
|
"""Serialize custom tools into the format expected by the backend."""
|
|
result: t.List[CustomToolWireDefinition] = []
|
|
for tool in tools:
|
|
entry: CustomToolWireDefinition = {
|
|
"slug": tool.slug,
|
|
"name": tool.name,
|
|
"description": tool.description,
|
|
"input_schema": tool.input_schema,
|
|
}
|
|
if tool.output_schema:
|
|
entry["output_schema"] = tool.output_schema
|
|
if tool.extends_toolkit:
|
|
entry["extends_toolkit"] = tool.extends_toolkit
|
|
preload = _serialized_preload_value(
|
|
tool.preload, inherited_preload=None, default_preload=default_preload
|
|
)
|
|
if preload is not None:
|
|
entry["preload"] = preload
|
|
result.append(entry)
|
|
return result
|
|
|
|
|
|
def serialize_custom_toolkits(
|
|
toolkits: t.Sequence[ExperimentalToolkit],
|
|
*,
|
|
default_preload: bool = False,
|
|
) -> t.List[CustomToolkitWireDefinition]:
|
|
"""Serialize custom toolkits into the format expected by the backend."""
|
|
result: t.List[CustomToolkitWireDefinition] = []
|
|
for tk in toolkits:
|
|
toolkit_tools: t.List[CustomToolWireDefinition] = []
|
|
for tool in tk.tools:
|
|
entry: CustomToolWireDefinition = {
|
|
"slug": tool.slug,
|
|
"name": tool.name,
|
|
"description": tool.description,
|
|
"input_schema": tool.input_schema,
|
|
}
|
|
if tool.output_schema:
|
|
entry["output_schema"] = tool.output_schema
|
|
preload = _serialized_preload_value(
|
|
tool.preload,
|
|
inherited_preload=tk.preload,
|
|
default_preload=default_preload,
|
|
)
|
|
if preload is not None:
|
|
entry["preload"] = preload
|
|
toolkit_tools.append(entry)
|
|
toolkit_entry: CustomToolkitWireDefinition = {
|
|
"slug": tk.slug,
|
|
"name": tk.name,
|
|
"description": tk.description,
|
|
"tools": toolkit_tools,
|
|
}
|
|
preload = _serialized_preload_value(
|
|
tk.preload, inherited_preload=None, default_preload=default_preload
|
|
)
|
|
if preload is not None:
|
|
toolkit_entry["preload"] = preload
|
|
result.append(toolkit_entry)
|
|
return result
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
# Routing map builders
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def build_custom_tools_map(
|
|
tools: t.List[CustomTool],
|
|
toolkits: t.Optional[t.List[ExperimentalToolkit]] = None,
|
|
) -> CustomToolsMap:
|
|
"""Build a CustomToolsMap from custom tools and toolkits."""
|
|
by_final_slug: t.Dict[str, CustomToolsMapEntry] = {}
|
|
by_original_slug: t.Dict[str, CustomToolsMapEntry] = {}
|
|
|
|
def add_entry(
|
|
handle: CustomTool, final_slug: str, toolkit: t.Optional[str]
|
|
) -> None:
|
|
original_slug = handle.slug.upper()
|
|
# Custom tool slugs are matched case-insensitively across local and response maps.
|
|
final_slug_key = final_slug.upper()
|
|
|
|
if len(final_slug) > MAX_SLUG_LENGTH:
|
|
raise ValidationError(
|
|
f'Custom tool slug "{handle.slug}" produces final slug '
|
|
f'"{final_slug}" which exceeds {MAX_SLUG_LENGTH} characters.'
|
|
)
|
|
|
|
if final_slug_key in by_final_slug:
|
|
raise ValidationError(
|
|
f'Custom tool slug collision: "{final_slug}" is already registered.'
|
|
)
|
|
|
|
if original_slug in by_original_slug:
|
|
existing = by_original_slug[original_slug]
|
|
raise ValidationError(
|
|
f'Custom tool slug collision: original slug "{handle.slug}" '
|
|
f"maps to multiple final slugs. "
|
|
f'"{existing.final_slug}" and "{final_slug}" both resolve '
|
|
f'from "{original_slug}".'
|
|
)
|
|
|
|
entry = CustomToolsMapEntry(
|
|
handle=handle, final_slug=final_slug, toolkit=toolkit
|
|
)
|
|
by_final_slug[final_slug_key] = entry
|
|
by_original_slug[original_slug] = entry
|
|
|
|
# Process standalone tools
|
|
for handle in tools:
|
|
add_entry(
|
|
handle,
|
|
_build_final_slug(handle.slug, handle.extends_toolkit),
|
|
handle.extends_toolkit,
|
|
)
|
|
|
|
# Process toolkit tools
|
|
if toolkits:
|
|
for tk in toolkits:
|
|
for handle in tk.tools:
|
|
add_entry(handle, _build_final_slug(handle.slug, tk.slug), tk.slug)
|
|
|
|
return CustomToolsMap(
|
|
by_final_slug=by_final_slug,
|
|
by_original_slug=by_original_slug,
|
|
toolkits=list(toolkits) if toolkits else None,
|
|
tools=list(tools) if tools else None,
|
|
)
|
|
|
|
|
|
def build_custom_tools_map_from_response(
|
|
tools: t.List[CustomTool],
|
|
toolkits: t.Optional[t.List[ExperimentalToolkit]],
|
|
experimental: t.Optional[
|
|
t.Union[
|
|
"SessionAttachResponseExperimental",
|
|
"SessionCreateResponseExperimental",
|
|
"SessionRetrieveResponseExperimental",
|
|
]
|
|
],
|
|
) -> CustomToolsMap:
|
|
"""Build a CustomToolsMap using the slug/original_slug mapping from the backend response."""
|
|
by_final_slug: t.Dict[str, CustomToolsMapEntry] = {}
|
|
by_original_slug: t.Dict[str, CustomToolsMapEntry] = {}
|
|
|
|
# Build lookup from original slug -> handle + toolkit
|
|
handles_by_original: t.Dict[str, t.Tuple[CustomTool, t.Optional[str]]] = {}
|
|
for handle in tools:
|
|
key = handle.slug.upper()
|
|
if key in handles_by_original:
|
|
raise ValidationError(
|
|
f'Duplicate custom tool slug "{handle.slug}" — '
|
|
f"each tool must have a unique slug across all custom tools and toolkits."
|
|
)
|
|
handles_by_original[key] = (handle, handle.extends_toolkit)
|
|
if toolkits:
|
|
for tk in toolkits:
|
|
for handle in tk.tools:
|
|
key = handle.slug.upper()
|
|
if key in handles_by_original:
|
|
raise ValidationError(
|
|
f'Duplicate custom tool slug "{handle.slug}" — '
|
|
f"each tool must have a unique slug across all custom tools and toolkits."
|
|
)
|
|
handles_by_original[key] = (handle, tk.slug)
|
|
|
|
def add_entry(
|
|
final_slug: str, original_slug: str, toolkit: t.Optional[str]
|
|
) -> None:
|
|
match = handles_by_original.get(original_slug.upper())
|
|
if not match:
|
|
return
|
|
handle, default_toolkit = match
|
|
resolved_toolkit = toolkit if toolkit is not None else default_toolkit
|
|
entry = CustomToolsMapEntry(
|
|
handle=handle, final_slug=final_slug, toolkit=resolved_toolkit
|
|
)
|
|
by_final_slug[final_slug.upper()] = entry
|
|
by_original_slug[original_slug.upper()] = entry
|
|
|
|
if experimental and experimental.custom_tools:
|
|
for ct in experimental.custom_tools:
|
|
add_entry(ct.slug, ct.original_slug, ct.extends_toolkit)
|
|
|
|
if experimental and experimental.custom_toolkits:
|
|
for ctk in experimental.custom_toolkits:
|
|
for ctk_tool in ctk.tools:
|
|
add_entry(ctk_tool.slug, ctk_tool.original_slug, ctk.slug)
|
|
|
|
return CustomToolsMap(
|
|
by_final_slug=by_final_slug,
|
|
by_original_slug=by_original_slug,
|
|
toolkits=list(toolkits) if toolkits else None,
|
|
tools=list(tools) if tools else None,
|
|
)
|
|
|
|
|
|
def find_custom_tool_map_entry_by_final_slug(
|
|
custom_tools_map: t.Optional[CustomToolsMap],
|
|
slug: str,
|
|
) -> t.Optional[CustomToolsMapEntry]:
|
|
"""Find a custom tool entry by final slug only."""
|
|
if custom_tools_map is None:
|
|
return None
|
|
return custom_tools_map.by_final_slug.get(slug.upper())
|
|
|
|
|
|
def assert_no_custom_tool_slugs_in_preload(
|
|
preload_tools: t.Union[t.Sequence[str], t.Literal["all"], None],
|
|
custom_tools_map: t.Optional[CustomToolsMap],
|
|
) -> None:
|
|
"""Reject legacy top-level preload of custom tool slugs."""
|
|
if preload_tools is None or preload_tools == PRELOAD_TOOLS_ALL:
|
|
return
|
|
if isinstance(preload_tools, str):
|
|
raise ValidationError(
|
|
'preload.tools must be a list of Composio tool slugs or "all". '
|
|
"Set preload=True on the SDK custom tool or custom toolkit "
|
|
"definition to expose custom tools directly."
|
|
)
|
|
|
|
custom_preload_slugs = []
|
|
for slug in preload_tools:
|
|
normalized = slug.upper()
|
|
if normalized.startswith(LOCAL_TOOL_PREFIX) or (
|
|
# Top-level preload.tools is only for Composio-managed tool slugs.
|
|
# Custom tools use preload=True on their SDK definitions instead.
|
|
custom_tools_map is not None
|
|
and (
|
|
normalized in custom_tools_map.by_original_slug
|
|
or normalized in custom_tools_map.by_final_slug
|
|
)
|
|
):
|
|
custom_preload_slugs.append(slug)
|
|
|
|
if custom_preload_slugs:
|
|
raise ValidationError(
|
|
"Custom tool slugs are not supported in preload.tools: "
|
|
f"{', '.join(custom_preload_slugs)}. Set preload=True on the SDK "
|
|
"custom tool or custom toolkit definition instead."
|
|
)
|
|
|
|
|
|
def get_preloaded_custom_tool_slugs(
|
|
custom_tools_map: t.Optional[CustomToolsMap],
|
|
*,
|
|
default_preload: bool = False,
|
|
) -> t.List[str]:
|
|
"""Return final custom tool slugs selected locally for preload."""
|
|
if custom_tools_map is None:
|
|
return []
|
|
|
|
seen: t.Set[str] = set()
|
|
custom_tool_slugs: t.List[str] = []
|
|
|
|
for entry in custom_tools_map.by_final_slug.values():
|
|
toolkit = next(
|
|
(
|
|
tk
|
|
for tk in custom_tools_map.toolkits or []
|
|
if entry.toolkit and tk.slug.lower() == entry.toolkit.lower()
|
|
),
|
|
None,
|
|
)
|
|
should_preload = (
|
|
entry.handle.preload
|
|
if entry.handle.preload is not None
|
|
else toolkit.preload
|
|
if toolkit is not None and toolkit.preload is not None
|
|
else default_preload
|
|
)
|
|
if not should_preload:
|
|
continue
|
|
|
|
final_slug_key = entry.final_slug.upper()
|
|
if final_slug_key in seen:
|
|
continue
|
|
|
|
seen.add(final_slug_key)
|
|
custom_tool_slugs.append(entry.final_slug)
|
|
|
|
return custom_tool_slugs
|