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:
228
src/pipecat/adapters/schemas/direct_function.py
Normal file
228
src/pipecat/adapters/schemas/direct_function.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user