Files
composiohq--composio/python/composio/core/models/custom_tool.py
T
2026-07-13 12:38:34 +08:00

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