Merge pull request #2051 from pipecat-ai/pk/direct-functions

Implement "direct functions", which allow you to bypass specifying a …
This commit is contained in:
kompfner
2025-07-01 14:19:33 -04:00
committed by GitHub
7 changed files with 736 additions and 32 deletions

View File

@@ -0,0 +1,228 @@
import inspect
import types
from typing import (
Any,
Callable,
Dict,
List,
Mapping,
Protocol,
Set,
Tuple,
Union,
get_args,
get_origin,
get_type_hints,
)
import docstring_parser
from pipecat.adapters.schemas.function_schema import FunctionSchema
class DirectFunction(Protocol):
"""Protocol for a "direct" function that handles LLM function calls.
"Direct" functions' metadata is automatically extracted from their function signature and
docstrings, allowing them to be used without accompanying function configurations (as
`FunctionSchema`s or in provider-specific formats).
"""
async def __call__(self, params: "FunctionCallParams", **kwargs: Any) -> None: ...
class BaseDirectFunctionWrapper:
"""
Base class for a wrapper around a DirectFunction that:
- extracts metadata from the function signature and docstring
- using that metadata, generates a corresponding FunctionSchema
"""
@classmethod
def special_first_param_name(cls) -> str:
"""The name of the "special" first function parameter that is ignored by the metadata
extraction, as it's not relevant to the LLM.
"""
raise NotImplementedError("Subclasses must define the special first parameter name.")
def __init__(self, function: Callable):
self.__class__.validate_function(function)
self.function = function
self._initialize_metadata()
@classmethod
def validate_function(cls, function: Callable) -> None:
if not inspect.iscoroutinefunction(function):
raise Exception(f"Direct function {function.__name__} must be async")
params = list(inspect.signature(function).parameters.items())
special_first_param_name = cls.special_first_param_name()
if len(params) == 0:
raise Exception(
f"Direct function {function.__name__} must have at least one parameter ({special_first_param_name})"
)
first_param_name = params[0][0]
if first_param_name != special_first_param_name:
raise Exception(
f"Direct function {function.__name__} first parameter must be named '{special_first_param_name}'"
)
def to_function_schema(self) -> FunctionSchema:
return FunctionSchema(
name=self.name,
description=self.description,
properties=self.properties,
required=self.required,
)
def _initialize_metadata(self):
# Get function name
self.name = self.function.__name__
# Parse docstring for description and parameters
docstring = docstring_parser.parse(inspect.getdoc(self.function))
# Get function description
self.description = (docstring.description or "").strip()
# Get function parameters as JSON schemas, and the list of required parameters
self.properties, self.required = self._get_parameters_as_jsonschema(
self.function, docstring.params
)
# TODO: maybe to better support things like enums, check if each type is a pydantic type and use its convert-to-jsonschema function
def _get_parameters_as_jsonschema(
self, func: Callable, docstring_params: List[docstring_parser.DocstringParam]
) -> Tuple[Dict[str, Any], List[str]]:
"""
Get function parameters as a dictionary of JSON schemas and a list of required parameters.
Ignore the first parameter, as it's expected to be the "special" one.
Args:
func: Function to get parameters from
docstring_params: List of parameters extracted from the function's docstring
Returns:
A tuple containing:
- A dictionary mapping each function parameter to its JSON schema
- A list of required parameter names
"""
sig = inspect.signature(func)
hints = get_type_hints(func)
properties = {}
required = []
for name, param in sig.parameters.items():
# Ignore 'self' parameter
if name == "self":
continue
# Ignore the first parameter, which is expected to be the "special" one
# (We have already validated that this is the case in validate_function())
is_first_param = name == next(iter(sig.parameters))
if is_first_param:
continue
type_hint = hints.get(name)
# Convert type hint to JSON schema
properties[name] = self._typehint_to_jsonschema(type_hint)
# Add whether the parameter is required
# If the parameter has no default value, it's required
if param.default is inspect.Parameter.empty:
required.append(name)
# Add parameter description from docstring
for doc_param in docstring_params:
if doc_param.arg_name == name:
properties[name]["description"] = doc_param.description or ""
return properties, required
def _typehint_to_jsonschema(self, type_hint: Any) -> Dict[str, Any]:
"""
Convert a Python type hint to a JSON Schema.
Args:
type_hint: A Python type hint
Returns:
A dictionary representing the JSON Schema
"""
if type_hint is None:
return {}
# Handle basic types
if type_hint is type(None):
return {"type": "null"}
if type_hint is str:
return {"type": "string"}
elif type_hint is int:
return {"type": "integer"}
elif type_hint is float:
return {"type": "number"}
elif type_hint is bool:
return {"type": "boolean"}
elif type_hint is dict or type_hint is Dict:
return {"type": "object"}
elif type_hint is list or type_hint is List:
return {"type": "array"}
# Get origin and arguments for complex types
origin = get_origin(type_hint)
args = get_args(type_hint)
# Handle Optional/Union types
if origin is Union or origin is types.UnionType:
return {"anyOf": [self._typehint_to_jsonschema(arg) for arg in args]}
# Handle List, Tuple, Set with specific item types
if origin in (list, List, tuple, Tuple, set, Set) and args:
return {"type": "array", "items": self._typehint_to_jsonschema(args[0])}
# Handle Dict with specific key/value types
if origin in (dict, Dict) and len(args) == 2:
# For JSON Schema, keys must be strings
return {"type": "object", "additionalProperties": self._typehint_to_jsonschema(args[1])}
# Handle TypedDict
if hasattr(type_hint, "__annotations__"):
properties = {}
required = []
# NOTE: this does not yet support some fields being required and others not, which could happen when:
# - the base class is a TypedDict with required fields (total=True or not specified) and the derived class has optional fields (total=False)
# - Python 3.11+ NotRequired is used
all_fields_required = getattr(type_hint, "__total__", True)
for field_name, field_type in get_type_hints(type_hint).items():
properties[field_name] = self._typehint_to_jsonschema(field_type)
if all_fields_required:
required.append(field_name)
schema = {"type": "object", "properties": properties}
if required:
schema["required"] = required
return schema
# Default to any type if we can't determine the specific schema
return {}
class DirectFunctionWrapper(BaseDirectFunctionWrapper):
"""
Wrapper around a DirectFunction that:
- extracts metadata from the function signature and docstring
- generates a corresponding FunctionSchema
- helps with function invocation
"""
@classmethod
def special_first_param_name(cls) -> str:
return "params"
async def invoke(self, args: Mapping[str, Any], params: "FunctionCallParams"):
return await self.function(params=params, **args)

View File

@@ -13,6 +13,7 @@ and custom adapter-specific tools in the Pipecat framework.
from enum import Enum
from typing import Any, Dict, List, Optional
from pipecat.adapters.schemas.direct_function import DirectFunction, DirectFunctionWrapper
from pipecat.adapters.schemas.function_schema import FunctionSchema
@@ -36,7 +37,7 @@ class ToolsSchema:
def __init__(
self,
standard_tools: List[FunctionSchema],
standard_tools: List[FunctionSchema | DirectFunction],
custom_tools: Optional[Dict[AdapterType, List[Dict[str, Any]]]] = None,
) -> None:
"""Initialize the tools schema.
@@ -46,7 +47,20 @@ class ToolsSchema:
custom_tools: Dictionary mapping adapter types to their custom tool definitions.
These tools may not follow the FunctionSchema format (e.g., search_tool).
"""
self._standard_tools = standard_tools
def _map_standard_tools(tools):
schemas = []
for tool in tools:
if isinstance(tool, FunctionSchema):
schemas.append(tool)
elif callable(tool):
wrapper = DirectFunctionWrapper(tool)
schemas.append(wrapper.to_function_schema())
else:
raise TypeError(f"Unsupported tool type: {type(tool)}")
return schemas
self._standard_tools = _map_standard_tools(standard_tools)
self._custom_tools = custom_tools
@property

View File

@@ -8,12 +8,33 @@
import asyncio
import inspect
import types
from dataclasses import dataclass
from typing import Any, Awaitable, Callable, Dict, Mapping, Optional, Protocol, Sequence, Type
from typing import (
Any,
Awaitable,
Callable,
Dict,
List,
Mapping,
Optional,
Protocol,
Sequence,
Set,
Tuple,
Type,
Union,
get_args,
get_origin,
get_type_hints,
)
import docstring_parser
from loguru import logger
from pipecat.adapters.base_llm_adapter import BaseLLMAdapter
from pipecat.adapters.schemas.direct_function import DirectFunction, DirectFunctionWrapper
from pipecat.adapters.schemas.function_schema import FunctionSchema
from pipecat.adapters.services.open_ai_adapter import OpenAILLMAdapter
from pipecat.frames.frames import (
CancelFrame,
@@ -94,7 +115,7 @@ class FunctionCallRegistryItem:
"""
function_name: Optional[str]
handler: FunctionCallHandler
handler: FunctionCallHandler | "DirectFunctionWrapper"
cancel_on_interruption: bool
@@ -285,6 +306,19 @@ class LLMService(AIService):
self._start_callbacks[function_name] = start_callback
def register_direct_function(
self,
handler: DirectFunction,
*,
cancel_on_interruption: bool = True,
):
wrapper = DirectFunctionWrapper(handler)
self._functions[wrapper.name] = FunctionCallRegistryItem(
function_name=wrapper.name,
handler=wrapper,
cancel_on_interruption=cancel_on_interruption,
)
def unregister_function(self, function_name: Optional[str]):
"""Remove a registered function handler.
@@ -295,6 +329,11 @@ class LLMService(AIService):
if self._start_callbacks[function_name]:
del self._start_callbacks[function_name]
def unregister_direct_function(self, handler: Any):
wrapper = DirectFunctionWrapper(handler)
del self._functions[wrapper.name]
# Note: no need to remove start callback here, as direct functions don't support start callbacks.
def has_function(self, function_name: str):
"""Check if a function handler is registered.
@@ -474,35 +513,50 @@ class LLMService(AIService):
await self.push_frame(result_frame_downstream, FrameDirection.DOWNSTREAM)
await self.push_frame(result_frame_upstream, FrameDirection.UPSTREAM)
signature = inspect.signature(item.handler)
if len(signature.parameters) > 1:
import warnings
with warnings.catch_warnings():
warnings.simplefilter("always")
warnings.warn(
"Function calls with parameters `(function_name, tool_call_id, arguments, llm, context, result_callback)` are deprecated, use a single `FunctionCallParams` parameter instead.",
DeprecationWarning,
)
await item.handler(
runner_item.function_name,
runner_item.tool_call_id,
runner_item.arguments,
self,
runner_item.context,
function_call_result_callback,
if isinstance(item.handler, DirectFunctionWrapper):
# Handler is a DirectFunctionWrapper
await item.handler.invoke(
args=runner_item.arguments,
params=FunctionCallParams(
function_name=runner_item.function_name,
tool_call_id=runner_item.tool_call_id,
arguments=runner_item.arguments,
llm=self,
context=runner_item.context,
result_callback=function_call_result_callback,
),
)
else:
params = FunctionCallParams(
function_name=runner_item.function_name,
tool_call_id=runner_item.tool_call_id,
arguments=runner_item.arguments,
llm=self,
context=runner_item.context,
result_callback=function_call_result_callback,
)
await item.handler(params)
# Handler is a FunctionCallHandler
signature = inspect.signature(item.handler)
if len(signature.parameters) > 1:
import warnings
with warnings.catch_warnings():
warnings.simplefilter("always")
warnings.warn(
"Function calls with parameters `(function_name, tool_call_id, arguments, llm, context, result_callback)` are deprecated, use a single `FunctionCallParams` parameter instead.",
DeprecationWarning,
)
await item.handler(
runner_item.function_name,
runner_item.tool_call_id,
runner_item.arguments,
self,
runner_item.context,
function_call_result_callback,
)
else:
params = FunctionCallParams(
function_name=runner_item.function_name,
tool_call_id=runner_item.tool_call_id,
arguments=runner_item.arguments,
llm=self,
context=runner_item.context,
result_callback=function_call_result_callback,
)
await item.handler(params)
async def _cancel_function_call(self, function_name: Optional[str]):
cancelled_tasks = set()