Files
2026-07-13 13:39:38 +08:00

679 lines
22 KiB
Python

# Copyright 2023 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
import functools
import inspect
import itertools
from abc import ABC, abstractmethod
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass
from enum import Flag, auto
from typing import (
TYPE_CHECKING,
Annotated,
Any,
Generic,
Literal,
TypeGuard,
TypeVar,
get_type_hints,
overload,
)
from pydantic import Field
from typing_extensions import NotRequired, ParamSpec, Required, Self, TypedDict
from ..log import logger
from . import _provider_format
if TYPE_CHECKING:
from ..voice.events import RunContext
class Tool(ABC):
@property
@abstractmethod
def id(self) -> str: ...
class ProviderTool(Tool):
def __init__(self, *, id: str) -> None:
self._id = id
@property
def id(self) -> str:
return self._id
class Toolset:
@dataclass
class ToolCalledEvent:
ctx: RunContext
arguments: dict[str, Any]
@dataclass
class ToolCompletedEvent:
ctx: RunContext
output: Any | Exception | None
def __init__(self, *, id: str, tools: Sequence[Tool | Toolset] | None = None) -> None:
self._id = id
self._tools: Sequence[Tool | Toolset] = list(tools) if tools is not None else []
self._tools.extend(find_function_tools(self))
@property
def id(self) -> str:
return self._id
@property
def tools(self) -> Sequence[Tool | Toolset]:
return self._tools
async def setup(self) -> Self:
"""Initialize the toolset and any nested toolsets.
Called automatically by ``AgentActivity`` when an agent starts.
"""
toolsets = [tool for tool in self.tools if isinstance(tool, Toolset)]
if toolsets:
await asyncio.gather(*(toolset.setup() for toolset in toolsets))
return self
async def aclose(self) -> None:
"""Close the toolset and release any held resources.
Agent-scoped toolsets (passed to ``Agent(tools=...)``) are closed when the
``AgentActivity`` ends (on agent transition or session close). Session-scoped
toolsets (passed to ``AgentSession(tools=...)``) are closed only when the
``AgentSession`` shuts down.
"""
toolsets = [tool for tool in self._tools if isinstance(tool, Toolset)]
if toolsets:
await asyncio.gather(*(toolset.aclose() for toolset in toolsets))
# Used by ToolChoice
class Function(TypedDict, total=False):
name: Required[str]
class NamedToolChoice(TypedDict, total=False):
type: Required[Literal["function"]]
function: Required[Function]
ToolChoice = NamedToolChoice | Literal["auto", "required", "none"]
class ToolError(Exception):
def __init__(self, message: str) -> None:
"""
Exception raised within AI functions.
This exception should be raised by users when an error occurs
in the context of AI operations. The provided message will be
visible to the LLM, allowing it to understand the context of
the error during FunctionOutput generation.
"""
super().__init__(message)
self._message = message
@property
def message(self) -> str:
return self._message
class StopResponse(Exception):
def __init__(self) -> None:
"""
Exception raised within AI functions.
This exception can be raised by the user to indicate that
the agent should not generate a response for the current
function call.
"""
super().__init__()
class ToolFlag(Flag):
NONE = 0
IGNORE_ON_ENTER = auto()
CANCELLABLE = auto()
DuplicateMode = Literal["allow", "reject", "replace", "confirm"]
@dataclass
class FunctionToolInfo:
name: str
description: str | None
flags: ToolFlag
on_duplicate: DuplicateMode = "allow"
class RawFunctionDescription(TypedDict):
"""
Represents the raw function schema format used in LLM function calling APIs.
This structure directly maps to OpenAI's function definition format as documented at:
https://platform.openai.com/docs/guides/function-calling?api-mode=responses
It is also compatible with other LLM providers that support raw JSON Schema-based
function definitions.
"""
name: str
description: NotRequired[str | None]
parameters: dict[str, object]
@dataclass
class RawFunctionToolInfo:
name: str
raw_schema: dict[str, Any]
flags: ToolFlag
on_duplicate: DuplicateMode = "allow"
CONFIRM_DUPLICATE_PARAM = "lk_agents_confirm_duplicate"
"""Schema parameter added when ``@function_tool(on_duplicate='confirm')``."""
_CONFIRM_DUPLICATE_DESCRIPTION = (
"Set this to True to confirm you want to run a duplicate. "
"Only do this when user confirms the duplication is needed."
)
_InfoT = TypeVar("_InfoT", FunctionToolInfo, RawFunctionToolInfo)
_P = ParamSpec("_P")
_R = TypeVar("_R", bound=Awaitable[Any])
class _BaseFunctionTool(Tool, Generic[_InfoT, _P, _R]):
"""Base class for function tool wrappers with descriptor support."""
def __init__(self, func: Callable[_P, _R], info: _InfoT, instance: Any = None) -> None:
functools.update_wrapper(self, func)
self._func = func
self._info: _InfoT = info
self._instance = instance
@property
def id(self) -> str:
return self._info.name
@property
def info(self) -> _InfoT:
return self._info
def __get__(self, obj: Any, objtype: type | None = None) -> Self:
if obj is None:
return self
# bind the tool to an instance
bound_tool = self.__class__(self._func, self._info, instance=obj)
sig = inspect.signature(self._func)
# skip the instance parameter (e.g. usually the 'self')
params = list(sig.parameters.values())[1:]
bound_tool.__signature__ = sig.replace(parameters=params) # type: ignore[attr-defined]
return bound_tool
def __call__(self, *args: _P.args, **kwargs: _P.kwargs) -> _R:
if self._instance is not None:
return self._func(self._instance, *args, **kwargs)
return self._func(*args, **kwargs)
class FunctionTool(_BaseFunctionTool[FunctionToolInfo, _P, _R]):
"""Wrapper for a function decorated with @function_tool"""
def __init__(
self, func: Callable[_P, _R], info: FunctionToolInfo, instance: Any = None
) -> None:
super().__init__(func, info, instance)
setattr(self, "__livekit_tool_info", self._info)
class RawFunctionTool(_BaseFunctionTool[RawFunctionToolInfo, _P, _R]):
"""Wrapper for a function decorated with @function_tool(raw_schema=...)"""
def __init__(
self, func: Callable[_P, _R], info: RawFunctionToolInfo, instance: Any = None
) -> None:
super().__init__(func, info, instance)
setattr(self, "__livekit_raw_tool_info", self._info)
@overload
def function_tool(
f: Callable[_P, _R],
*,
raw_schema: RawFunctionDescription | dict[str, Any],
flags: ToolFlag = ToolFlag.NONE,
on_duplicate: DuplicateMode = "allow",
) -> RawFunctionTool[_P, _R]: ...
@overload
def function_tool(
f: None = None,
*,
raw_schema: RawFunctionDescription | dict[str, Any],
flags: ToolFlag = ToolFlag.NONE,
on_duplicate: DuplicateMode = "allow",
) -> Callable[[Callable[_P, _R]], RawFunctionTool[_P, _R]]: ...
@overload
def function_tool(
f: Callable[_P, _R],
*,
name: str | None = None,
description: str | None = None,
flags: ToolFlag = ToolFlag.NONE,
on_duplicate: DuplicateMode = "allow",
) -> FunctionTool[_P, _R]: ...
@overload
def function_tool(
f: None = None,
*,
name: str | None = None,
description: str | None = None,
flags: ToolFlag = ToolFlag.NONE,
on_duplicate: DuplicateMode = "allow",
) -> Callable[[Callable[_P, _R]], FunctionTool[_P, _R]]: ...
def function_tool(
f: Callable[_P, _R] | None = None,
*,
name: str | None = None,
description: str | None = None,
raw_schema: RawFunctionDescription | dict[str, Any] | None = None,
flags: ToolFlag = ToolFlag.NONE,
on_duplicate: DuplicateMode = "allow",
) -> (
FunctionTool[_P, _R]
| RawFunctionTool[_P, _R]
| Callable[[Callable[_P, _R]], FunctionTool[_P, _R] | RawFunctionTool[_P, _R]]
):
def deco_raw(
func: Callable[_P, _R],
) -> RawFunctionTool[_P, _R]:
assert raw_schema is not None
if not raw_schema.get("name"):
raise ValueError("raw function name cannot be empty")
if "parameters" not in raw_schema:
# support empty parameters
raise ValueError("raw function description must contain a parameters key")
schema = {**raw_schema}
if on_duplicate == "confirm":
schema["parameters"] = _inject_confirm_duplicate(schema["parameters"])
info = RawFunctionToolInfo(
name=raw_schema["name"],
raw_schema=schema,
flags=flags,
on_duplicate=on_duplicate,
)
return RawFunctionTool(func, info)
def deco_func(func: Callable[_P, _R]) -> FunctionTool[_P, _R]:
from docstring_parser import parse_from_object
wrapped: Callable[..., Any] = func
if on_duplicate == "confirm":
wrapped = _wrap_with_confirm_duplicate(func)
docstring = parse_from_object(func)
info = FunctionToolInfo(
name=name or func.__name__,
description=description or docstring.description,
flags=flags,
on_duplicate=on_duplicate,
)
return FunctionTool(wrapped, info)
if f is not None:
return deco_raw(f) if raw_schema is not None else deco_func(f)
return deco_raw if raw_schema is not None else deco_func
def _wrap_with_confirm_duplicate(func: Callable[..., Any]) -> Callable[..., Any]:
"""Extend ``func``'s signature with a CONFIRM_DUPLICATE_PARAM kwarg, stripped
by the wrapper before delegating so direct calls with the original args still work."""
try:
resolved = get_type_hints(func, include_extras=True)
except Exception:
resolved = dict(getattr(func, "__annotations__", {}))
annotation = Annotated[
bool | None, Field(default=False, description=_CONFIRM_DUPLICATE_DESCRIPTION)
]
new_annotations = {**resolved, CONFIRM_DUPLICATE_PARAM: annotation}
@functools.wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
kwargs.pop(CONFIRM_DUPLICATE_PARAM, None)
result = func(*args, **kwargs)
if asyncio.iscoroutine(result):
return await result
return result
sig = inspect.signature(func)
extra = inspect.Parameter(
CONFIRM_DUPLICATE_PARAM,
inspect.Parameter.KEYWORD_ONLY,
default=False,
annotation=annotation,
)
wrapper.__signature__ = sig.replace(parameters=[*sig.parameters.values(), extra]) # type: ignore[attr-defined]
# set both for PEP 649: __annotations__ for 3.10-3.13, __annotate__ for 3.14.
# __annotate__ must come last — assigning __annotations__ nulls it on 3.14.
wrapper.__annotations__ = new_annotations
wrapper.__annotate__ = lambda _format=1: dict(new_annotations) # type: ignore[attr-defined]
return wrapper
def _inject_confirm_duplicate(parameters: dict[str, Any]) -> dict[str, Any]:
"""Add CONFIRM_DUPLICATE_PARAM to a raw JSON-schema (strict-mode conformant)."""
params = {**parameters}
properties = {**params.get("properties", {})}
properties[CONFIRM_DUPLICATE_PARAM] = {
"type": ["boolean", "null"],
"description": _CONFIRM_DUPLICATE_DESCRIPTION,
}
params["properties"] = properties
required = list(params.get("required", []))
if CONFIRM_DUPLICATE_PARAM not in required:
required.append(CONFIRM_DUPLICATE_PARAM)
params["required"] = required
return params
def is_function_tool(f: Any) -> TypeGuard[FunctionTool]:
# TODO(long): for backward compatibility, deprecate in future versions?
return isinstance(f, FunctionTool)
def get_function_info(f: FunctionTool) -> FunctionToolInfo:
return f.info
def is_raw_function_tool(f: Any) -> TypeGuard[RawFunctionTool]:
return isinstance(f, RawFunctionTool)
def get_raw_function_info(f: RawFunctionTool) -> RawFunctionToolInfo:
return f.info
def _resolve_wrapped_tool(tool: Any) -> FunctionTool | RawFunctionTool | None:
"""Convert a wrapped tool to a FunctionTool or RawFunctionTool with a warning."""
if not callable(tool):
return None
if isinstance(tool, (FunctionTool, RawFunctionTool)):
return tool
resolved_tool: FunctionTool | RawFunctionTool | None = None
if (
hasattr(tool, "__wrapped__") # automatically added by functools.wraps
and isinstance(tool.__wrapped__, (FunctionTool, RawFunctionTool))
):
wrapped = tool.__wrapped__
resolved_tool = wrapped.__class__(tool, wrapped.info) # type: ignore
elif (info := getattr(tool, "__livekit_tool_info", None)) and isinstance(
info, FunctionToolInfo
):
resolved_tool = FunctionTool(tool, info)
elif (info := getattr(tool, "__livekit_raw_tool_info", None)) and isinstance(
info, RawFunctionToolInfo
):
resolved_tool = RawFunctionTool(tool, info)
if resolved_tool:
tool_name = resolved_tool.info.name
logger.warning(
f"function tool {tool_name} is wrapped, this may cause unexpected behavior and not be supported in future versions, "
"please wrap the original function before converting to a function tool.",
extra={
"function_tool": tool_name,
},
)
return resolved_tool
def find_function_tools(cls_or_obj: Any) -> list[FunctionTool | RawFunctionTool]:
methods: list[FunctionTool | RawFunctionTool] = []
for _, member in inspect.getmembers(cls_or_obj):
if isinstance(member, (FunctionTool, RawFunctionTool)):
methods.append(member)
elif normalized_tool := _resolve_wrapped_tool(member):
methods.append(normalized_tool)
return methods
def get_fnc_tool_names(tools: Sequence[Tool | Toolset]) -> list[str]:
"""Get names of all function and raw function tools in the list, unwrapping tool sets."""
names = []
for tool in tools:
if isinstance(tool, (FunctionTool, RawFunctionTool)):
names.append(tool.info.name)
elif isinstance(tool, Toolset):
names.extend(get_fnc_tool_names(tool.tools))
return names
class ToolContext:
"""Stateless container for a set of AI functions"""
def __init__(self, tools: Sequence[Tool | Toolset]) -> None:
self.update_tools(tools)
@classmethod
def empty(cls) -> ToolContext:
return cls([])
@property
def function_tools(self) -> dict[str, FunctionTool | RawFunctionTool]:
"""A copy of all function tools in the tool context, including those in tool sets."""
return self._fnc_tools_map.copy()
@property
def provider_tools(self) -> list[ProviderTool]:
"""A copy of all provider tools in the tool context, including those in tool sets."""
return self._provider_tools
@property
def toolsets(self) -> list[Toolset]:
"""A copy of all tool sets in the tool context."""
return self._tool_sets
def flatten(self) -> list[Tool]:
"""Flatten the tool context to a list of tools."""
tools: list[Tool] = []
tools.extend(list(self._fnc_tools_map.values()))
tools.extend(self._provider_tools)
return tools
def get_function_tool(self, name: str) -> FunctionTool | RawFunctionTool | None:
return self._fnc_tools_map.get(name)
def __eq__(self, other: object) -> bool:
if not isinstance(other, ToolContext):
return False
if self._fnc_tools_map.keys() != other._fnc_tools_map.keys():
return False
for name in self._fnc_tools_map:
if self._fnc_tools_map[name] is not other._fnc_tools_map[name]:
return False
if len(self._provider_tools) != len(other._provider_tools):
return False
self_provider_ids = {id(tool) for tool in self._provider_tools}
other_provider_ids = {id(tool) for tool in other._provider_tools}
if self_provider_ids != other_provider_ids:
return False
self_tool_set_ids = {id(tool_set) for tool_set in self._tool_sets}
other_tool_set_ids = {id(tool_set) for tool_set in other._tool_sets}
if self_tool_set_ids != other_tool_set_ids:
return False
return True
def update_tools(self, tools: Sequence[Tool | Toolset]) -> None:
self._update_tools(tools)
def _update_tools(
self, tools: Sequence[Tool | Toolset], *, exclude: Sequence[Tool] = ()
) -> None:
self._tools = list(tools)
self._fnc_tools_map: dict[str, FunctionTool | RawFunctionTool] = {}
self._provider_tools: list[ProviderTool] = []
self._tool_sets: list[Toolset] = []
def add_tool(tool: Tool | Toolset) -> None:
if any(tool is e for e in exclude):
return
if isinstance(tool, ProviderTool):
self._provider_tools.append(tool)
elif isinstance(tool, (FunctionTool, RawFunctionTool)):
existing = self._fnc_tools_map.get(tool.info.name)
if existing is not None:
if existing is not tool:
raise ValueError(f"duplicate function name: {tool.info.name}")
return # same instance, skip
self._fnc_tools_map[tool.info.name] = tool
elif isinstance(tool, Toolset):
for t in tool.tools:
add_tool(t)
self._tool_sets.append(tool)
elif normalized_tool := _resolve_wrapped_tool(tool):
add_tool(normalized_tool)
elif callable(tool):
raise ValueError(
"Expected an instance of FunctionTool or RawFunctionTool, got a callable object. "
"If it's a wrapped tool, please consider wrapping the original function before converting to a function tool."
)
else:
raise ValueError(f"unknown tool type: {type(tool)}")
for tool in itertools.chain(tools, find_function_tools(self)):
add_tool(tool)
def _sync_flattened(self, tools: Sequence[Tool]) -> None:
"""Apply in-place edits of a ``flatten()`` list, preserving Toolset grouping.
Added tools become top-level entries; removed tools are dropped from the
flat lookup. A removed Toolset member stays in its toolset (membership and
lifecycle remain the toolset's) — it just stops being callable.
"""
current = self.flatten()
current_ids = {id(t) for t in current}
tool_ids = {id(t) for t in tools}
if current_ids == tool_ids:
return
added = [t for t in tools if id(t) not in current_ids]
removed_ids = current_ids - tool_ids
removed = [c for c in current if id(c) in removed_ids]
structured = [t for t in self._tools if not any(t is r for r in removed)]
self._update_tools([*structured, *added], exclude=removed)
def _exclude(self, tools: Sequence[Tool]) -> None:
"""Hide ``tools`` from the callable set while keeping their toolsets intact."""
if not tools:
return
kept = [t for t in self.flatten() if not any(t is e for e in tools)]
self._sync_flattened(kept)
def copy(self) -> ToolContext:
return ToolContext(self._tools.copy())
@overload
def parse_function_tools(
self, format: Literal["openai"], *, strict: bool = True
) -> list[dict[str, Any]]: ...
@overload
def parse_function_tools(
self,
format: Literal["openai.responses"],
*,
strict: bool = True,
provider_tool_type: type[ProviderTool] | None = None,
) -> list[dict[str, Any]]: ...
@overload
def parse_function_tools(
self,
format: Literal["google"],
*,
tool_behavior: _provider_format.google.TOOL_BEHAVIOR | None = None,
use_parameters_json_schema: bool = True,
) -> list[dict[str, Any]]: ...
@overload
def parse_function_tools(self, format: Literal["aws"]) -> list[dict[str, Any]]: ...
@overload
def parse_function_tools(
self, format: Literal["anthropic"], *, strict: bool = True
) -> list[dict[str, Any]]: ...
def parse_function_tools(
self,
format: Literal["openai", "google", "aws", "anthropic"] | str,
**kwargs: Any,
) -> list[dict[str, Any]]:
"""Parse the function tools to a provider-specific schema."""
if format == "openai":
return _provider_format.openai.to_fnc_ctx(self, **kwargs)
elif format == "openai.responses":
return _provider_format.openai.to_responses_fnc_ctx(self, **kwargs)
elif format == "google":
return _provider_format.google.to_fnc_ctx(self, **kwargs)
elif format == "anthropic":
return _provider_format.anthropic.to_fnc_ctx(self, **kwargs)
elif format == "aws":
return _provider_format.aws.to_fnc_ctx(self, **kwargs)
raise ValueError(f"Unsupported provider format: {format}")