Merge branch 'main' into feature-python-foundry-agents

This commit is contained in:
Dmytro Struk
2025-11-05 08:04:26 -08:00
Unverified
214 changed files with 22261 additions and 1154 deletions
@@ -19,7 +19,7 @@ from ._middleware import (
)
from ._serialization import SerializationMixin
from ._threads import ChatMessageStoreProtocol
from ._tools import ToolProtocol
from ._tools import FUNCTION_INVOKING_CHAT_CLIENT_MARKER, FunctionInvocationConfiguration, ToolProtocol
from ._types import ChatMessage, ChatOptions, ChatResponse, ChatResponseUpdate, ToolMode, prepare_messages
if TYPE_CHECKING:
@@ -357,6 +357,10 @@ class BaseChatClient(SerializationMixin, ABC):
self.middleware = middleware
self.function_invocation_configuration = (
FunctionInvocationConfiguration() if hasattr(self.__class__, FUNCTION_INVOKING_CHAT_CLIENT_MARKER) else None
)
def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]:
"""Convert the instance to a dictionary.
+345 -103
View File
@@ -71,6 +71,7 @@ logger = get_logger()
__all__ = [
"FUNCTION_INVOKING_CHAT_CLIENT_MARKER",
"AIFunction",
"FunctionInvocationConfiguration",
"HostedCodeInterpreterTool",
"HostedFileSearchTool",
"HostedMCPSpecificApproval",
@@ -84,7 +85,8 @@ __all__ = [
logger = get_logger()
FUNCTION_INVOKING_CHAT_CLIENT_MARKER: Final[str] = "__function_invoking_chat_client__"
DEFAULT_MAX_ITERATIONS: Final[int] = 10
DEFAULT_MAX_ITERATIONS: Final[int] = 40
DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST: Final[int] = 3
TChatClient = TypeVar("TChatClient", bound="ChatClientProtocol")
# region Helpers
@@ -156,34 +158,19 @@ def _parse_inputs(
# region Tools
@runtime_checkable
class ToolProtocol(Protocol):
"""Represents a generic tool that can be specified to an AI service.
"""Represents a generic tool.
This protocol defines the interface that all tools must implement to be compatible
with the agent framework.
with the agent framework. It is implemented by various tool classes such as HostedMCPTool,
HostedWebSearchTool, and AIFunction's. A AIFunction is usually created by the `ai_function` decorator.
Since each connector needs to parse tools differently, users can pass a dict to
specify a service-specific tool when no abstraction is available.
Attributes:
name: The name of the tool.
description: A description of the tool, suitable for use in describing the purpose to a model.
additional_properties: Additional properties associated with the tool.
Examples:
.. code-block:: python
from agent_framework import ToolProtocol
class CustomTool:
def __init__(self, name: str, description: str) -> None:
self.name = name
self.description = description
self.additional_properties = None
def __str__(self) -> str:
return f"CustomTool(name={self.name})"
# Tool now implements ToolProtocol
tool: ToolProtocol = CustomTool("my_tool", "Does something useful")
"""
name: str
@@ -201,22 +188,11 @@ class ToolProtocol(Protocol):
class BaseTool(SerializationMixin):
"""Base class for AI tools, providing common attributes and methods.
This class provides the foundation for creating custom tools with serialization support.
Used as the base class for the various tools in the agent framework, such as HostedMCPTool,
HostedWebSearchTool, and AIFunction.
Examples:
.. code-block:: python
from agent_framework import BaseTool
class MyCustomTool(BaseTool):
def __init__(self, name: str, custom_param: str) -> None:
super().__init__(name=name, description="My custom tool")
self.custom_param = custom_param
tool = MyCustomTool(name="custom", custom_param="value")
print(tool) # MyCustomTool(name=custom, description=My custom tool)
Since each connector needs to parse tools differently, this class is not exposed directly to end users.
In most cases, users can pass a dict to specify a service-specific tool when no abstraction is available.
"""
DEFAULT_EXCLUDE: ClassVar[set[str]] = {"additional_properties"}
@@ -551,6 +527,10 @@ def _default_histogram() -> Histogram:
TClass = TypeVar("TClass", bound="SerializationMixin")
class EmptyInputModel(BaseModel):
"""An empty input model for functions with no parameters."""
class AIFunction(BaseTool, Generic[ArgsT, ReturnT]):
"""A tool that wraps a Python function to make it callable by AI models.
@@ -602,8 +582,10 @@ class AIFunction(BaseTool, Generic[ArgsT, ReturnT]):
name: str,
description: str = "",
approval_mode: Literal["always_require", "never_require"] | None = None,
max_invocations: int | None = None,
max_invocation_exceptions: int | None = None,
additional_properties: dict[str, Any] | None = None,
func: Callable[..., Awaitable[ReturnT] | ReturnT],
func: Callable[..., Awaitable[ReturnT] | ReturnT] | None = None,
input_model: type[ArgsT] | Mapping[str, Any] | None = None,
**kwargs: Any,
) -> None:
@@ -614,6 +596,10 @@ class AIFunction(BaseTool, Generic[ArgsT, ReturnT]):
description: A description of the function.
approval_mode: Whether or not approval is required to run this tool.
Default is that approval is not needed.
max_invocations: The maximum number of times this function can be invoked.
If None, there is no limit. Should be at least 1.
max_invocation_exceptions: The maximum number of exceptions allowed during invocations.
If None, there is no limit. Should be at least 1.
additional_properties: Additional properties to set on the function.
func: The function to wrap.
input_model: The Pydantic model that defines the input parameters for the function.
@@ -630,21 +616,56 @@ class AIFunction(BaseTool, Generic[ArgsT, ReturnT]):
self.func = func
self.input_model = self._resolve_input_model(input_model)
self.approval_mode = approval_mode or "never_require"
if max_invocations is not None and max_invocations < 1:
raise ValueError("max_invocations must be at least 1 or None.")
if max_invocation_exceptions is not None and max_invocation_exceptions < 1:
raise ValueError("max_invocation_exceptions must be at least 1 or None.")
self.max_invocations = max_invocations
self.invocation_count = 0
self.max_invocation_exceptions = max_invocation_exceptions
self.invocation_exception_count = 0
self._invocation_duration_histogram = _default_histogram()
self.type: Literal["ai_function"] = "ai_function"
@property
def declaration_only(self) -> bool:
"""Indicate whether the function is declaration only (i.e., has no implementation)."""
return self.func is None
def _resolve_input_model(self, input_model: type[ArgsT] | Mapping[str, Any] | None) -> type[ArgsT]:
if input_model:
if inspect.isclass(input_model) and issubclass(input_model, BaseModel):
return input_model
if isinstance(input_model, Mapping):
return cast(type[ArgsT], _create_model_from_json_schema(self.name, input_model))
raise TypeError("input_model must be a Pydantic BaseModel subclass or a JSON schema dict.")
return cast(type[ArgsT], _create_input_model_from_func(self.func, self.name))
"""Resolve the input model for the function."""
if input_model is None:
if self.func is None:
return cast(type[ArgsT], EmptyInputModel)
return cast(type[ArgsT], _create_input_model_from_func(func=self.func, name=self.name))
if inspect.isclass(input_model) and issubclass(input_model, BaseModel):
return input_model
if isinstance(input_model, Mapping):
return cast(type[ArgsT], _create_model_from_json_schema(self.name, input_model))
raise TypeError("input_model must be a Pydantic BaseModel subclass or a JSON schema dict.")
def __call__(self, *args: Any, **kwargs: Any) -> ReturnT | Awaitable[ReturnT]:
"""Call the wrapped function with the provided arguments."""
return self.func(*args, **kwargs)
if self.func is None:
raise ToolException(f"Function '{self.name}' is declaration only and cannot be invoked.")
if self.max_invocations is not None and self.invocation_count >= self.max_invocations:
raise ToolException(
f"Function '{self.name}' has reached its maximum invocation limit, you can no longer use this tool."
)
if (
self.max_invocation_exceptions is not None
and self.invocation_exception_count >= self.max_invocation_exceptions
):
raise ToolException(
f"Function '{self.name}' has reached its maximum exception limit, "
f"you tried to use this tool too many times and it kept failing."
)
self.invocation_count += 1
try:
return self.func(*args, **kwargs)
except Exception:
self.invocation_exception_count += 1
raise
async def invoke(
self,
@@ -664,6 +685,8 @@ class AIFunction(BaseTool, Generic[ArgsT, ReturnT]):
Raises:
TypeError: If arguments is not an instance of the expected input model.
"""
if self.declaration_only:
raise ToolException(f"Function '{self.name}' is declaration only and cannot be invoked.")
global OBSERVABILITY_SETTINGS
from .observability import OBSERVABILITY_SETTINGS
@@ -833,7 +856,7 @@ def _parse_annotation(annotation: Any) -> Any:
return annotation
def _create_input_model_from_func(func: Callable[..., Any], tool_name: str) -> type[BaseModel]:
def _create_input_model_from_func(func: Callable[..., Any], name: str) -> type[BaseModel]:
"""Create a Pydantic model from a function's signature."""
sig = inspect.signature(func)
fields = {
@@ -844,7 +867,7 @@ def _create_input_model_from_func(func: Callable[..., Any], tool_name: str) -> t
for pname, param in sig.parameters.items()
if pname not in {"self", "cls"}
}
return create_model(f"{tool_name}_input", **fields) # type: ignore[call-overload, no-any-return]
return create_model(f"{name}_input", **fields) # type: ignore[call-overload, no-any-return]
# Map JSON Schema types to Pydantic types
@@ -907,6 +930,8 @@ def ai_function(
name: str | None = None,
description: str | None = None,
approval_mode: Literal["always_require", "never_require"] | None = None,
max_invocations: int | None = None,
max_invocation_exceptions: int | None = None,
additional_properties: dict[str, Any] | None = None,
) -> AIFunction[Any, ReturnT]: ...
@@ -918,6 +943,8 @@ def ai_function(
name: str | None = None,
description: str | None = None,
approval_mode: Literal["always_require", "never_require"] | None = None,
max_invocations: int | None = None,
max_invocation_exceptions: int | None = None,
additional_properties: dict[str, Any] | None = None,
) -> Callable[[Callable[..., ReturnT | Awaitable[ReturnT]]], AIFunction[Any, ReturnT]]: ...
@@ -928,6 +955,8 @@ def ai_function(
name: str | None = None,
description: str | None = None,
approval_mode: Literal["always_require", "never_require"] | None = None,
max_invocations: int | None = None,
max_invocation_exceptions: int | None = None,
additional_properties: dict[str, Any] | None = None,
) -> AIFunction[Any, ReturnT] | Callable[[Callable[..., ReturnT | Awaitable[ReturnT]]], AIFunction[Any, ReturnT]]:
"""Decorate a function to turn it into a AIFunction that can be passed to models and executed automatically.
@@ -940,6 +969,22 @@ def ai_function(
with a string description as the second argument. You can also use Pydantic's
``Field`` class for more advanced configuration.
Args:
func: The function to decorate.
Keyword Args:
name: The name of the function. If not provided, the function's ``__name__``
attribute will be used.
description: A description of the function. If not provided, the function's
docstring will be used.
approval_mode: Whether or not approval is required to run this tool.
Default is that approval is not needed.
max_invocations: The maximum number of times this function can be invoked.
If None, there is no limit, should be at least 1.
max_invocation_exceptions: The maximum number of exceptions allowed during invocations.
If None, there is no limit, should be at least 1.
additional_properties: Additional properties to set on the function.
Note:
When approval_mode is set to "always_require", the function will not be executed
until explicit approval is given, this only applies to the auto-invocation flow.
@@ -997,6 +1042,8 @@ def ai_function(
name=tool_name,
description=tool_desc,
approval_mode=approval_mode,
max_invocations=max_invocations,
max_invocation_exceptions=max_invocation_exceptions,
additional_properties=additional_properties or {},
func=f,
)
@@ -1009,10 +1056,123 @@ def ai_function(
# region Function Invoking Chat Client
class FunctionInvocationConfiguration(SerializationMixin):
"""Configuration for function invocation in chat clients.
This class is created automatically on every chat client that supports function invocation.
This means that for most cases you can just alter the attributes on the instance, rather then creating a new one.
Example:
.. code-block:: python
from agent_framework.openai import OpenAIChatClient
# Create an OpenAI chat client
client = OpenAIChatClient(api_key="your_api_key")
# Disable function invocation
client.function_invocation_config.enabled = False
# Set maximum iterations to 10
client.function_invocation_config.max_iterations = 10
# Enable termination on unknown function calls
client.function_invocation_config.terminate_on_unknown_calls = True
# Add additional tools for function execution
client.function_invocation_config.additional_tools = [my_custom_tool]
# Enable detailed error information in function results
client.function_invocation_config.include_detailed_errors = True
# You can also create a new configuration instance if needed
new_config = FunctionInvocationConfiguration(
enabled=True,
max_iterations=20,
terminate_on_unknown_calls=False,
additional_tools=[another_tool],
include_detailed_errors=False,
)
# and then assign it to the client
client.function_invocation_config = new_config
Attributes:
enabled: Whether function invocation is enabled.
When this is set to False, the client will not attempt to invoke any functions,
because the tool mode will be set to None.
max_iterations: Maximum number of function invocation iterations.
Each request to this client might end up making multiple requests to the model. Each time the model responds
with a function call request, this client might perform that invocation and send the results back to the
model in a new request. This property limits the number of times such a roundtrip is performed. The value
must be at least one, as it includes the initial request.
If you want to fully disable function invocation, use the ``enabled`` property.
The default is 40.
max_consecutive_errors_per_request: Maximum consecutive errors allowed per request.
The maximum number of consecutive function call errors allowed before stopping
further function calls for the request.
The default is 3.
terminate_on_unknown_calls: Whether to terminate on unknown function calls.
When False, call requests to any tools that aren't available to the client
will result in a response message automatically being created and returned to the inner client stating that
the tool couldn't be found. This behavior can help in cases where a model hallucinates a function, but it's
problematic if the model has been made aware of the existence of tools outside of the normal mechanisms, and
requests one of those. ``additional_tools`` can be used to help with that. But if instead the consumer wants
to know about all function call requests that the client can't handle, this can be set to True. Upon
receiving a request to call a function that the client doesn't know about, it will terminate the function
calling loop and return the response, leaving the handling of the function call requests to the consumer of
the client.
additional_tools: Additional tools to include for function execution.
These will not impact the requests sent by the client, which will pass through the
``tools`` unmodified. However, if the inner client requests the invocation of a tool
that was not in ``ChatOptions.tools``, this ``additional_tools`` collection will also be consulted to look
for a corresponding tool. This is useful when the service might have been pre-configured to be aware of
certain tools that aren't also sent on each individual request. These tools are treated the same as
``declaration_only`` tools and will be returned to the user.
include_detailed_errors: Whether to include detailed error information in function results.
When set to True, detailed error information such as exception type and message
will be included in the function result content when a function invocation fails.
When False, only a generic error message will be included.
"""
def __init__(
self,
enabled: bool = True,
max_iterations: int = DEFAULT_MAX_ITERATIONS,
max_consecutive_errors_per_request: int = DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST,
terminate_on_unknown_calls: bool = False,
additional_tools: Sequence[ToolProtocol] | None = None,
include_detailed_errors: bool = False,
) -> None:
"""Initialize FunctionInvocationConfiguration.
Args:
enabled: Whether function invocation is enabled.
max_iterations: Maximum number of function invocation iterations.
max_consecutive_errors_per_request: Maximum consecutive errors allowed per request.
terminate_on_unknown_calls: Whether to terminate on unknown function calls.
additional_tools: Additional tools to include for function execution.
include_detailed_errors: Whether to include detailed error information in function results.
"""
self.enabled = enabled
if max_iterations < 1:
raise ValueError("max_iterations must be at least 1.")
self.max_iterations = max_iterations
if max_consecutive_errors_per_request < 0:
raise ValueError("max_consecutive_errors_per_request must be 0 or more.")
self.max_consecutive_errors_per_request = max_consecutive_errors_per_request
self.terminate_on_unknown_calls = terminate_on_unknown_calls
self.additional_tools = additional_tools or []
self.include_detailed_errors = include_detailed_errors
async def _auto_invoke_function(
function_call_content: "FunctionCallContent | FunctionApprovalResponseContent",
custom_args: dict[str, Any] | None = None,
*,
config: FunctionInvocationConfiguration,
tool_map: dict[str, AIFunction[BaseModel, Any]],
sequence_index: int | None = None,
request_index: int | None = None,
@@ -1025,6 +1185,7 @@ async def _auto_invoke_function(
custom_args: Additional custom arguments to merge with parsed arguments.
Keyword Args:
config: The function invocation configuration.
tool_map: A mapping of tool names to AIFunction instances.
sequence_index: The index of the function call in the sequence.
request_index: The index of the request iteration.
@@ -1037,29 +1198,33 @@ async def _auto_invoke_function(
KeyError: If the requested function is not found in the tool map.
"""
from ._types import (
FunctionApprovalRequestContent,
FunctionApprovalResponseContent,
FunctionCallContent,
FunctionResultContent,
)
# Note: The scenarios for approval_mode="always_require", declaration_only, and
# terminate_on_unknown_calls are all handled in _try_execute_function_calls before
# this function is called. This function only handles the actual execution of approved,
# non-declaration-only functions.
tool: AIFunction[BaseModel, Any] | None = None
if isinstance(function_call_content, FunctionCallContent):
if function_call_content.type == "function_call":
tool = tool_map.get(function_call_content.name)
# Tool should exist because _try_execute_function_calls validates this
if tool is None:
raise KeyError(f"No tool or function named '{function_call_content.name}'")
if tool.approval_mode == "always_require":
return FunctionApprovalRequestContent(id=function_call_content.call_id, function_call=function_call_content)
exc = KeyError(f'Function "{function_call_content.name}" not found.')
return FunctionResultContent(
call_id=function_call_content.call_id,
result=f'Error: Requested function "{function_call_content.name}" not found.',
exception=exc,
)
else:
if isinstance(function_call_content, FunctionApprovalResponseContent):
if function_call_content.approved:
tool = tool_map.get(function_call_content.function_call.name)
if tool is None:
# we assume it is a hosted tool
return function_call_content
function_call_content = function_call_content.function_call
else:
raise ToolException("Unapproved tool cannot be executed.")
# Note: Unapproved tools (approved=False) are handled in _replace_approval_contents_with_results
# and never reach this function, so we only handle approved=True cases here.
tool = tool_map.get(function_call_content.function_call.name)
if tool is None:
# we assume it is a hosted tool
return function_call_content
function_call_content = function_call_content.function_call
parsed_args: dict[str, Any] = dict(function_call_content.parse_arguments() or {})
@@ -1068,10 +1233,10 @@ async def _auto_invoke_function(
try:
args = tool.input_model.model_validate(merged_args)
except ValidationError as exc:
return FunctionResultContent(
call_id=function_call_content.call_id,
exception=exc,
)
message = "Error: Argument parsing failed."
if config.include_detailed_errors:
message = f"{message} Exception: {exc}"
return FunctionResultContent(call_id=function_call_content.call_id, result=message, exception=exc)
if not middleware_pipeline or (
not hasattr(middleware_pipeline, "has_middlewares") and not middleware_pipeline.has_middlewares
):
@@ -1086,10 +1251,10 @@ async def _auto_invoke_function(
result=function_result,
)
except Exception as exc:
return FunctionResultContent(
call_id=function_call_content.call_id,
exception=exc,
)
message = "Error: Function failed."
if config.include_detailed_errors:
message = f"{message} Exception: {exc}"
return FunctionResultContent(call_id=function_call_content.call_id, result=message, exception=exc)
# Execute through middleware pipeline if available
from ._middleware import FunctionInvocationContext
@@ -1117,10 +1282,10 @@ async def _auto_invoke_function(
result=function_result,
)
except Exception as exc:
return FunctionResultContent(
call_id=function_call_content.call_id,
exception=exc,
)
message = "Error: Function failed."
if config.include_detailed_errors:
message = f"{message} Exception: {exc}"
return FunctionResultContent(call_id=function_call_content.call_id, result=message, exception=exc)
def _get_tool_map(
@@ -1141,7 +1306,7 @@ def _get_tool_map(
return ai_function_list
async def _execute_function_calls(
async def _try_execute_function_calls(
custom_args: dict[str, Any],
attempt_idx: int,
function_calls: Sequence["FunctionCallContent"] | Sequence["FunctionApprovalResponseContent"],
@@ -1149,6 +1314,7 @@ async def _execute_function_calls(
| Callable[..., Any] \
| MutableMapping[str, Any] \
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]",
config: FunctionInvocationConfiguration,
middleware_pipeline: Any = None, # Optional MiddlewarePipeline to avoid circular imports
) -> Sequence["Contents"]:
"""Execute multiple function calls concurrently.
@@ -1158,22 +1324,33 @@ async def _execute_function_calls(
attempt_idx: The index of the current attempt iteration.
function_calls: A sequence of FunctionCallContent to execute.
tools: The tools available for execution.
config: Configuration for function invocation.
middleware_pipeline: Optional middleware pipeline to apply during execution.
Returns:
A list of Contents containing the results of each function call.
A list of Contents containing the results of each function call,
or the approval requests if any function requires approval,
or the original function calls if any are declaration only.
"""
from ._types import FunctionApprovalRequestContent, FunctionCallContent
tool_map = _get_tool_map(tools)
approval_tools = [tool_name for tool_name, tool in tool_map.items() if tool.approval_mode == "always_require"]
declaration_only = [tool_name for tool_name, tool in tool_map.items() if tool.declaration_only]
additional_tool_names = [tool.name for tool in config.additional_tools] if config.additional_tools else []
# check if any are calling functions that need approval
# if so, we return approval request for all
approval_needed = False
declaration_only_flag = False
for fcc in function_calls:
if isinstance(fcc, FunctionCallContent) and fcc.name in approval_tools:
approval_needed = True
break
if isinstance(fcc, FunctionCallContent) and (fcc.name in declaration_only or fcc.name in additional_tool_names):
declaration_only_flag = True
break
if config.terminate_on_unknown_calls and isinstance(fcc, FunctionCallContent) and fcc.name not in tool_map:
raise KeyError(f'Error: Requested function "{fcc.name}" not found.')
if approval_needed:
# approval can only be needed for Function Call Contents, not Approval Responses.
return [
@@ -1181,6 +1358,9 @@ async def _execute_function_calls(
for fcc in function_calls
if isinstance(fcc, FunctionCallContent)
]
if declaration_only_flag:
# return the declaration only tools to the user, since we cannot execute them.
return [fcc for fcc in function_calls if isinstance(fcc, FunctionCallContent)]
# Run all function calls concurrently
return await asyncio.gather(*[
@@ -1191,6 +1371,7 @@ async def _execute_function_calls(
sequence_index=seq_idx,
request_index=attempt_idx,
middleware_pipeline=middleware_pipeline,
config=config,
)
for seq_idx, function_call in enumerate(function_calls)
])
@@ -1334,17 +1515,17 @@ def _handle_function_calls_response(
# because the underlying function may not preserve it in kwargs
stored_middleware_pipeline = kwargs.get("_function_middleware_pipeline")
# Get max_iterations from instance additional_properties or class attribute
instance_max_iterations: int = DEFAULT_MAX_ITERATIONS
if hasattr(self, "additional_properties") and self.additional_properties:
instance_max_iterations = self.additional_properties.get("max_iterations", DEFAULT_MAX_ITERATIONS)
elif hasattr(self.__class__, "MAX_ITERATIONS"):
instance_max_iterations = getattr(self.__class__, "MAX_ITERATIONS", DEFAULT_MAX_ITERATIONS)
# Get the config for function invocation (not part of ChatClientProtocol, hence getattr)
config: FunctionInvocationConfiguration | None = getattr(self, "function_invocation_configuration", None)
if not config:
# Default config if not set
config = FunctionInvocationConfiguration()
errors_in_a_row: int = 0
prepped_messages = prepare_messages(messages)
response: "ChatResponse | None" = None
fcc_messages: "list[ChatMessage]" = []
for attempt_idx in range(instance_max_iterations):
for attempt_idx in range(config.max_iterations if config.enabled else 0):
fcc_todo = _collect_approval_responses(prepped_messages)
if fcc_todo:
tools = _extract_tools(kwargs)
@@ -1352,13 +1533,29 @@ def _handle_function_calls_response(
approved_responses = [resp for resp in fcc_todo.values() if resp.approved]
approved_function_results: list[Contents] = []
if approved_responses:
approved_function_results = await _execute_function_calls(
approved_function_results = await _try_execute_function_calls(
custom_args=kwargs,
attempt_idx=attempt_idx,
function_calls=approved_responses,
tools=tools, # type: ignore
middleware_pipeline=stored_middleware_pipeline,
config=config,
)
if any(
fcr.exception is not None
for fcr in approved_function_results
if isinstance(fcr, FunctionResultContent)
):
errors_in_a_row += 1
# no need to reset the counter here, since this is the start of a new attempt.
if errors_in_a_row >= config.max_consecutive_errors_per_request:
logger.warning(
"Maximum consecutive function call errors reached (%d). "
"Stopping further function calls for this request.",
config.max_consecutive_errors_per_request,
)
# break out of the loop and do the fallback response
break
_replace_approval_contents_with_results(prepped_messages, fcc_todo, approved_function_results)
response = await func(self, messages=prepped_messages, **kwargs)
@@ -1381,15 +1578,15 @@ def _handle_function_calls_response(
if function_calls and tools:
# Use the stored middleware pipeline instead of extracting from kwargs
# because kwargs may have been modified by the underlying function
function_call_results: list[Contents] = await _execute_function_calls(
function_call_results: list[Contents] = await _try_execute_function_calls(
custom_args=kwargs,
attempt_idx=attempt_idx,
function_calls=function_calls,
tools=tools, # type: ignore
middleware_pipeline=stored_middleware_pipeline,
config=config,
)
# Check if we have approval requests in the results
# Check if we have approval requests or function calls (not results) in the results
if any(isinstance(fccr, FunctionApprovalRequestContent) for fccr in function_call_results):
# Add approval requests to the existing assistant message (with tool_calls)
# instead of creating a separate tool message
@@ -1402,6 +1599,26 @@ def _handle_function_calls_response(
result_message = ChatMessage(role="assistant", contents=function_call_results)
response.messages.append(result_message)
return response
if any(isinstance(fccr, FunctionCallContent) for fccr in function_call_results):
# the function calls are already in the response, so we just continue
return response
if any(
fcr.exception is not None
for fcr in function_call_results
if isinstance(fcr, FunctionResultContent)
):
errors_in_a_row += 1
if errors_in_a_row >= config.max_consecutive_errors_per_request:
logger.warning(
"Maximum consecutive function call errors reached (%d). "
"Stopping further function calls for this request.",
config.max_consecutive_errors_per_request,
)
# break out of the loop and do the fallback response
break
else:
errors_in_a_row = 0
# add a single ChatMessage to the response with the results
result_message = ChatMessage(role="tool", contents=function_call_results)
@@ -1482,16 +1699,16 @@ def _handle_function_calls_streaming_response(
# because the underlying function may not preserve it in kwargs
stored_middleware_pipeline = kwargs.get("_function_middleware_pipeline")
# Get max_iterations from instance additional_properties or class attribute
instance_max_iterations: int = DEFAULT_MAX_ITERATIONS
if hasattr(self, "additional_properties") and self.additional_properties:
instance_max_iterations = self.additional_properties.get("max_iterations", DEFAULT_MAX_ITERATIONS)
elif hasattr(self.__class__, "MAX_ITERATIONS"):
instance_max_iterations = getattr(self.__class__, "MAX_ITERATIONS", DEFAULT_MAX_ITERATIONS)
# Get the config for function invocation (not part of ChatClientProtocol, hence getattr)
config: FunctionInvocationConfiguration | None = getattr(self, "function_invocation_configuration", None)
if not config:
# Default config if not set
config = FunctionInvocationConfiguration()
errors_in_a_row: int = 0
prepped_messages = prepare_messages(messages)
fcc_messages: "list[ChatMessage]" = []
for attempt_idx in range(instance_max_iterations):
for attempt_idx in range(config.max_iterations if config.enabled else 0):
fcc_todo = _collect_approval_responses(prepped_messages)
if fcc_todo:
tools = _extract_tools(kwargs)
@@ -1499,13 +1716,21 @@ def _handle_function_calls_streaming_response(
approved_responses = [resp for resp in fcc_todo.values() if resp.approved]
approved_function_results: list[Contents] = []
if approved_responses:
approved_function_results = await _execute_function_calls(
approved_function_results = await _try_execute_function_calls(
custom_args=kwargs,
attempt_idx=attempt_idx,
function_calls=approved_responses,
tools=tools, # type: ignore
middleware_pipeline=stored_middleware_pipeline,
config=config,
)
if any(
fcr.exception is not None
for fcr in approved_function_results
if isinstance(fcr, FunctionResultContent)
):
errors_in_a_row += 1
# no need to reset the counter here, since this is the start of a new attempt.
_replace_approval_contents_with_results(prepped_messages, fcc_todo, approved_function_results)
all_updates: list["ChatResponseUpdate"] = []
@@ -1551,15 +1776,16 @@ def _handle_function_calls_streaming_response(
if function_calls and tools:
# Use the stored middleware pipeline instead of extracting from kwargs
# because kwargs may have been modified by the underlying function
function_call_results: list[Contents] = await _execute_function_calls(
function_call_results: list[Contents] = await _try_execute_function_calls(
custom_args=kwargs,
attempt_idx=attempt_idx,
function_calls=function_calls,
tools=tools, # type: ignore
middleware_pipeline=stored_middleware_pipeline,
config=config,
)
# Check if we have approval requests in the results
# Check if we have approval requests or function calls (not results) in the results
if any(isinstance(fccr, FunctionApprovalRequestContent) for fccr in function_call_results):
# Add approval requests to the existing assistant message (with tool_calls)
# instead of creating a separate tool message
@@ -1575,6 +1801,26 @@ def _handle_function_calls_streaming_response(
yield ChatResponseUpdate(contents=function_call_results, role="assistant")
response.messages.append(result_message)
return
if any(isinstance(fccr, FunctionCallContent) for fccr in function_call_results):
# the function calls were already yielded.
return
if any(
fcr.exception is not None
for fcr in function_call_results
if isinstance(fcr, FunctionResultContent)
):
errors_in_a_row += 1
if errors_in_a_row >= config.max_consecutive_errors_per_request:
logger.warning(
"Maximum consecutive function call errors reached (%d). "
"Stopping further function calls for this request.",
config.max_consecutive_errors_per_request,
)
# break out of the loop and do the fallback response
break
else:
errors_in_a_row = 0
# add a single ChatMessage to the response with the results
result_message = ChatMessage(role="tool", contents=function_call_results)
@@ -1648,10 +1894,6 @@ def use_function_invocation(
if getattr(chat_client, FUNCTION_INVOKING_CHAT_CLIENT_MARKER, False):
return chat_client
# Set MAX_ITERATIONS as a class variable if not already set
if not hasattr(chat_client, "MAX_ITERATIONS"):
chat_client.MAX_ITERATIONS = DEFAULT_MAX_ITERATIONS # type: ignore
try:
chat_client.get_response = _handle_function_calls_response( # type: ignore
func=chat_client.get_response, # type: ignore
@@ -80,6 +80,14 @@ def _clone_chat_agent(agent: ChatAgent) -> ChatAgent:
options = agent.chat_options
middleware = list(agent.middleware or [])
# Reconstruct the original tools list by combining regular tools with MCP tools.
# ChatAgent.__init__ separates MCP tools into _local_mcp_tools during initialization,
# so we need to recombine them here to pass the complete tools list to the constructor.
# This makes sure MCP tools are preserved when cloning agents for handoff workflows.
all_tools = list(options.tools) if options.tools else []
if agent._local_mcp_tools:
all_tools.extend(agent._local_mcp_tools)
return ChatAgent(
chat_client=agent.chat_client,
instructions=options.instructions,
@@ -101,7 +109,7 @@ def _clone_chat_agent(agent: ChatAgent) -> ChatAgent:
store=options.store,
temperature=options.temperature,
tool_choice=options.tool_choice, # type: ignore[arg-type]
tools=list(options.tools) if options.tools else None,
tools=all_tools if all_tools else None,
top_p=options.top_p,
user=options.user,
additional_chat_options=dict(options.additional_properties),
@@ -0,0 +1,23 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib
from typing import Any
PACKAGE_NAME = "agent_framework_chatkit"
PACKAGE_EXTRA = "chatkit"
_IMPORTS = ["__version__", "ThreadItemConverter", "simple_to_agent_input", "stream_agent_response"]
def __getattr__(name: str) -> Any:
if name in _IMPORTS:
try:
return getattr(importlib.import_module(PACKAGE_NAME), name)
except ModuleNotFoundError as exc:
raise ModuleNotFoundError(
f"The '{PACKAGE_EXTRA}' extra is not installed, please do `pip install agent-framework-{PACKAGE_EXTRA}`"
) from exc
raise AttributeError(f"Module {PACKAGE_NAME} has no attribute {name}.")
def __dir__() -> list[str]:
return _IMPORTS
@@ -0,0 +1,10 @@
# Copyright (c) Microsoft. All rights reserved.
from agent_framework_chatkit import (
ThreadItemConverter,
__version__,
simple_to_agent_input,
stream_agent_response,
)
__all__ = ["ThreadItemConverter", "__version__", "simple_to_agent_input", "stream_agent_response"]
@@ -502,8 +502,6 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
tool_outputs = []
if function_result_content.result:
output = prepare_function_call_results(function_result_content.result)
elif function_result_content.exception:
output = "Error: " + str(function_result_content.exception)
else:
output = "No output received."
tool_outputs.append(ToolOutput(tool_call_id=call_id, output=output))
@@ -380,11 +380,6 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
args["tool_call_id"] = content.call_id
if content.result is not None:
args["content"] = prepare_function_call_results(content.result)
elif content.exception is not None:
# Send the exception message to the model
# Otherwise we won't have any channels to talk to OpenAI
# TODO(yuge): This should ideally be customizable
args["content"] = "Error: " + str(content.exception)
case _:
if "content" not in args:
args["content"] = []
@@ -511,8 +511,6 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
}
if content.result:
args["output"] = prepare_function_call_results(content.result)
if content.exception:
args["output"] = "Error: " + str(content.exception)
return args
case FunctionApprovalRequestContent():
return {
+3 -2
View File
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251028"
version = "1.0.0b251104"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -19,6 +19,7 @@ classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Typing :: Typed",
]
dependencies = [
@@ -32,7 +33,7 @@ dependencies = [
"opentelemetry-exporter-otlp-proto-grpc>=1.36.0",
"opentelemetry-semantic-conventions-ai>=0.4.13",
# connectors and functions
"openai>=1.99.0,<2",
"openai>=1.99.0",
"azure-identity>=1,<2",
"mcp[ws]>=1.13",
]
File diff suppressed because it is too large Load Diff
@@ -63,6 +63,26 @@ def test_ai_function_decorator_without_args():
assert test_tool(1, 2) == 3
def test_ai_function_without_args():
"""Test the ai_function decorator."""
@ai_function
def test_tool() -> int:
"""A simple function that adds two numbers."""
return 1 + 2
assert isinstance(test_tool, ToolProtocol)
assert isinstance(test_tool, AIFunction)
assert test_tool.name == "test_tool"
assert test_tool.description == "A simple function that adds two numbers."
assert test_tool.parameters() == {
"properties": {},
"title": "test_tool_input",
"type": "object",
}
assert test_tool() == 3
async def test_ai_function_decorator_with_async():
"""Test the ai_function decorator with an async function."""
@@ -689,12 +689,15 @@ def test_function_result_exception_handling(openai_unit_test_env: dict[str, str]
# Test with exception (no result)
test_exception = ValueError("Test error message")
message_with_exception = ChatMessage(
role="tool", contents=[FunctionResultContent(call_id="call-123", exception=test_exception)]
role="tool",
contents=[
FunctionResultContent(call_id="call-123", result="Error: Function failed.", exception=test_exception)
],
)
openai_messages = client._openai_chat_message_parser(message_with_exception)
assert len(openai_messages) == 1
assert openai_messages[0]["content"] == "Error: Test error message"
assert openai_messages[0]["content"] == "Error: Function failed."
assert openai_messages[0]["tool_call_id"] == "call-123"
@@ -3,6 +3,7 @@
from collections.abc import AsyncIterable, AsyncIterator
from dataclasses import dataclass
from typing import Any, cast
from unittest.mock import MagicMock
import pytest
@@ -10,6 +11,7 @@ from agent_framework import (
AgentRunResponse,
AgentRunResponseUpdate,
BaseAgent,
ChatAgent,
ChatMessage,
FunctionCallContent,
HandoffBuilder,
@@ -20,6 +22,8 @@ from agent_framework import (
WorkflowEvent,
WorkflowOutputEvent,
)
from agent_framework._mcp import MCPTool
from agent_framework._workflows._handoff import _clone_chat_agent
@dataclass
@@ -368,3 +372,32 @@ async def test_handoff_async_termination_condition() -> None:
user_messages = [msg for msg in final_conv_list if msg.role == Role.USER]
assert len(user_messages) == 2
assert termination_call_count > 0
async def test_clone_chat_agent_preserves_mcp_tools() -> None:
"""Test that _clone_chat_agent preserves MCP tools when cloning an agent."""
mock_chat_client = MagicMock()
mock_mcp_tool = MagicMock(spec=MCPTool)
mock_mcp_tool.name = "test_mcp_tool"
def sample_function() -> str:
return "test"
original_agent = ChatAgent(
chat_client=mock_chat_client,
name="TestAgent",
instructions="Test instructions",
tools=[mock_mcp_tool, sample_function],
)
assert hasattr(original_agent, "_local_mcp_tools")
assert len(original_agent._local_mcp_tools) == 1
assert original_agent._local_mcp_tools[0] == mock_mcp_tool
cloned_agent = _clone_chat_agent(original_agent)
assert hasattr(cloned_agent, "_local_mcp_tools")
assert len(cloned_agent._local_mcp_tools) == 1
assert cloned_agent._local_mcp_tools[0] == mock_mcp_tool
assert len(cloned_agent.chat_options.tools) == 1