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

715 lines
20 KiB
Python

from __future__ import annotations
import functools
import typing as t
import typing_extensions as te
if t.TYPE_CHECKING:
from .tools import Tool, ToolExecutionResponse, tool_execute_params
# TODO: Maybe use `te.Unpack` in tools.execute?
class ToolExecuteParams(te.TypedDict):
allow_tracing: te.NotRequired[t.Optional[bool]]
arguments: t.Dict[str, t.Optional[t.Any]]
connected_account_id: te.NotRequired[str]
custom_auth_params: te.NotRequired["tool_execute_params.CustomAuthParams"]
custom_connection_data: te.NotRequired["tool_execute_params.CustomConnectionData"]
entity_id: te.NotRequired[str]
text: te.NotRequired[str]
user_id: te.NotRequired[str]
version: te.NotRequired[str]
dangerously_skip_version_check: te.NotRequired[t.Optional[bool]]
ModifierInOut = t.Union["ToolExecuteParams", "ToolExecutionResponse", "Tool"]
class BeforeExecute(t.Protocol):
"""
A modifier that is called before the tool is executed.
"""
def __call__(
self,
tool: str,
toolkit: str,
params: ToolExecuteParams,
) -> ToolExecuteParams: ...
class AfterExecute(t.Protocol):
"""
A modifier that is called after the tool is executed.
"""
def __call__(
self,
tool: str,
toolkit: str,
response: ToolExecutionResponse,
) -> ToolExecutionResponse: ...
class SchemaModifier(t.Protocol):
"""
A modifier that is called to modify the schema of the tool.
"""
def __call__(
self,
tool: str,
toolkit: str,
schema: "Tool",
) -> "Tool": ...
class BeforeFileUploadCallable(t.Protocol):
"""Legacy positional form of the ``before_file_upload`` hook.
``(path, tool, toolkit) -> str | bool``. Still supported for back-compat,
but new code should use :class:`BeforeFileUploadContextCallable` so it can
discriminate local paths from URLs via ``context["source"]``.
"""
def __call__(
self,
path: str,
tool: str,
toolkit: str,
) -> t.Union[str, bool]: ...
class BeforeFileUploadContext(te.TypedDict):
"""Context passed to the new-form ``before_file_upload`` hook.
- ``path``: the local filesystem path for ``source="path"``, or the URL
string for ``source="url"``.
- ``source``: discriminator — ``"path"`` for local paths, ``"url"`` for
``http(s)://...`` inputs. Mirrors the TypeScript SDK's ``source`` field
(TS additionally emits ``"file"`` for ``File`` objects; Python has no
equivalent runtime type).
- ``tool`` / ``toolkit``: slugs of the tool being executed.
"""
path: str
source: te.Literal["path", "url"]
tool: str
toolkit: str
class BeforeFileUploadContextCallable(t.Protocol):
"""Preferred form of the ``before_file_upload`` hook.
Takes a single :class:`BeforeFileUploadContext` argument and returns either
a new path/URL string, or ``False`` to abort the upload.
"""
def __call__(
self,
context: BeforeFileUploadContext,
) -> t.Union[str, bool]: ...
BeforeFileUploadLike = t.Union[
BeforeFileUploadCallable,
BeforeFileUploadContextCallable,
]
"""Either form of ``before_file_upload``. Adapted internally."""
def _count_positional_params(fn: t.Callable) -> int:
"""Return the number of positional (or positional-or-keyword) params, or
-1 if the signature can't be introspected (e.g. builtins)."""
import inspect
try:
sig = inspect.signature(fn)
except (TypeError, ValueError):
return -1
return sum(
1
for p in sig.parameters.values()
if p.kind
in (
inspect.Parameter.POSITIONAL_ONLY,
inspect.Parameter.POSITIONAL_OR_KEYWORD,
)
)
def _adapt_before_file_upload(
hook: BeforeFileUploadLike,
) -> BeforeFileUploadContextCallable:
"""Normalise a user-supplied hook to the context-object form.
A hook declared with exactly 3 positional parameters is treated as the
legacy ``(path, tool, toolkit)`` form; anything else (typically a single
positional ``context`` parameter) is treated as the new form.
"""
if _count_positional_params(hook) == 3:
legacy = t.cast(BeforeFileUploadCallable, hook)
def wrap(context: BeforeFileUploadContext) -> t.Union[str, bool]:
return legacy(context["path"], context["tool"], context["toolkit"])
return wrap
return t.cast(BeforeFileUploadContextCallable, hook)
ModifierSlug: t.TypeAlias = str
AfterExecuteModifierL: t.TypeAlias = t.Literal["after_execute"]
BeforeExecuteModifierL: t.TypeAlias = t.Literal["before_execute"]
SchemaModifierL: t.TypeAlias = t.Literal["schema"]
BeforeFileUploadModifierL: t.TypeAlias = t.Literal["before_file_upload"]
class Modifier:
def __init__(
self,
modifier: t.Optional[
AfterExecute
| BeforeExecute
| SchemaModifier
| BeforeExecuteMeta
| AfterExecuteMeta
| BeforeFileUploadCallable
| BeforeFileUploadContextCallable
],
type_: (
AfterExecuteModifierL
| BeforeExecuteModifierL
| SchemaModifierL
| AfterExecuteMetaModifierL
| BeforeExecuteMetaModifierL
| BeforeFileUploadModifierL
),
tools: t.List[str],
toolkits: t.List[str],
) -> None:
self.modifier = modifier
self.tools = tools
self.type = type_
self.toolkits = toolkits
def apply(
self,
toolkit: str,
tool: str,
data: ModifierInOut,
modifer_type: str,
) -> ModifierInOut:
if self.modifier is None:
raise ValueError("Modifier is not provided")
# If no tools or toolkits are provided, apply the modifier to all tools
if (
self.type == modifer_type
and len(self.tools) == 0
and len(self.toolkits) == 0
):
return self.modifier(tool, toolkit, data) # type: ignore
# If the modifier is not the same type, or the slug is not in the tools or
# toolkits, return the data as is
if (
self.type != modifer_type
or tool not in self.tools
and toolkit not in self.toolkits
):
return data
# Apply the modifier to the data
return self.modifier(tool, toolkit, data) # type: ignore
@t.overload
def after_execute(
modifier: t.Optional[AfterExecute],
) -> Modifier: ...
@t.overload
def after_execute(
*,
tools: t.Optional[t.List[str]] = None,
toolkits: t.Optional[t.List[str]] = None,
) -> t.Callable[[AfterExecute], Modifier]: ...
def after_execute(
modifier: t.Optional[AfterExecute] = None,
*,
tools: t.Optional[t.List[str]] = None,
toolkits: t.Optional[t.List[str]] = None,
) -> Modifier | t.Callable[[AfterExecute], Modifier]:
if modifier is not None:
return Modifier(
modifier=modifier,
type_="after_execute",
tools=tools or [],
toolkits=toolkits or [],
)
if tools is not None or toolkits is not None:
return t.cast(
t.Callable[[AfterExecute], Modifier],
functools.partial(
after_execute,
tools=tools or [],
toolkits=toolkits or [],
),
)
raise ValueError("Either tools or toolkits must be provided")
@t.overload
def before_execute(modifier: t.Optional[BeforeExecute]) -> Modifier: ...
@t.overload
def before_execute(
*,
tools: t.Optional[t.List[str]] = None,
toolkits: t.Optional[t.List[str]] = None,
) -> t.Callable[[BeforeExecute], Modifier]: ...
def before_execute(
modifier: t.Optional[BeforeExecute] = None,
*,
tools: t.Optional[t.List[str]] = None,
toolkits: t.Optional[t.List[str]] = None,
) -> Modifier | t.Callable[[BeforeExecute], Modifier]:
if modifier is not None:
return Modifier(
modifier=modifier,
type_="before_execute",
tools=tools or [],
toolkits=toolkits or [],
)
if tools is not None or toolkits is not None:
return t.cast(
t.Callable[[BeforeExecute], Modifier],
functools.partial(
before_execute,
tools=tools or [],
toolkits=toolkits or [],
),
)
raise ValueError("Either tools or toolkits must be provided")
@t.overload
def before_file_upload(modifier: t.Optional[BeforeFileUploadLike]) -> Modifier: ...
@t.overload
def before_file_upload(
*,
tools: t.Optional[t.List[str]] = None,
toolkits: t.Optional[t.List[str]] = None,
) -> t.Callable[[BeforeFileUploadLike], Modifier]: ...
def before_file_upload(
modifier: t.Optional[BeforeFileUploadLike] = None,
*,
tools: t.Optional[t.List[str]] = None,
toolkits: t.Optional[t.List[str]] = None,
) -> Modifier | t.Callable[[BeforeFileUploadLike], Modifier]:
"""
Build a ``Modifier`` for the file-upload hook (same scoping pattern as
:func:`before_execute`).
Your callable may take **either**:
- a single ``context`` argument (:class:`BeforeFileUploadContext`) — the
preferred form, exposes ``context["source"]`` (``"path"`` or ``"url"``),
or
- three positional arguments ``(path, tool, toolkit)`` — legacy form, kept
for back-compat.
Return a new path/URL string to substitute, or ``False`` to abort the
upload (raises :class:`~composio.exceptions.FileUploadAbortedError`).
Pass the returned ``Modifier`` in ``modifiers=[...]`` on
:meth:`composio.core.models.tools.Tools.execute` or ``tools.get``. Multiple
such modifiers are composed in list order.
"""
if modifier is not None:
return Modifier(
modifier=modifier,
type_="before_file_upload",
tools=tools or [],
toolkits=toolkits or [],
)
if tools is not None or toolkits is not None:
return t.cast(
t.Callable[[BeforeFileUploadLike], Modifier],
functools.partial(
before_file_upload,
tools=tools or [],
toolkits=toolkits or [],
),
)
raise ValueError("Either tools or toolkits must be provided")
@t.overload
def schema_modifier(modifier: t.Optional[SchemaModifier]) -> Modifier: ...
@t.overload
def schema_modifier(
*,
tools: t.Optional[t.List[str]] = None,
toolkits: t.Optional[t.List[str]] = None,
) -> t.Callable[[SchemaModifier], Modifier]: ...
def schema_modifier(
modifier: t.Optional[SchemaModifier] = None,
*,
tools: t.Optional[t.List[str]] = None,
toolkits: t.Optional[t.List[str]] = None,
) -> Modifier | t.Callable[[SchemaModifier], Modifier]:
if modifier is not None:
return Modifier(
modifier=modifier,
type_="schema",
tools=tools or [],
toolkits=toolkits or [],
)
if tools is not None or toolkits is not None:
return t.cast(
t.Callable[[SchemaModifier], Modifier],
functools.partial(
schema_modifier,
tools=tools or [],
toolkits=toolkits or [],
),
)
raise ValueError("Either tools or toolkits must be provided")
@t.overload
def before_execute_meta(modifier: t.Optional[BeforeExecuteMeta]) -> Modifier: ...
@t.overload
def before_execute_meta(
*,
tools: t.Optional[t.List[str]] = None,
toolkits: t.Optional[t.List[str]] = None,
) -> t.Callable[[BeforeExecuteMeta], Modifier]: ...
def before_execute_meta(
modifier: t.Optional[BeforeExecuteMeta] = None,
*,
tools: t.Optional[t.List[str]] = None,
toolkits: t.Optional[t.List[str]] = None,
) -> Modifier | t.Callable[[BeforeExecuteMeta], Modifier]:
if modifier is not None:
return Modifier(
modifier=modifier,
type_="before_execute_meta",
tools=tools or [],
toolkits=toolkits or [],
)
if tools is not None or toolkits is not None:
return t.cast(
t.Callable[[BeforeExecuteMeta], Modifier],
functools.partial(
before_execute_meta,
tools=tools or [],
toolkits=toolkits or [],
),
)
raise ValueError("Either tools or toolkits must be provided")
@t.overload
def after_execute_meta(modifier: t.Optional[AfterExecuteMeta]) -> Modifier: ...
@t.overload
def after_execute_meta(
*,
tools: t.Optional[t.List[str]] = None,
toolkits: t.Optional[t.List[str]] = None,
) -> t.Callable[[AfterExecuteMeta], Modifier]: ...
def after_execute_meta(
modifier: t.Optional[AfterExecuteMeta] = None,
*,
tools: t.Optional[t.List[str]] = None,
toolkits: t.Optional[t.List[str]] = None,
) -> Modifier | t.Callable[[AfterExecuteMeta], Modifier]:
if modifier is not None:
return Modifier(
modifier=modifier,
type_="after_execute_meta",
tools=tools or [],
toolkits=toolkits or [],
)
if tools is not None or toolkits is not None:
return t.cast(
t.Callable[[AfterExecuteMeta], Modifier],
functools.partial(
after_execute_meta,
tools=tools or [],
toolkits=toolkits or [],
),
)
raise ValueError("Either tools or toolkits must be provided")
Modifiers = t.List[Modifier]
def merge_before_file_upload(
modifiers: t.Optional[Modifiers],
tool: str,
toolkit: str,
) -> t.Optional[BeforeFileUploadContextCallable]:
"""Compose ``before_file_upload``-type :class:`Modifier`\\ s for this *tool* / *toolkit*.
Scoping matches :class:`Modifier` (empty ``tools`` and ``toolkits`` = all tools).
Each user-supplied hook is adapted to the context form by
:func:`_adapt_before_file_upload`, so legacy 3-arg callables keep working
while new-form callables receive the full :class:`BeforeFileUploadContext`
(including ``source``).
"""
to_chain = [
m
for m in (modifiers or [])
if m.type == "before_file_upload" and m.modifier is not None
]
if not to_chain:
return None
def _applies(m: Modifier) -> bool:
if len(m.tools) == 0 and len(m.toolkits) == 0:
return True
return tool in m.tools or toolkit in m.toolkits
adapted_chain = [
(m, _adapt_before_file_upload(t.cast(BeforeFileUploadLike, m.modifier)))
for m in to_chain
]
def combined(context: BeforeFileUploadContext) -> t.Union[str, bool]:
p: str = context["path"]
for m, hook in adapted_chain:
if not _applies(m):
continue
# Preserve source/tool/toolkit while forwarding the (possibly
# rewritten) path to the next hook.
next_ctx: BeforeFileUploadContext = {
"path": p,
"source": context["source"],
"tool": context["tool"],
"toolkit": context["toolkit"],
}
out = hook(next_ctx)
if out is False:
return False
if isinstance(out, str):
p = out
return p
return combined
@t.overload
def apply_modifier_by_type(
modifiers: Modifiers,
toolkit: str,
tool: str,
*,
type: BeforeExecuteModifierL,
request: ToolExecuteParams,
) -> ToolExecuteParams: ...
@t.overload
def apply_modifier_by_type(
modifiers: Modifiers,
toolkit: str,
tool: str,
*,
type: AfterExecuteModifierL,
response: "ToolExecutionResponse",
) -> "ToolExecutionResponse": ...
@t.overload
def apply_modifier_by_type(
modifiers: Modifiers,
toolkit: str,
tool: str,
*,
type: t.Literal["schema"],
schema: "Tool",
) -> "Tool": ...
@t.overload
def apply_modifier_by_type(
modifiers: Modifiers,
toolkit: str,
tool: str,
*,
type: BeforeExecuteMetaModifierL,
session_id: str,
params: t.Dict[str, t.Any],
) -> t.Dict[str, t.Any]: ...
@t.overload
def apply_modifier_by_type(
modifiers: Modifiers,
toolkit: str,
tool: str,
*,
type: AfterExecuteMetaModifierL,
session_id: str,
response: "ToolExecutionResponse",
) -> "ToolExecutionResponse": ...
def apply_modifier_by_type(
modifiers: Modifiers,
toolkit: str,
tool: str,
*,
type: t.Literal[
"before_execute",
"after_execute",
"schema",
"before_execute_meta",
"after_execute_meta",
],
schema: t.Optional["Tool"] = None,
request: t.Optional["ToolExecuteParams"] = None,
response: t.Optional["ToolExecutionResponse"] = None,
session_id: t.Optional[str] = None,
params: t.Optional[t.Dict[str, t.Any]] = None,
) -> t.Union[ModifierInOut, t.Dict[str, t.Any]]:
"""Apply a modifier to a tool."""
# For meta modifiers, we handle them differently
if type in ("before_execute_meta", "after_execute_meta"):
if session_id is None:
raise ValueError("session_id is required for meta modifiers")
if type == "before_execute_meta":
if params is None:
raise ValueError("params is required for before_execute_meta")
result_params: t.Dict[str, t.Any] = params
for modifier in modifiers:
if modifier.type == type:
# Check if modifier should be applied
should_apply = (
(len(modifier.tools) == 0 and len(modifier.toolkits) == 0)
or tool in modifier.tools
or toolkit in modifier.toolkits
)
if should_apply and modifier.modifier is not None:
result_params = t.cast(BeforeExecuteMeta, modifier.modifier)(
tool, toolkit, session_id, result_params
)
return result_params
else: # after_execute_meta
if response is None:
raise ValueError("response is required for after_execute_meta")
result_response: "ToolExecutionResponse" = response
for modifier in modifiers:
if modifier.type == type:
# Check if modifier should be applied
should_apply = (
(len(modifier.tools) == 0 and len(modifier.toolkits) == 0)
or tool in modifier.tools
or toolkit in modifier.toolkits
)
if should_apply and modifier.modifier is not None:
result_response = t.cast(AfterExecuteMeta, modifier.modifier)(
tool, toolkit, session_id, result_response
)
return result_response
# For regular modifiers
result: ModifierInOut
if schema is not None:
result = schema
elif request is not None:
result = request
elif response is not None:
result = response
else:
raise ValueError("No data provided")
for modifier in modifiers:
result = modifier.apply(
toolkit=toolkit,
tool=tool,
data=result,
modifer_type=type,
)
return result
class BeforeExecuteMeta(t.Protocol):
"""
A modifier that is called before the meta tool is executed in a session context.
"""
def __call__(
self,
tool: str,
toolkit: str,
session_id: str,
params: t.Dict[str, t.Any],
) -> t.Dict[str, t.Any]: ...
class AfterExecuteMeta(t.Protocol):
"""
A modifier that is called after the meta tool is executed in a session context.
"""
def __call__(
self,
tool: str,
toolkit: str,
session_id: str,
response: ToolExecutionResponse,
) -> ToolExecutionResponse: ...
AfterExecuteMetaModifierL: t.TypeAlias = t.Literal["after_execute_meta"]
BeforeExecuteMetaModifierL: t.TypeAlias = t.Literal["before_execute_meta"]
class ToolOptions(te.TypedDict):
modify_schema: te.NotRequired[
t.Dict[ModifierSlug, AfterExecute | BeforeExecute | SchemaModifier]
]