chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
"""Adapter that turns ``@tool``-decorated functions into a ToolHandler.
|
||||
|
||||
The stream-layer ``ToolHandler`` takes a list of OpenAI-shape JSON
|
||||
schemas and a single ``execute`` callable. Users who have written
|
||||
tools with the ``@tool`` decorator (Python functions with type hints
|
||||
and Google-style docstrings) shouldn't have to hand-roll that shape:
|
||||
:func:`build_tool_handler` reads each function's tool metadata and
|
||||
builds the handler for them.
|
||||
|
||||
Dispatch is by tool name. Calling an unknown tool raises — the SDK
|
||||
surfaces the error back to the agent as a tool error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from .._tool_handler import ToolCallInfo, ToolHandler
|
||||
from ._decorator import TOOL_MARKER_ATTR, ToolMetadata
|
||||
|
||||
|
||||
def build_tool_handler(functions: list[Callable[..., Any]]) -> ToolHandler:
|
||||
"""Build a :class:`ToolHandler` from ``@tool``-decorated functions.
|
||||
|
||||
Each function must carry tool metadata attached by the
|
||||
:func:`~omnigent_client.tool` decorator (checked via
|
||||
:data:`TOOL_MARKER_ATTR`). The returned handler exposes the
|
||||
OpenAI-shape schemas the SDK sends to the server, and an
|
||||
``execute`` callable that dispatches incoming tool calls by
|
||||
name.
|
||||
|
||||
:param functions: List of ``@tool``-decorated Python functions,
|
||||
e.g. ``[get_current_time, search_docs]``. Each must be a
|
||||
module-level ``def`` or ``async def`` decorated with
|
||||
``@tool``.
|
||||
:returns: A :class:`ToolHandler` ready to pass as
|
||||
``session.tool_handler`` or via the ``tools=`` keyword on
|
||||
``OmnigentClient.query`` / ``Session.query``.
|
||||
:raises TypeError: If any function is missing the ``@tool``
|
||||
marker (i.e. wasn't decorated).
|
||||
:raises ValueError: If two functions share the same tool name
|
||||
— tool names must be unique per handler.
|
||||
"""
|
||||
if not functions:
|
||||
raise ValueError("build_tool_handler() requires at least one function")
|
||||
|
||||
schemas: list[dict[str, object]] = []
|
||||
funcs_by_name: dict[str, Callable[..., Any]] = {}
|
||||
|
||||
for fn in functions:
|
||||
meta: ToolMetadata | None = getattr(fn, TOOL_MARKER_ATTR, None)
|
||||
if meta is None:
|
||||
raise TypeError(
|
||||
f"{fn.__module__}.{fn.__qualname__} is not decorated with "
|
||||
f"@tool. Decorate it with `from omnigent_client import tool` "
|
||||
f"and apply @tool above the function definition."
|
||||
)
|
||||
if meta.name in funcs_by_name:
|
||||
raise ValueError(
|
||||
f"Duplicate tool name {meta.name!r}: "
|
||||
f"{funcs_by_name[meta.name].__qualname__} and "
|
||||
f"{fn.__qualname__} both export the same name."
|
||||
)
|
||||
funcs_by_name[meta.name] = fn
|
||||
schema: dict[str, object] = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": meta.name,
|
||||
"description": meta.description,
|
||||
"parameters": meta.json_schema,
|
||||
},
|
||||
}
|
||||
schemas.append(schema)
|
||||
|
||||
async def execute(call: ToolCallInfo) -> str:
|
||||
"""Dispatch ``call`` to the matching ``@tool`` function.
|
||||
|
||||
Async functions (``async def``) are awaited on the
|
||||
event loop. Sync functions (``def``) are dispatched to
|
||||
a worker thread via ``asyncio.to_thread`` so blocking
|
||||
calls inside — ``time.sleep``, file I/O, subprocess,
|
||||
``requests`` — don't stall the event loop. Without the
|
||||
thread bounce, several concurrent ``@tool`` invocations
|
||||
(e.g. a parallel fan-out of async client tools) would
|
||||
serialize: each body would block every sibling AND any
|
||||
caller render loop sharing the loop.
|
||||
|
||||
The return value is JSON-serialized unless the function
|
||||
already returned a string (which is passed through).
|
||||
"""
|
||||
fn = funcs_by_name.get(call.name)
|
||||
if fn is None:
|
||||
# The SDK will surface this back to the agent as a tool
|
||||
# error — this typically means the LLM invented a tool
|
||||
# name that wasn't in the schemas we sent.
|
||||
raise KeyError(f"Unknown tool {call.name!r}. Registered: {sorted(funcs_by_name)}")
|
||||
if inspect.iscoroutinefunction(fn):
|
||||
result = await fn(**call.arguments)
|
||||
else:
|
||||
# Sync body — route to a worker thread so it
|
||||
# doesn't block the event loop (see the fan-out
|
||||
# serialization case above).
|
||||
result = await asyncio.to_thread(lambda: fn(**call.arguments))
|
||||
if isinstance(result, str):
|
||||
return result
|
||||
# Pydantic models and dataclasses commonly aren't JSON-ready
|
||||
# out of the box — ``default=str`` handles datetime/UUID/etc.
|
||||
return json.dumps(result, default=str)
|
||||
|
||||
return ToolHandler(schemas=schemas, execute=execute)
|
||||
Reference in New Issue
Block a user