Python: updated docstrings of all _files (#1107)

* updated docstrings of all _files

* fix mypy

* fixed codeblocks in workflows and some other files

---------

Co-authored-by: Eric Zhu <ekzhu@users.noreply.github.com>
This commit is contained in:
Eduard van Valkenburg
2025-10-02 18:18:58 +02:00
committed by GitHub
Unverified
parent d6c40edbc6
commit 8b80a03fbb
23 changed files with 3098 additions and 819 deletions
+5 -3
View File
@@ -7,11 +7,11 @@ import os
from pathlib import Path
from dotenv import load_dotenv
from agent_framework import __version__ as agent_framework_version
from py2docfx.__main__ import main as py2docfx_main
load_dotenv()
async def generate_af_docs(root_path: Path):
"""Generate documentation for the Agent Framework using py2docfx.
@@ -21,9 +21,10 @@ async def generate_af_docs(root_path: Path):
"packages": [
{
"package_info": {
"name": "agent-framework",
"version": agent_framework_version,
"name": "agent-framework-core",
"version": "1.0.0b251001",
"install_type": "pypi",
"extras": ["all"]
},
"sphinx_extensions": [
"sphinxcontrib.autodoc_pydantic",
@@ -75,6 +76,7 @@ async def generate_af_docs(root_path: Path):
str((root_path / "docs" / "build").absolute()),
"-j",
json.dumps(package),
"--verbose"
]
try:
await py2docfx_main(args)
+320 -86
View File
@@ -29,7 +29,7 @@ from ._types import (
Role,
ToolMode,
)
from .exceptions import AgentExecutionException
from .exceptions import AgentExecutionException, AgentInitializationError
from .observability import use_agent_observability
if sys.version_info >= (3, 12):
@@ -53,7 +53,70 @@ __all__ = ["AgentProtocol", "BaseAgent", "ChatAgent"]
@runtime_checkable
class AgentProtocol(Protocol):
"""A protocol for an agent that can be invoked."""
"""A protocol for an agent that can be invoked.
This protocol defines the interface that all agents must implement,
including properties for identification and methods for execution.
Note:
Protocols use structural subtyping (duck typing). Classes don't need
to explicitly inherit from this protocol to be considered compatible.
This allows you to create completely custom agents without using
any Agent Framework base classes.
Examples:
.. code-block:: python
from agent_framework import AgentProtocol
# Any class implementing the required methods is compatible
# No need to inherit from AgentProtocol or use any framework classes
class CustomAgent:
def __init__(self):
self._id = "custom-agent-001"
self._name = "Custom Agent"
@property
def id(self) -> str:
return self._id
@property
def name(self) -> str | None:
return self._name
@property
def display_name(self) -> str:
return self.name or self.id
@property
def description(self) -> str | None:
return "A fully custom agent implementation"
async def run(self, messages=None, *, thread=None, **kwargs):
# Your custom implementation
from agent_framework import AgentRunResponse
return AgentRunResponse(messages=[], response_id="custom-response")
def run_stream(self, messages=None, *, thread=None, **kwargs):
# Your custom streaming implementation
async def _stream():
from agent_framework import AgentRunResponseUpdate
yield AgentRunResponseUpdate()
return _stream()
def get_new_thread(self, **kwargs):
# Return your own thread implementation
return {"id": "custom-thread", "messages": []}
# Verify the instance satisfies the protocol
instance = CustomAgent()
assert isinstance(instance, AgentProtocol)
"""
@property
def id(self) -> str:
@@ -137,7 +200,51 @@ class AgentProtocol(Protocol):
class BaseAgent(SerializationMixin):
"""Base class for all Agent Framework agents."""
"""Base class for all Agent Framework agents.
This class provides core functionality for agent implementations, including
context providers, middleware support, and thread management.
Note:
BaseAgent cannot be instantiated directly as it doesn't implement the
``run()``, ``run_stream()``, and other methods required by AgentProtocol.
Use a concrete implementation like ChatAgent or create a subclass.
Examples:
.. code-block:: python
from agent_framework import BaseAgent, AgentThread, AgentRunResponse
# Create a concrete subclass that implements the protocol
class SimpleAgent(BaseAgent):
async def run(self, messages=None, *, thread=None, **kwargs):
# Custom implementation
return AgentRunResponse(messages=[], response_id="simple-response")
def run_stream(self, messages=None, *, thread=None, **kwargs):
async def _stream():
# Custom streaming implementation
yield AgentRunResponseUpdate()
return _stream()
# Now instantiate the concrete subclass
agent = SimpleAgent(name="my-agent", description="A simple agent implementation")
# Create with specific ID and additional properties
agent = SimpleAgent(
id="custom-id-123",
name="configured-agent",
description="An agent with custom configuration",
additional_properties={"version": "1.0", "environment": "production"},
)
# Access agent properties
print(agent.id) # Custom or auto-generated UUID
print(agent.display_name) # Returns name or id
"""
DEFAULT_EXCLUDE: ClassVar[set[str]] = {"additional_properties"}
@@ -152,14 +259,13 @@ class BaseAgent(SerializationMixin):
additional_properties: MutableMapping[str, Any] | None = None,
**kwargs: Any,
) -> None:
"""Base class for all Agent Framework agents.
"""Initialize a BaseAgent instance.
Args:
id: The unique identifier of the agent If no id is provided,
id: The unique identifier of the agent. If no id is provided,
a new UUID will be generated.
name: The name of the agent, can be None.
description: The description of the agent.
display_name: The display name of the agent, which is either the name or id.
context_providers: The collection of multiple context providers to include during agent invocation.
middleware: List of middleware to intercept agent and function invocations.
additional_properties: Additional properties set on the agent.
@@ -189,6 +295,11 @@ class BaseAgent(SerializationMixin):
"""Notify the thread of new messages.
This also calls the invoked method of a potential context provider on the thread.
Args:
thread: The thread to notify of new messages.
input_messages: The input messages to notify about.
response_messages: The response messages to notify about.
"""
if isinstance(input_messages, ChatMessage) or len(input_messages) > 0:
await thread.on_new_messages(input_messages)
@@ -206,11 +317,26 @@ class BaseAgent(SerializationMixin):
return self.name or self.id
def get_new_thread(self, **kwargs: Any) -> AgentThread:
"""Returns AgentThread instance that is compatible with the agent."""
"""Return a new AgentThread instance that is compatible with the agent.
Args:
kwargs: Additional keyword arguments passed to AgentThread.
Returns:
A new AgentThread instance configured with the agent's context provider.
"""
return AgentThread(**kwargs, context_provider=self.context_provider)
async def deserialize_thread(self, serialized_thread: Any, **kwargs: Any) -> AgentThread:
"""Deserializes the thread."""
"""Deserialize a thread from its serialized state.
Args:
serialized_thread: The serialized thread data.
kwargs: Additional keyword arguments.
Returns:
A new AgentThread instance restored from the serialized state.
"""
thread: AgentThread = self.get_new_thread()
await thread.update_from_thread_state(serialized_thread, **kwargs)
return thread
@@ -233,11 +359,29 @@ class BaseAgent(SerializationMixin):
description: The description for the tool. If None, uses the agent's description or empty string.
arg_name: The name of the function argument (default: "task").
arg_description: The description for the function argument.
If None, defaults to "Input for {self.display_name}".
If None, defaults to "Task for {tool_name}".
stream_callback: Optional callback for streaming responses. If provided, uses run_stream.
Returns:
An AIFunction that can be used as a tool by other agents.
Raises:
TypeError: If the agent does not implement AgentProtocol.
ValueError: If the agent tool name cannot be determined.
Examples:
.. code-block:: python
from agent_framework import ChatAgent
# Create an agent
agent = ChatAgent(chat_client=client, name="research-agent", description="Performs research tasks")
# Convert the agent to a tool
research_tool = agent.as_tool()
# Use the tool with another agent
coordinator = ChatAgent(chat_client=client, name="coordinator", tools=research_tool)
"""
# Verify that self implements AgentProtocol
if not isinstance(self, AgentProtocol):
@@ -318,7 +462,50 @@ class BaseAgent(SerializationMixin):
@use_agent_middleware
@use_agent_observability
class ChatAgent(BaseAgent):
"""A Chat Client Agent."""
"""A Chat Client Agent.
This is the primary agent implementation that uses a chat client to interact
with language models. It supports tools, context providers, middleware, and
both streaming and non-streaming responses.
Examples:
Basic usage:
.. code-block:: python
from agent_framework import ChatAgent
from agent_framework.clients import OpenAIChatClient
# Create a basic chat agent
client = OpenAIChatClient(model="gpt-4")
agent = ChatAgent(chat_client=client, name="assistant", description="A helpful assistant")
# Run the agent with a simple message
response = await agent.run("Hello, how are you?")
print(response.text)
With tools and streaming:
.. code-block:: python
# Create an agent with tools and instructions
def get_weather(location: str) -> str:
return f"The weather in {location} is sunny."
agent = ChatAgent(
chat_client=client,
name="weather-agent",
instructions="You are a weather assistant.",
tools=get_weather,
temperature=0.7,
max_tokens=500,
)
# Use streaming responses
async for update in agent.run_stream("What's the weather in Paris?"):
print(update.text, end="")
"""
AGENT_SYSTEM_NAME: ClassVar[str] = "microsoft.agent_framework"
@@ -331,6 +518,7 @@ class ChatAgent(BaseAgent):
name: str | None = None,
description: str | None = None,
chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None,
conversation_id: str | None = None,
context_providers: ContextProvider | list[ContextProvider] | AggregateContextProvider | None = None,
middleware: Middleware | list[Middleware] | None = None,
frequency_penalty: float | None = None,
@@ -355,43 +543,54 @@ class ChatAgent(BaseAgent):
request_kwargs: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
"""Create a ChatAgent.
"""Initialize a ChatAgent instance.
Remarks:
The set of attributes from frequency_penalty to additional_properties are used to
call the chat client, they can also be passed to both run methods.
Note:
The set of parameters from frequency_penalty to request_kwargs are used to
call the chat client. They can also be passed to both run methods.
When both are set, the ones passed to the run methods take precedence.
Args:
chat_client: The chat client to use for the agent.
instructions: Optional instructions for the agent.
These will be put into the messages sent to the chat client service as a system message.
id: The unique identifier for the agent, will be created automatically if not provided.
These will be put into the messages sent to the chat client service as a system message.
id: The unique identifier for the agent. Will be created automatically if not provided.
name: The name of the agent.
description: A brief description of the agent's purpose.
chat_message_store_factory: factory function to create an instance of ChatMessageStoreProtocol.
chat_message_store_factory: Factory function to create an instance of ChatMessageStoreProtocol.
If not provided, the default in-memory store will be used.
conversation_id: The conversation ID for service-managed threads.
Cannot be used together with chat_message_store_factory.
context_providers: The collection of multiple context providers to include during agent invocation.
middleware: List of middleware to intercept agent and function invocations.
frequency_penalty: the frequency penalty to use.
logit_bias: the logit bias to use.
frequency_penalty: The frequency penalty to use.
logit_bias: The logit bias to use.
max_tokens: The maximum number of tokens to generate.
metadata: additional metadata to include in the request.
metadata: Additional metadata to include in the request.
model: The model to use for the agent.
presence_penalty: the presence penalty to use.
response_format: the format of the response.
seed: the random seed to use.
stop: the stop sequence(s) for the request.
store: whether to store the response.
temperature: the sampling temperature to use.
tool_choice: the tool choice for the request.
tools: the tools to use for the request.
top_p: the nucleus sampling probability to use.
user: the user to associate with the request.
request_kwargs: a dictionary of other values that will be passed through
to the chat_client `get_response` and `get_streaming_response` methods.
kwargs: any additional keyword arguments. Will be stored as `additional_properties`
presence_penalty: The presence penalty to use.
response_format: The format of the response.
seed: The random seed to use.
stop: The stop sequence(s) for the request.
store: Whether to store the response.
temperature: The sampling temperature to use.
tool_choice: The tool choice for the request.
tools: The tools to use for the request.
top_p: The nucleus sampling probability to use.
user: The user to associate with the request.
request_kwargs: A dictionary of other values that will be passed through
to the chat_client ``get_response`` and ``get_streaming_response`` methods.
kwargs: Any additional keyword arguments. Will be stored as ``additional_properties``.
Raises:
AgentInitializationError: If both conversation_id and chat_message_store_factory are provided.
"""
if conversation_id is not None and chat_message_store_factory is not None:
raise AgentInitializationError(
"Cannot specify both conversation_id and chat_message_store_factory. "
"Use conversation_id for service-managed threads or chat_message_store_factory for local storage."
)
if not hasattr(chat_client, FUNCTION_INVOKING_CHAT_CLIENT_MARKER) and isinstance(chat_client, BaseChatClient):
logger.warning(
"The provided chat client does not support function invoking, this might limit agent capabilities."
@@ -417,6 +616,7 @@ class ChatAgent(BaseAgent):
agent_tools = [tool for tool in normalized_tools if not isinstance(tool, MCPTool)]
self.chat_options = ChatOptions(
model_id=model,
conversation_id=conversation_id,
frequency_penalty=frequency_penalty,
instructions=instructions,
logit_bias=logit_bias,
@@ -438,12 +638,16 @@ class ChatAgent(BaseAgent):
self._update_agent_name()
async def __aenter__(self) -> "Self":
"""Async context manager entry.
"""Enter the async context manager.
If any of the chat_client, local_mcp_tools, or context_providers are context managers,
If any of the chat_client or local_mcp_tools are context managers,
they will be entered into the async exit stack to ensure proper cleanup.
This list might be extended in the future.
Note:
This list might be extended in the future.
Returns:
The ChatAgent instance.
"""
for context_manager in chain([self.chat_client], self._local_mcp_tools):
if isinstance(context_manager, AbstractAsyncContextManager):
@@ -456,17 +660,22 @@ class ChatAgent(BaseAgent):
exc_val: BaseException | None,
exc_tb: Any,
) -> None:
"""Async context manager exit.
"""Exit the async context manager.
Close the async exit stack to ensure all context managers are exited properly.
Args:
exc_type: The exception type if an exception was raised, None otherwise.
exc_val: The exception value if an exception was raised, None otherwise.
exc_tb: The exception traceback if an exception was raised, None otherwise.
"""
await self._async_exit_stack.aclose()
def _update_agent_name(self) -> None:
"""Update the agent name in a chat client.
"""Update the agent name in the chat client.
Checks if there is a agent name, the implementation
should check if there is already a agent name defined, and if not
Checks if the chat client supports agent name updates. The implementation
should check if there is already an agent name defined, and if not
set it to this value.
"""
if hasattr(self.chat_client, "_update_agent_name") and callable(self.chat_client._update_agent_name): # type: ignore[reportAttributeAccessIssue, attr-defined]
@@ -501,33 +710,36 @@ class ChatAgent(BaseAgent):
) -> AgentRunResponse:
"""Run the agent with the given messages and options.
Remarks:
Since you won't always call the agent.run directly, but it get's called
through orchestration, it is advised to set your default values for
Note:
Since you won't always call ``agent.run()`` directly (it gets called
through workflows), it is advised to set your default values for
all the chat client parameters in the agent constructor.
If both parameters are used, the ones passed to the run methods take precedence.
Args:
messages: The messages to process.
thread: The thread to use for the agent.
frequency_penalty: the frequency penalty to use.
logit_bias: the logit bias to use.
frequency_penalty: The frequency penalty to use.
logit_bias: The logit bias to use.
max_tokens: The maximum number of tokens to generate.
metadata: additional metadata to include in the request.
metadata: Additional metadata to include in the request.
model: The model to use for the agent.
presence_penalty: the presence penalty to use.
response_format: the format of the response.
seed: the random seed to use.
stop: the stop sequence(s) for the request.
store: whether to store the response.
temperature: the sampling temperature to use.
tool_choice: the tool choice for the request.
tools: the tools to use for the request.
top_p: the nucleus sampling probability to use.
user: the user to associate with the request.
additional_properties: additional properties to include in the request.
presence_penalty: The presence penalty to use.
response_format: The format of the response.
seed: The random seed to use.
stop: The stop sequence(s) for the request.
store: Whether to store the response.
temperature: The sampling temperature to use.
tool_choice: The tool choice for the request.
tools: The tools to use for the request.
top_p: The nucleus sampling probability to use.
user: The user to associate with the request.
additional_properties: Additional properties to include in the request.
kwargs: Additional keyword arguments for the agent.
will only be passed to functions that are called.
Will only be passed to functions that are called.
Returns:
An AgentRunResponse containing the agent's response.
"""
input_messages = self._normalize_messages(messages)
thread, run_chat_options, thread_messages = await self._prepare_thread_and_messages(
@@ -627,34 +839,36 @@ class ChatAgent(BaseAgent):
) -> AsyncIterable[AgentRunResponseUpdate]:
"""Stream the agent with the given messages and options.
Remarks:
Since you won't always call the agent.run_stream directly, but it get's called
through orchestration, it is advised to set your default values for
Note:
Since you won't always call ``agent.run_stream()`` directly (it gets called
through orchestration), it is advised to set your default values for
all the chat client parameters in the agent constructor.
If both parameters are used, the ones passed to the run methods take precedence.
Args:
messages: The messages to process.
thread: The thread to use for the agent.
frequency_penalty: the frequency penalty to use.
logit_bias: the logit bias to use.
frequency_penalty: The frequency penalty to use.
logit_bias: The logit bias to use.
max_tokens: The maximum number of tokens to generate.
metadata: additional metadata to include in the request.
metadata: Additional metadata to include in the request.
model: The model to use for the agent.
presence_penalty: the presence penalty to use.
response_format: the format of the response.
seed: the random seed to use.
stop: the stop sequence(s) for the request.
store: whether to store the response.
temperature: the sampling temperature to use.
tool_choice: the tool choice for the request.
tools: the tools to use for the request.
top_p: the nucleus sampling probability to use.
user: the user to associate with the request.
additional_properties: additional properties to include in the request.
kwargs: any additional keyword arguments.
will only be passed to functions that are called.
presence_penalty: The presence penalty to use.
response_format: The format of the response.
seed: The random seed to use.
stop: The stop sequence(s) for the request.
store: Whether to store the response.
temperature: The sampling temperature to use.
tool_choice: The tool choice for the request.
tools: The tools to use for the request.
top_p: The nucleus sampling probability to use.
user: The user to associate with the request.
additional_properties: Additional properties to include in the request.
kwargs: Any additional keyword arguments.
Will only be passed to functions that are called.
Yields:
AgentRunResponseUpdate objects containing chunks of the agent's response.
"""
input_messages = self._normalize_messages(messages)
thread, run_chat_options, thread_messages = await self._prepare_thread_and_messages(
@@ -737,24 +951,35 @@ class ChatAgent(BaseAgent):
If you supply a service_thread_id, the thread will be marked as service managed.
If you don't supply a service_thread_id but have a conversation_id configured on the agent,
that conversation_id will be used to create a service-managed thread.
If you don't supply a service_thread_id but have a chat_message_store_factory configured on the agent,
that factory will be used to create a message store for the thread and the thread will be
managed locally.
When neither is present, the thread will be created without a service ID or message store,
this will be updated based on usage, when you run the agent with this thread.
If you run with store=True, the response will respond with a thread_id and that will be set.
Otherwise a messages store is created from the default factory.
When neither is present, the thread will be created without a service ID or message store.
This will be updated based on usage when you run the agent with this thread.
If you run with ``store=True``, the response will include a thread_id and that will be set.
Otherwise a message store is created from the default factory.
Args:
service_thread_id: Optional service managed thread ID.
kwargs: not used at present.
kwargs: Not used at present.
Returns:
A new AgentThread instance.
"""
if service_thread_id is not None:
return AgentThread(
service_thread_id=service_thread_id,
context_provider=self.context_provider,
)
if self.chat_options.conversation_id is not None:
return AgentThread(
service_thread_id=self.chat_options.conversation_id,
context_provider=self.context_provider,
)
if self.chat_message_store_factory is not None:
return AgentThread(
message_store=self.chat_message_store_factory(),
@@ -800,19 +1025,23 @@ class ChatAgent(BaseAgent):
thread: AgentThread | None,
input_messages: list[ChatMessage] | None = None,
) -> tuple[AgentThread, ChatOptions, list[ChatMessage]]:
"""Prepare the messages for agent execution.
"""Prepare the thread and messages for agent execution.
Also updates the chat_options of the agent, with
This method prepares the conversation thread, merges context provider data,
and assembles the final message list for the chat client.
Args:
thread: The conversation thread.
input_messages: Messages to process.
Returns:
The validated thread and normalized messages.
A tuple containing:
- The validated or created thread
- The merged chat options
- The complete list of messages for the chat client
Raises:
AgentExecutionException: If the thread is not of the expected type.
AgentExecutionException: If the conversation IDs on the thread and agent don't match.
"""
chat_options = copy(self.chat_options) if self.chat_options else ChatOptions()
thread = thread or self.get_new_thread()
@@ -852,4 +1081,9 @@ class ChatAgent(BaseAgent):
return thread, chat_options, thread_messages
def _get_agent_name(self) -> str:
"""Get the agent name for message attribution.
Returns:
The agent's name, or 'UnnamedAgent' if no name is set.
"""
return self.name or "UnnamedAgent"
+460 -147
View File
@@ -49,7 +49,44 @@ __all__ = [
@runtime_checkable
class ChatClientProtocol(Protocol):
"""A protocol for a chat client that can generate responses."""
"""A protocol for a chat client that can generate responses.
This protocol defines the interface that all chat clients must implement,
including methods for generating both streaming and non-streaming responses.
Note:
Protocols use structural subtyping (duck typing). Classes don't need
to explicitly inherit from this protocol to be considered compatible.
Examples:
.. code-block:: python
from agent_framework import ChatClientProtocol, ChatResponse, ChatMessage
# Any class implementing the required methods is compatible
class CustomChatClient:
@property
def additional_properties(self) -> dict[str, Any]:
return {}
async def get_response(self, messages, **kwargs):
# Your custom implementation
return ChatResponse(messages=[], response_id="custom")
def get_streaming_response(self, messages, **kwargs):
async def _stream():
from agent_framework import ChatResponseUpdate
yield ChatResponseUpdate()
return _stream()
# Verify the instance satisfies the protocol
client = CustomChatClient()
assert isinstance(client, ChatClientProtocol)
"""
@property
def additional_properties(self) -> dict[str, Any]:
@@ -75,41 +112,41 @@ class ChatClientProtocol(Protocol):
tools: ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
| list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| None = None,
top_p: float | None = None,
user: str | None = None,
additional_properties: dict[str, Any] | None = None,
**kwargs: Any,
) -> ChatResponse:
"""Sends input and returns the response.
"""Send input and return the response.
Args:
messages: The sequence of input messages to send.
response_format: the format of the response.
frequency_penalty: the frequency penalty to use.
logit_bias: the logit bias to use.
frequency_penalty: The frequency penalty to use.
logit_bias: The logit bias to use.
max_tokens: The maximum number of tokens to generate.
metadata: additional metadata to include in the request.
metadata: Additional metadata to include in the request.
model: The model to use for the agent.
presence_penalty: the presence penalty to use.
seed: the random seed to use.
stop: the stop sequence(s) for the request.
store: whether to store the response.
temperature: the sampling temperature to use.
tool_choice: the tool choice for the request.
tools: the tools to use for the request.
top_p: the nucleus sampling probability to use.
user: the user to associate with the request.
additional_properties: additional properties to include in the request
kwargs: any additional keyword arguments,
will only be passed to functions that are called.
presence_penalty: The presence penalty to use.
response_format: The format of the response.
seed: The random seed to use.
stop: The stop sequence(s) for the request.
store: Whether to store the response.
temperature: The sampling temperature to use.
tool_choice: The tool choice for the request.
tools: The tools to use for the request.
top_p: The nucleus sampling probability to use.
user: The user to associate with the request.
additional_properties: Additional properties to include in the request.
kwargs: Any additional keyword arguments.
Will only be passed to functions that are called.
Returns:
The response messages generated by the client.
Raises:
ValueError: If the input message sequence is `None`.
ValueError: If the input message sequence is ``None``.
"""
...
@@ -132,42 +169,42 @@ class ChatClientProtocol(Protocol):
tools: ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
| list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| None = None,
top_p: float | None = None,
user: str | None = None,
additional_properties: dict[str, Any] | None = None,
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
"""Sends input messages and streams the response.
"""Send input messages and stream the response.
Args:
messages: The sequence of input messages to send.
frequency_penalty: the frequency penalty to use.
logit_bias: the logit bias to use.
frequency_penalty: The frequency penalty to use.
logit_bias: The logit bias to use.
max_tokens: The maximum number of tokens to generate.
metadata: additional metadata to include in the request.
metadata: Additional metadata to include in the request.
model: The model to use for the agent.
presence_penalty: the presence penalty to use.
response_format: the format of the response.
seed: the random seed to use.
stop: the stop sequence(s) for the request.
store: whether to store the response.
temperature: the sampling temperature to use.
tool_choice: the tool choice for the request.
tools: the tools to use for the request.
top_p: the nucleus sampling probability to use.
user: the user to associate with the request.
additional_properties: additional properties to include in the request
kwargs: any additional keyword arguments,
will only be passed to functions that are called.
presence_penalty: The presence penalty to use.
response_format: The format of the response.
seed: The random seed to use.
stop: The stop sequence(s) for the request.
store: Whether to store the response.
temperature: The sampling temperature to use.
tool_choice: The tool choice for the request.
tools: The tools to use for the request.
top_p: The nucleus sampling probability to use.
user: The user to associate with the request.
additional_properties: Additional properties to include in the request.
kwargs: Any additional keyword arguments.
Will only be passed to functions that are called.
Yields:
An async iterable of chat response updates containing the content of the response messages
generated by the client.
ChatResponseUpdate: An async iterable of chat response updates containing
the content of the response messages generated by the client.
Raises:
ValueError: If the input message sequence is `None`.
ValueError: If the input message sequence is ``None``.
"""
...
@@ -176,7 +213,14 @@ class ChatClientProtocol(Protocol):
def prepare_messages(messages: str | ChatMessage | list[str] | list[ChatMessage]) -> list[ChatMessage]:
"""Turn the allowed input into a list of chat messages."""
"""Convert various message input formats into a list of ChatMessage objects.
Args:
messages: The input messages in various supported formats.
Returns:
A list of ChatMessage objects.
"""
if isinstance(messages, str):
return [ChatMessage(role="user", text=messages)]
if isinstance(messages, ChatMessage):
@@ -189,8 +233,150 @@ def prepare_messages(messages: str | ChatMessage | list[str] | list[ChatMessage]
return return_messages
def merge_chat_options(
*,
base_chat_options: ChatOptions | Any | None,
model: str | None = None,
frequency_penalty: float | None = None,
logit_bias: dict[str | int, float] | None = None,
max_tokens: int | None = None,
metadata: dict[str, Any] | None = None,
presence_penalty: float | None = None,
response_format: type[BaseModel] | None = None,
seed: int | None = None,
stop: str | Sequence[str] | None = None,
store: bool | None = None,
temperature: float | None = None,
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto",
tools: list[ToolProtocol | dict[str, Any] | Callable[..., Any]] | None = None,
top_p: float | None = None,
user: str | None = None,
additional_properties: dict[str, Any] | None = None,
) -> ChatOptions:
"""Merge base chat options with direct parameters to create a new ChatOptions instance.
When both base_chat_options and individual parameters are provided, the individual
parameters take precedence and override the corresponding values in base_chat_options.
Tools from both sources are combined into a single list.
Args:
base_chat_options: Optional base ChatOptions to merge with direct parameters.
model: The model to use for the agent.
frequency_penalty: The frequency penalty to use.
logit_bias: The logit bias to use.
max_tokens: The maximum number of tokens to generate.
metadata: Additional metadata to include in the request.
presence_penalty: The presence penalty to use.
response_format: The format of the response.
seed: The random seed to use.
stop: The stop sequence(s) for the request.
store: Whether to store the response.
temperature: The sampling temperature to use.
tool_choice: The tool choice for the request.
tools: The normalized tools to use for the request.
top_p: The nucleus sampling probability to use.
user: The user to associate with the request.
additional_properties: Additional properties to include in the request.
Returns:
A new ChatOptions instance with merged values.
Raises:
TypeError: If base_chat_options is not None and not an instance of ChatOptions.
"""
# Validate base_chat_options type if provided
if base_chat_options is not None and not isinstance(base_chat_options, ChatOptions):
raise TypeError("chat_options must be an instance of ChatOptions")
if base_chat_options is not None:
# Combine tools from both sources
base_tools = base_chat_options.tools or []
combined_tools = [*base_tools, *(tools or [])] if tools else base_tools
# Create new chat_options, using direct parameters when provided, otherwise fall back to base
return ChatOptions(
model_id=model if model is not None else base_chat_options.model_id,
frequency_penalty=(
frequency_penalty if frequency_penalty is not None else base_chat_options.frequency_penalty
),
logit_bias=logit_bias if logit_bias is not None else base_chat_options.logit_bias,
max_tokens=max_tokens if max_tokens is not None else base_chat_options.max_tokens,
metadata=metadata if metadata is not None else base_chat_options.metadata,
presence_penalty=(presence_penalty if presence_penalty is not None else base_chat_options.presence_penalty),
response_format=(response_format if response_format is not None else base_chat_options.response_format),
seed=seed if seed is not None else base_chat_options.seed,
stop=stop if stop is not None else base_chat_options.stop,
store=store if store is not None else base_chat_options.store,
temperature=temperature if temperature is not None else base_chat_options.temperature,
top_p=top_p if top_p is not None else base_chat_options.top_p,
tool_choice=(
tool_choice if (tool_choice is not None and tool_choice != "auto") else base_chat_options.tool_choice # type: ignore[arg-type]
),
tools=combined_tools or None,
user=user if user is not None else base_chat_options.user,
additional_properties=(
additional_properties if additional_properties is not None else base_chat_options.additional_properties
),
conversation_id=base_chat_options.conversation_id,
)
# No base options, create from direct parameters only
return ChatOptions(
model_id=model,
frequency_penalty=frequency_penalty,
logit_bias=logit_bias,
max_tokens=max_tokens,
metadata=metadata,
presence_penalty=presence_penalty,
response_format=response_format,
seed=seed,
stop=stop,
store=store,
temperature=temperature,
top_p=top_p,
tool_choice=tool_choice,
tools=tools,
user=user,
additional_properties=additional_properties or {},
)
class BaseChatClient(SerializationMixin, ABC):
"""Base class for chat clients."""
"""Base class for chat clients.
This abstract base class provides core functionality for chat client implementations,
including middleware support, message preparation, and tool normalization.
Note:
BaseChatClient cannot be instantiated directly as it's an abstract base class.
Subclasses must implement ``_inner_get_response()`` and ``_inner_get_streaming_response()``.
Examples:
.. code-block:: python
from agent_framework import BaseChatClient, ChatResponse, ChatMessage
from collections.abc import AsyncIterable
class CustomChatClient(BaseChatClient):
async def _inner_get_response(self, *, messages, chat_options, **kwargs):
# Your custom implementation
return ChatResponse(
messages=[ChatMessage(role="assistant", text="Hello!")], response_id="custom-response"
)
async def _inner_get_streaming_response(self, *, messages, chat_options, **kwargs):
# Your custom streaming implementation
from agent_framework import ChatResponseUpdate
yield ChatResponseUpdate(role="assistant", contents=[{"type": "text", "text": "Hello!"}])
# Create an instance of your custom client
client = CustomChatClient()
# Use the client to get responses
response = await client.get_response("Hello, how are you?")
"""
OTEL_PROVIDER_NAME: ClassVar[str] = "unknown"
DEFAULT_EXCLUDE: ClassVar[set[str]] = {"additional_properties"}
@@ -210,12 +396,12 @@ class BaseChatClient(SerializationMixin, ABC):
additional_properties: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
"""Initialize BaseChatClient.
"""Initialize a BaseChatClient instance.
Args:
additional_properties: Additional properties for the client.
middleware: Middleware for the client.
**kwargs: Additional keyword arguments (merged into additional_properties).
additional_properties: Additional properties for the client.
kwargs: Additional keyword arguments (merged into additional_properties).
"""
# Merge kwargs into additional_properties
self.additional_properties = additional_properties or {}
@@ -247,7 +433,17 @@ class BaseChatClient(SerializationMixin, ABC):
def prepare_messages(
self, messages: str | ChatMessage | list[str] | list[ChatMessage], chat_options: ChatOptions
) -> MutableSequence[ChatMessage]:
"""Turn the allowed input into a list of chat messages."""
"""Convert various message input formats into a list of ChatMessage objects.
Prepends system instructions if present in chat_options.
Args:
messages: The input messages in various supported formats.
chat_options: The chat options containing instructions and other settings.
Returns:
A mutable sequence of ChatMessage objects.
"""
if chat_options.instructions:
system_msg = ChatMessage(role="system", text=chat_options.instructions)
return [system_msg, *prepare_messages(messages)]
@@ -265,19 +461,38 @@ class BaseChatClient(SerializationMixin, ABC):
return {k: v for k, v in kwargs.items() if not k.startswith("_")}
@staticmethod
def _normalize_tools(
async def _normalize_tools(
tools: ToolProtocol
| MutableMapping[str, Any]
| Callable[..., Any]
| list[ToolProtocol | MutableMapping[str, Any] | Callable[..., Any]]
| Sequence[ToolProtocol | MutableMapping[str, Any] | Callable[..., Any]]
| None = None,
) -> list[ToolProtocol | dict[str, Any] | Callable[..., Any]]:
"""Normalize the tools input to a list of tools."""
"""Normalize tools input to a consistent list format.
Expands MCP tools to their constituent functions, connecting them if needed.
Args:
tools: The tools in various supported formats.
Returns:
A normalized list of tools.
"""
from typing import cast
final_tools: list[ToolProtocol | dict[str, Any] | Callable[..., Any]] = []
if not tools:
return final_tools
for tool in tools if isinstance(tools, list) else [tools]: # type: ignore[reportUnknownType]
# Use cast when a sequence is passed (likely already a list)
tools_list = (
cast(list[ToolProtocol | MutableMapping[str, Any] | Callable[..., Any]], tools)
if isinstance(tools, Sequence) and not isinstance(tools, (str, bytes))
else [tools]
)
for tool in tools_list: # type: ignore[reportUnknownType]
if isinstance(tool, MCPTool):
if not tool.is_connected:
await tool.connect()
final_tools.extend(tool.functions) # type: ignore
continue
final_tools.append(tool) # type: ignore
@@ -352,7 +567,7 @@ class BaseChatClient(SerializationMixin, ABC):
tools: ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
| list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| None = None,
top_p: float | None = None,
user: str | None = None,
@@ -361,54 +576,64 @@ class BaseChatClient(SerializationMixin, ABC):
) -> ChatResponse:
"""Get a response from a chat client.
When both ``chat_options`` (in kwargs) and individual parameters are provided,
the individual parameters take precedence and override the corresponding values
in ``chat_options``. Tools from both sources are combined into a single list.
Args:
messages: the message or messages to send to the model
frequency_penalty: the frequency penalty to use.
logit_bias: the logit bias to use.
messages: The message or messages to send to the model.
frequency_penalty: The frequency penalty to use.
logit_bias: The logit bias to use.
max_tokens: The maximum number of tokens to generate.
metadata: additional metadata to include in the request.
metadata: Additional metadata to include in the request.
model: The model to use for the agent.
presence_penalty: the presence penalty to use.
response_format: the format of the response.
seed: the random seed to use.
stop: the stop sequence(s) for the request.
store: whether to store the response.
temperature: the sampling temperature to use.
tool_choice: the tool choice for the request.
tools: the tools to use for the request.
top_p: the nucleus sampling probability to use.
user: the user to associate with the request.
additional_properties: additional properties to include in the request.
kwargs: any additional keyword arguments,
will only be passed to functions that are called.
presence_penalty: The presence penalty to use.
response_format: The format of the response.
seed: The random seed to use.
stop: The stop sequence(s) for the request.
store: Whether to store the response.
temperature: The sampling temperature to use.
tool_choice: The tool choice for the request.
tools: The tools to use for the request.
top_p: The nucleus sampling probability to use.
user: The user to associate with the request.
additional_properties: Additional properties to include in the request.
kwargs: Any additional keyword arguments.
May include ``chat_options`` which provides base values that can be overridden by direct parameters.
Returns:
A chat response from the model.
"""
# Should we merge chat options instead of ignoring the input params?
if "chat_options" in kwargs:
chat_options = kwargs.pop("chat_options")
if not isinstance(chat_options, ChatOptions):
raise TypeError("chat_options must be an instance of ChatOptions")
else:
chat_options = ChatOptions(
model_id=model,
frequency_penalty=frequency_penalty,
logit_bias=logit_bias,
max_tokens=max_tokens,
metadata=metadata,
presence_penalty=presence_penalty,
response_format=response_format,
seed=seed,
stop=stop,
store=store,
temperature=temperature,
top_p=top_p,
tool_choice=tool_choice,
tools=self._normalize_tools(tools), # type: ignore
user=user,
additional_properties=additional_properties or {},
# Normalize tools and merge with base chat_options
normalized_tools = await self._normalize_tools(tools)
chat_options = merge_chat_options(
base_chat_options=kwargs.pop("chat_options", None),
model=model,
frequency_penalty=frequency_penalty,
logit_bias=logit_bias,
max_tokens=max_tokens,
metadata=metadata,
presence_penalty=presence_penalty,
response_format=response_format,
seed=seed,
stop=stop,
store=store,
temperature=temperature,
tool_choice=tool_choice,
tools=normalized_tools,
top_p=top_p,
user=user,
additional_properties=additional_properties,
)
# Validate that store is True when conversation_id is set
if chat_options.conversation_id is not None and chat_options.store is not True:
logger.warning(
"When conversation_id is set, store must be True for service-managed threads. "
"Automatically setting store=True."
)
chat_options.store = True
prepped_messages = self.prepare_messages(messages, chat_options)
self._prepare_tool_choice(chat_options=chat_options)
@@ -434,7 +659,7 @@ class BaseChatClient(SerializationMixin, ABC):
tools: ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
| list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| None = None,
top_p: float | None = None,
user: str | None = None,
@@ -443,53 +668,64 @@ class BaseChatClient(SerializationMixin, ABC):
) -> AsyncIterable[ChatResponseUpdate]:
"""Get a streaming response from a chat client.
When both ``chat_options`` (in kwargs) and individual parameters are provided,
the individual parameters take precedence and override the corresponding values
in ``chat_options``. Tools from both sources are combined into a single list.
Args:
messages: the message or messages to send to the model
frequency_penalty: the frequency penalty to use
logit_bias: the logit bias to use
messages: The message or messages to send to the model.
frequency_penalty: The frequency penalty to use.
logit_bias: The logit bias to use.
max_tokens: The maximum number of tokens to generate.
metadata: additional metadata to include in the request.
metadata: Additional metadata to include in the request.
model: The model to use for the agent.
presence_penalty: the presence penalty to use.
response_format: the format of the response.
seed: the random seed to use.
stop: the stop sequence(s) for the request.
store: whether to store the response.
temperature: the sampling temperature to use.
tool_choice: the tool choice for the request.
tools: the tools to use for the request.
top_p: the nucleus sampling probability to use.
user: the user to associate with the request.
additional_properties: additional properties to include in the request
kwargs: any additional keyword arguments
presence_penalty: The presence penalty to use.
response_format: The format of the response.
seed: The random seed to use.
stop: The stop sequence(s) for the request.
store: Whether to store the response.
temperature: The sampling temperature to use.
tool_choice: The tool choice for the request.
tools: The tools to use for the request.
top_p: The nucleus sampling probability to use.
user: The user to associate with the request.
additional_properties: Additional properties to include in the request.
kwargs: Any additional keyword arguments.
May include ``chat_options`` which provides base values that can be overridden by direct parameters.
Yields:
A stream representing the response(s) from the LLM.
ChatResponseUpdate: A stream representing the response(s) from the LLM.
"""
# Should we merge chat options instead of ignoring the input params?
if "chat_options" in kwargs:
chat_options = kwargs.pop("chat_options")
if not isinstance(chat_options, ChatOptions):
raise TypeError("chat_options must be an instance of ChatOptions")
else:
chat_options = ChatOptions(
model_id=model,
frequency_penalty=frequency_penalty,
logit_bias=logit_bias,
max_tokens=max_tokens,
metadata=metadata,
presence_penalty=presence_penalty,
response_format=response_format,
seed=seed,
stop=stop,
store=store,
temperature=temperature,
top_p=top_p,
tool_choice=tool_choice,
tools=self._normalize_tools(tools),
user=user,
additional_properties=additional_properties or {},
# Normalize tools and merge with base chat_options
normalized_tools = await self._normalize_tools(tools)
chat_options = merge_chat_options(
base_chat_options=kwargs.pop("chat_options", None),
model=model,
frequency_penalty=frequency_penalty,
logit_bias=logit_bias,
max_tokens=max_tokens,
metadata=metadata,
presence_penalty=presence_penalty,
response_format=response_format,
seed=seed,
stop=stop,
store=store,
temperature=temperature,
tool_choice=tool_choice,
tools=normalized_tools,
top_p=top_p,
user=user,
additional_properties=additional_properties,
)
# Validate that store is True when conversation_id is set
if chat_options.conversation_id is not None and chat_options.store is not True:
logger.warning(
"When conversation_id is set, store must be True for service-managed threads. "
"Automatically setting store=True."
)
chat_options.store = True
prepped_messages = self.prepare_messages(messages, chat_options)
self._prepare_tool_choice(chat_options=chat_options)
@@ -502,8 +738,11 @@ class BaseChatClient(SerializationMixin, ABC):
def _prepare_tool_choice(self, chat_options: ChatOptions) -> None:
"""Prepare the tools and tool choice for the chat options.
This function should be overridden by subclasses to customize tool handling.
Because it currently parses only AIFunctions.
This function should be overridden by subclasses to customize tool handling,
as it currently parses only AIFunctions.
Args:
chat_options: The chat options to prepare.
"""
chat_tool_mode = chat_options.tool_choice
if chat_tool_mode is None or chat_tool_mode == ToolMode.NONE or chat_tool_mode == "none":
@@ -520,49 +759,123 @@ class BaseChatClient(SerializationMixin, ABC):
Override this in the subclass to return the proper URL.
If the service does not have a URL, return None.
Returns:
The service URL or 'Unknown' if not implemented.
"""
return "Unknown"
def create_agent(
self,
*,
id: str | None = None,
name: str | None = None,
description: str | None = None,
instructions: str | None = None,
tools: ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
| list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| None = None,
chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None,
context_providers: ContextProvider | list[ContextProvider] | AggregateContextProvider | None = None,
middleware: Middleware | list[Middleware] | None = None,
frequency_penalty: float | None = None,
logit_bias: dict[str | int, float] | None = None,
max_tokens: int | None = None,
metadata: dict[str, Any] | None = None,
model: str | None = None,
presence_penalty: float | None = None,
response_format: type[BaseModel] | None = None,
seed: int | None = None,
stop: str | Sequence[str] | None = None,
store: bool | None = None,
temperature: float | None = None,
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto",
tools: ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| None = None,
top_p: float | None = None,
user: str | None = None,
request_kwargs: dict[str, Any] | None = None,
**kwargs: Any,
) -> "ChatAgent":
"""Create an agent with the given name and instructions.
"""Create a ChatAgent with this client.
This is a convenience method that creates a ChatAgent instance with this
chat client already configured.
Args:
id: The unique identifier for the agent. Will be created automatically if not provided.
name: The name of the agent.
instructions: The instructions for the agent.
tools: Optional list of tools to associate with the agent.
description: A brief description of the agent's purpose.
instructions: Optional instructions for the agent.
These will be put into the messages sent to the chat client service as a system message.
chat_message_store_factory: Factory function to create an instance of ChatMessageStoreProtocol.
If not provided, the default in-memory store will be used.
context_providers: Context providers to include during agent invocation.
middleware: List of middleware to intercept agent and function invocations.
**kwargs: Additional keyword arguments to pass to the agent.
See ChatAgent for all the available options.
frequency_penalty: The frequency penalty to use.
logit_bias: The logit bias to use.
max_tokens: The maximum number of tokens to generate.
metadata: Additional metadata to include in the request.
model: The model to use for the agent.
presence_penalty: The presence penalty to use.
response_format: The format of the response.
seed: The random seed to use.
stop: The stop sequence(s) for the request.
store: Whether to store the response.
temperature: The sampling temperature to use.
tool_choice: The tool choice for the request.
tools: The tools to use for the request.
top_p: The nucleus sampling probability to use.
user: The user to associate with the request.
request_kwargs: A dictionary of other values that will be passed through
to the chat_client ``get_response`` and ``get_streaming_response`` methods.
kwargs: Any additional keyword arguments. Will be stored as ``additional_properties``.
Returns:
An instance of ChatAgent.
A ChatAgent instance configured with this chat client.
Examples:
.. code-block:: python
from agent_framework.clients import OpenAIChatClient
# Create a client
client = OpenAIChatClient(model="gpt-4")
# Create an agent using the convenience method
agent = client.create_agent(
name="assistant", instructions="You are a helpful assistant.", temperature=0.7
)
# Run the agent
response = await agent.run("Hello!")
"""
from ._agents import ChatAgent
return ChatAgent(
chat_client=self,
id=id,
name=name,
description=description,
instructions=instructions,
tools=tools,
chat_message_store_factory=chat_message_store_factory,
context_providers=context_providers,
middleware=middleware,
frequency_penalty=frequency_penalty,
logit_bias=logit_bias,
max_tokens=max_tokens,
metadata=metadata,
model=model,
presence_penalty=presence_penalty,
response_format=response_format,
seed=seed,
stop=stop,
store=store,
temperature=temperature,
tool_choice=tool_choice,
tools=tools,
top_p=top_p,
user=user,
request_kwargs=request_kwargs,
**kwargs,
)
+240 -54
View File
@@ -243,7 +243,22 @@ def _normalize_mcp_name(name: str) -> str:
class MCPTool:
"""Main MCP class, to initialize use one of the subclasses."""
"""Main MCP class for connecting to Model Context Protocol servers.
This is the base class for MCP tool implementations. It handles connection management,
tool and prompt loading, and communication with MCP servers.
Note:
MCPTool cannot be instantiated directly. Use one of the subclasses:
MCPStdioTool, MCPStreamableHTTPTool, or MCPWebsocketTool.
Examples:
See the subclass documentation for usage examples:
- :class:`MCPStdioTool` for stdio-based MCP servers
- :class:`MCPStreamableHTTPTool` for HTTP-based MCP servers
- :class:`MCPWebsocketTool` for WebSocket-based MCP servers
"""
def __init__(
self,
@@ -256,7 +271,18 @@ class MCPTool:
request_timeout: int | None = None,
chat_client: "ChatClientProtocol | None" = None,
) -> None:
"""Initialize the MCP Plugin Base."""
"""Initialize the MCP Tool base.
Args:
name: The name of the MCP tool.
description: The description of the tool.
additional_properties: Additional properties for the tool.
load_tools: Whether to automatically load tools from the MCP server.
load_prompts: Whether to automatically load prompts from the MCP server.
session: Pre-existing session to use for the MCP connection.
request_timeout: The default timeout in seconds for all requests.
chat_client: The chat client to use for sampling callbacks.
"""
self.name = name
self.description = description or ""
self.additional_properties = additional_properties
@@ -273,7 +299,14 @@ class MCPTool:
return f"MCPTool(name={self.name}, description={self.description})"
async def connect(self) -> None:
"""Connect to the MCP server."""
"""Connect to the MCP server.
Establishes a connection to the MCP server, initializes the session,
and loads tools and prompts if configured to do so.
Raises:
ToolException: If connection or session initialization fails.
"""
if not self.session:
try:
transport = await self._exit_stack.enter_async_context(self.get_mcp_client())
@@ -324,9 +357,19 @@ class MCPTool:
"""Callback function for sampling.
This function is called when the MCP server needs to get a message completed.
It uses the configured chat client to generate responses.
This is a simple version of this function, it can be overridden to allow more complex sampling.
It get's added to the session at initialization time, so overriding it is the best way to do this.
Note:
This is a simple version of this function. It can be overridden to allow
more complex sampling. It gets added to the session at initialization time,
so overriding it is the best way to customize this behavior.
Args:
context: The request context from the MCP server.
params: The message creation request parameters.
Returns:
Either a CreateMessageResult with the generated message or ErrorData if generation fails.
"""
if not self.chat_client:
return types.ErrorData(
@@ -377,7 +420,11 @@ class MCPTool:
This function is called when the MCP Server sends a log message.
By default it will log the message to the logger with the level set in the params.
Please subclass the MCP*Plugin and override this function if you want to adapt the behavior.
Note:
Subclass MCPTool and override this function if you want to adapt the behavior.
Args:
params: The logging message notification parameters from the MCP server.
"""
logger.log(LOG_LEVEL_MAPPING[params.level], params.data)
@@ -387,12 +434,17 @@ class MCPTool:
) -> None:
"""Handle messages from the MCP server.
By default this function will handle exceptions on the server, by logging those.
By default this function will handle exceptions on the server by logging them,
and it will trigger a reload of the tools and prompts when the list changed
notification is received.
And it will trigger a reload of the tools and prompts when the list changed notification is received.
Note:
If you want to extend this behavior, you can subclass MCPTool and override
this function. If you want to keep the default behavior, make sure to call
``super().message_handler(message)``.
If you want to extend this behavior you can subclass the MCPPlugin and override this function,
if you want to keep the default behavior, make sure to call `super().message_handler(message)`.
Args:
message: The message from the MCP server (request responder, notification, or exception).
"""
if isinstance(message, Exception):
logger.error("Error from MCP server: %s", message, exc_info=message)
@@ -407,7 +459,14 @@ class MCPTool:
logger.debug("Unhandled notification: %s", message.root.method)
async def load_prompts(self) -> None:
"""Load prompts from the MCP server."""
"""Load prompts from the MCP server.
Retrieves available prompts from the connected MCP server and converts
them into AIFunction instances.
Raises:
ToolExecutionException: If the MCP server is not connected.
"""
if not self.session:
raise ToolExecutionException("MCP server not connected, please call connect() before using this method.")
try:
@@ -430,7 +489,14 @@ class MCPTool:
self.functions.append(func)
async def load_tools(self) -> None:
"""Load tools from the MCP server."""
"""Load tools from the MCP server.
Retrieves available tools from the connected MCP server and converts
them into AIFunction instances.
Raises:
ToolExecutionException: If the MCP server is not connected.
"""
if not self.session:
raise ToolExecutionException("MCP server not connected, please call connect() before using this method.")
try:
@@ -454,18 +520,37 @@ class MCPTool:
self.functions.append(func)
async def close(self) -> None:
"""Disconnect from the MCP server."""
"""Disconnect from the MCP server.
Closes the connection and cleans up resources.
"""
await self._exit_stack.aclose()
self.session = None
self.is_connected = False
@abstractmethod
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
"""Get an MCP client."""
"""Get an MCP client.
Returns:
An async context manager for the MCP client transport.
"""
pass
async def call_tool(self, tool_name: str, **kwargs: Any) -> list[Contents]:
"""Call a tool with the given arguments."""
"""Call a tool with the given arguments.
Args:
tool_name: The name of the tool to call.
kwargs: Arguments to pass to the tool.
Returns:
A list of content items returned by the tool.
Raises:
ToolExecutionException: If the MCP server is not connected, tools are not loaded,
or the tool call fails.
"""
if not self.session:
raise ToolExecutionException("MCP server not connected, please call connect() before using this method.")
if not self.load_tools_flag:
@@ -480,7 +565,19 @@ class MCPTool:
raise ToolExecutionException(f"Failed to call tool '{tool_name}'.", inner_exception=ex) from ex
async def get_prompt(self, prompt_name: str, **kwargs: Any) -> list[ChatMessage]:
"""Call a prompt with the given arguments."""
"""Call a prompt with the given arguments.
Args:
prompt_name: The name of the prompt to retrieve.
kwargs: Arguments to pass to the prompt.
Returns:
A list of chat messages returned by the prompt.
Raises:
ToolExecutionException: If the MCP server is not connected, prompts are not loaded,
or the prompt call fails.
"""
if not self.session:
raise ToolExecutionException("MCP server not connected, please call connect() before using this method.")
if not self.load_prompts_flag:
@@ -496,7 +593,17 @@ class MCPTool:
raise ToolExecutionException(f"Failed to call prompt '{prompt_name}'.", inner_exception=ex) from ex
async def __aenter__(self) -> Self:
"""Enter the context manager."""
"""Enter the async context manager.
Connects to the MCP server automatically.
Returns:
The MCPTool instance.
Raises:
ToolException: If connection fails.
ToolExecutionException: If context manager setup fails.
"""
try:
await self.connect()
return self
@@ -509,7 +616,15 @@ class MCPTool:
async def __aexit__(
self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: Any
) -> None:
"""Exit the context manager."""
"""Exit the async context manager.
Closes the connection and cleans up resources.
Args:
exc_type: The exception type if an exception was raised, None otherwise.
exc_value: The exception value if an exception was raised, None otherwise.
traceback: The exception traceback if an exception was raised, None otherwise.
"""
await self.close()
@@ -517,7 +632,29 @@ class MCPTool:
class MCPStdioTool(MCPTool):
"""MCP stdio server configuration."""
"""MCP tool for connecting to stdio-based MCP servers.
This class connects to MCP servers that communicate via standard input/output,
typically used for local processes.
Examples:
.. code-block:: python
from agent_framework import MCPStdioTool, ChatAgent
# Create an MCP stdio tool
mcp_tool = MCPStdioTool(
name="filesystem",
command="npx",
args=["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
description="File system operations",
)
# Use with a chat agent
async with mcp_tool:
agent = ChatAgent(chat_client=client, name="assistant", tools=mcp_tool)
response = await agent.run("List files in the directory")
"""
def __init__(
self,
@@ -536,28 +673,27 @@ class MCPStdioTool(MCPTool):
chat_client: "ChatClientProtocol | None" = None,
**kwargs: Any,
) -> None:
"""Initialize the MCP stdio plugin.
"""Initialize the MCP stdio tool.
The arguments are used to create a StdioServerParameters object.
Which is then used to create a stdio client.
see mcp.client.stdio.stdio_client and mcp.client.stdio.stdio_server_parameters
for more details.
Note:
The arguments are used to create a StdioServerParameters object,
which is then used to create a stdio client. See ``mcp.client.stdio.stdio_client``
and ``mcp.client.stdio.stdio_server_parameters`` for more details.
Args:
name: The name of the plugin.
name: The name of the tool.
command: The command to run the MCP server.
load_tools: Whether to load tools from the MCP server.
load_prompts: Whether to load prompts from the MCP server.
request_timeout: The default timeout used for all requests.
request_timeout: The default timeout in seconds for all requests.
session: The session to use for the MCP connection.
description: The description of the plugin.
description: The description of the tool.
additional_properties: Additional properties.
args: The arguments to pass to the command.
env: The environment variables to set for the command.
encoding: The encoding to use for the command output.
chat_client: The chat client to use for sampling.
kwargs: Any extra arguments to pass to the stdio client.
"""
super().__init__(
name=name,
@@ -576,7 +712,11 @@ class MCPStdioTool(MCPTool):
self._client_kwargs = kwargs
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
"""Get an MCP stdio client."""
"""Get an MCP stdio client.
Returns:
An async context manager for the stdio client transport.
"""
args: dict[str, Any] = {
"command": self.command,
"args": self.args,
@@ -590,7 +730,28 @@ class MCPStdioTool(MCPTool):
class MCPStreamableHTTPTool(MCPTool):
"""MCP streamable http server configuration."""
"""MCP tool for connecting to HTTP-based MCP servers.
This class connects to MCP servers that communicate via streamable HTTP/SSE.
Examples:
.. code-block:: python
from agent_framework import MCPStreamableHTTPTool, ChatAgent
# Create an MCP HTTP tool
mcp_tool = MCPStreamableHTTPTool(
name="web-api",
url="https://api.example.com/mcp",
headers={"Authorization": "Bearer token"},
description="Web API operations",
)
# Use with a chat agent
async with mcp_tool:
agent = ChatAgent(chat_client=client, name="assistant", tools=mcp_tool)
response = await agent.run("Fetch data from the API")
"""
def __init__(
self,
@@ -610,29 +771,29 @@ class MCPStreamableHTTPTool(MCPTool):
chat_client: "ChatClientProtocol | None" = None,
**kwargs: Any,
) -> None:
"""Initialize the MCP streamable http plugin.
"""Initialize the MCP streamable HTTP tool.
The arguments are used to create a streamable http client.
see mcp.client.streamable_http.streamablehttp_client for more details.
Any extra arguments passed to the constructor will be passed to the
streamable http client constructor.
Note:
The arguments are used to create a streamable HTTP client.
See ``mcp.client.streamable_http.streamablehttp_client`` for more details.
Any extra arguments passed to the constructor will be passed to the
streamable HTTP client constructor.
Args:
name: The name of the plugin.
name: The name of the tool.
url: The URL of the MCP server.
load_tools: Whether to load tools from the MCP server.
load_prompts: Whether to load prompts from the MCP server.
request_timeout: The default timeout used for all requests.
request_timeout: The default timeout in seconds for all requests.
session: The session to use for the MCP connection.
description: The description of the plugin.
description: The description of the tool.
additional_properties: Additional properties.
headers: The headers to send with the request.
timeout: The timeout for the request.
sse_read_timeout: The timeout for reading from the SSE stream.
terminate_on_close: Close the transport when the MCP client is terminated.
chat_client: The chat client to use for sampling.
kwargs: Any extra arguments to pass to the sse client.
kwargs: Any extra arguments to pass to the SSE client.
"""
super().__init__(
name=name,
@@ -652,7 +813,11 @@ class MCPStreamableHTTPTool(MCPTool):
self._client_kwargs = kwargs
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
"""Get an MCP streamable http client."""
"""Get an MCP streamable HTTP client.
Returns:
An async context manager for the streamable HTTP client transport.
"""
args: dict[str, Any] = {
"url": self.url,
}
@@ -670,7 +835,25 @@ class MCPStreamableHTTPTool(MCPTool):
class MCPWebsocketTool(MCPTool):
"""MCP websocket server configuration."""
"""MCP tool for connecting to WebSocket-based MCP servers.
This class connects to MCP servers that communicate via WebSocket.
Examples:
.. code-block:: python
from agent_framework import MCPWebsocketTool, ChatAgent
# Create an MCP WebSocket tool
mcp_tool = MCPWebsocketTool(
name="realtime-service", url="wss://service.example.com/mcp", description="Real-time service operations"
)
# Use with a chat agent
async with mcp_tool:
agent = ChatAgent(chat_client=client, name="assistant", tools=mcp_tool)
response = await agent.run("Connect to the real-time service")
"""
def __init__(
self,
@@ -686,26 +869,25 @@ class MCPWebsocketTool(MCPTool):
chat_client: "ChatClientProtocol | None" = None,
**kwargs: Any,
) -> None:
"""Initialize the MCP websocket plugin.
"""Initialize the MCP WebSocket tool.
The arguments are used to create a websocket client.
see mcp.client.websocket.websocket_client for more details.
Any extra arguments passed to the constructor will be passed to the
websocket client constructor.
Note:
The arguments are used to create a WebSocket client.
See ``mcp.client.websocket.websocket_client`` for more details.
Any extra arguments passed to the constructor will be passed to the
WebSocket client constructor.
Args:
name: The name of the plugin.
name: The name of the tool.
url: The URL of the MCP server.
load_tools: Whether to load tools from the MCP server.
load_prompts: Whether to load prompts from the MCP server.
request_timeout: The default timeout used for all requests.
request_timeout: The default timeout in seconds for all requests.
session: The session to use for the MCP connection.
description: The description of the plugin.
description: The description of the tool.
additional_properties: Additional properties.
chat_client: The chat client to use for sampling.
kwargs: Any extra arguments to pass to the websocket client.
kwargs: Any extra arguments to pass to the WebSocket client.
"""
super().__init__(
name=name,
@@ -721,7 +903,11 @@ class MCPWebsocketTool(MCPTool):
self._client_kwargs = kwargs
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
"""Get an MCP websocket client."""
"""Get an MCP WebSocket client.
Returns:
An async context manager for the WebSocket client transport.
"""
args: dict[str, Any] = {
"url": self.url,
}
+96 -31
View File
@@ -26,12 +26,28 @@ __all__ = ["AggregateContextProvider", "Context", "ContextProvider"]
class Context:
"""A class containing any context that should be provided to the AI model as supplied by an ContextProvider.
"""A class containing any context that should be provided to the AI model as supplied by a ContextProvider.
Each ContextProvider has the ability to provide its own context for each invocation.
The Context class contains the additional context supplied by the ContextProvider.
This context will be combined with context supplied by other providers before being passed to the AI model.
This context is per invocation, and will not be stored as part of the chat history.
Examples:
.. code-block:: python
from agent_framework import Context, ChatMessage
# Create context with instructions
context = Context(
instructions="Use a professional tone when responding.",
messages=[ChatMessage(content="Previous context", role="user")],
tools=[my_tool],
)
# Access context properties
print(context.instructions)
print(len(context.messages))
"""
def __init__(
@@ -43,9 +59,9 @@ class Context:
"""Create a new Context object.
Args:
instructions: Instructions to provide to the AI model.
messages: a list of messages.
tools: a list of tools to provide to this run.
instructions: The instructions to provide to the AI model.
messages: The list of messages to include in the context.
tools: The list of tools to provide to this run.
"""
self.instructions = instructions
self.messages: Sequence[ChatMessage] = messages or []
@@ -62,7 +78,27 @@ class ContextProvider(ABC):
It can listen to changes in the conversation and provide additional context to the AI model
just before invocation.
It also has a default memory prompt that can be used by all providers.
Note:
ContextProvider is an abstract base class. You must subclass it and implement
the ``invoking()`` method to create a custom context provider. Ideally, you should
also implement the ``invoked()`` and ``thread_created()`` methods to track conversation
state, but these are optional.
Examples:
.. code-block:: python
from agent_framework import ContextProvider, Context, ChatMessage
class CustomContextProvider(ContextProvider):
async def invoking(self, messages, **kwargs):
# Add custom instructions before each invocation
return Context(instructions="Always be concise and helpful.", messages=[], tools=[])
# Use with a chat agent
async with CustomContextProvider() as provider:
agent = ChatAgent(chat_client=client, name="assistant", context_providers=provider)
"""
# Default prompt to be used by all context providers when assembling memories/instructions
@@ -71,9 +107,9 @@ class ContextProvider(ABC):
async def thread_created(self, thread_id: str | None) -> None:
"""Called just after a new thread is created.
Implementers can use this method to do any operations required at the creation of a new thread.
For example, checking long term storage for any data that is relevant
to the current session based on the input text.
Implementers can use this method to perform any operations required at the creation
of a new thread. For example, checking long-term storage for any data that is relevant
to the current session.
Args:
thread_id: The ID of the new thread.
@@ -89,36 +125,39 @@ class ContextProvider(ABC):
) -> None:
"""Called after the agent has received a response from the underlying inference service.
You can inspect the request and response messages, and update the state of the context provider
You can inspect the request and response messages, and update the state of the context provider.
Args:
request_messages: messages that were sent to the model/agent
response_messages: messages that were returned by the model/agent
invoke_exception: exception that was thrown, if any.
kwargs: not used at present.
request_messages: The messages that were sent to the model/agent.
response_messages: The messages that were returned by the model/agent.
invoke_exception: The exception that was thrown, if any.
kwargs: Additional keyword arguments (not used at present).
"""
pass
@abstractmethod
async def invoking(self, messages: ChatMessage | MutableSequence[ChatMessage], **kwargs: Any) -> Context:
"""Called just before the Model/Agent/etc. is invoked.
"""Called just before the model/agent is invoked.
Implementers can load any additional context required at this time,
and they should return any context that should be passed to the agent.
Args:
messages: The most recent messages that the agent is being invoked with.
kwargs: not used at present.
kwargs: Additional keyword arguments (not used at present).
Returns:
A Context object containing instructions, messages, and tools to include.
"""
pass
async def __aenter__(self) -> "Self":
"""Async context manager entry.
"""Enter the async context manager.
Override this method to perform any setup operations when the context provider is entered.
Returns:
Self for chaining.
The ContextProvider instance for chaining.
"""
return self
@@ -128,14 +167,14 @@ class ContextProvider(ABC):
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
"""Async context manager exit.
"""Exit the async context manager.
Override this method to perform any cleanup operations when the context provider is exited.
Args:
exc_type: Exception type if an exception occurred, None otherwise.
exc_val: Exception value if an exception occurred, None otherwise.
exc_tb: Exception traceback if an exception occurred, None otherwise.
exc_type: The exception type if an exception occurred, None otherwise.
exc_val: The exception value if an exception occurred, None otherwise.
exc_tb: The exception traceback if an exception occurred, None otherwise.
"""
pass
@@ -146,14 +185,40 @@ class ContextProvider(ABC):
class AggregateContextProvider(ContextProvider):
"""A ContextProvider that contains multiple context providers.
It delegates events to multiple context providers and aggregates responses from those events before returning.
It delegates events to multiple context providers and aggregates responses from those
events before returning. This allows you to combine multiple context providers into a
single provider.
Note:
An AggregateContextProvider is created automatically when you pass a single context
provider or a sequence of context providers to the agent constructor.
Examples:
.. code-block:: python
from agent_framework import AggregateContextProvider, ChatAgent
# Create multiple context providers
provider1 = CustomContextProvider1()
provider2 = CustomContextProvider2()
provider3 = CustomContextProvider3()
# Pass them to the agent - AggregateContextProvider is created automatically
agent = ChatAgent(chat_client=client, name="assistant", context_providers=[provider1, provider2, provider3])
# Verify that an AggregateContextProvider was created
assert isinstance(agent.context_providers, AggregateContextProvider)
# Add additional providers to the agent
provider4 = CustomContextProvider4()
agent.context_providers.add(provider4)
"""
def __init__(self, context_providers: ContextProvider | Sequence[ContextProvider] | None = None) -> None:
"""Initialize the AggregateContextProvider with context providers.
Args:
context_providers: Context providers to add.
context_providers: The context provider(s) to add.
"""
if isinstance(context_providers, ContextProvider):
self.providers = [context_providers]
@@ -162,10 +227,10 @@ class AggregateContextProvider(ContextProvider):
self._exit_stack: AsyncExitStack | None = None
def add(self, context_provider: ContextProvider) -> None:
"""Adds new context provider.
"""Add a new context provider.
Args:
context_provider: Context provider to add.
context_provider: The context provider to add.
"""
self.providers.append(context_provider)
@@ -208,10 +273,10 @@ class AggregateContextProvider(ContextProvider):
@override
async def __aenter__(self) -> "Self":
"""Enter async context manager and set up all providers.
"""Enter the async context manager and set up all providers.
Returns:
Self for chaining.
The AggregateContextProvider instance for chaining.
"""
self._exit_stack = AsyncExitStack()
await self._exit_stack.__aenter__()
@@ -229,12 +294,12 @@ class AggregateContextProvider(ContextProvider):
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
"""Exit async context manager and clean up all providers.
"""Exit the async context manager and clean up all providers.
Args:
exc_type: Exception type if an exception occurred, None otherwise.
exc_val: Exception value if an exception occurred, None otherwise.
exc_tb: Exception traceback if an exception occurred, None otherwise.
exc_type: The exception type if an exception occurred, None otherwise.
exc_val: The exception value if an exception occurred, None otherwise.
exc_tb: The exception traceback if an exception occurred, None otherwise.
"""
if self._exit_stack is not None:
await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)
@@ -2,17 +2,15 @@
import inspect
from abc import ABC, abstractmethod
from collections.abc import AsyncIterable, Awaitable, Callable
from dataclasses import dataclass, field
from collections.abc import AsyncIterable, Awaitable, Callable, MutableSequence
from enum import Enum
from typing import TYPE_CHECKING, Any, Generic, TypeAlias, TypeVar
from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeAlias, TypeVar
from ._serialization import SerializationMixin
from ._types import AgentRunResponse, AgentRunResponseUpdate, ChatMessage
from .exceptions import MiddlewareException
if TYPE_CHECKING:
from collections.abc import AsyncIterable, MutableSequence
from pydantic import BaseModel
from ._agents import AgentProtocol
@@ -20,18 +18,6 @@ if TYPE_CHECKING:
from ._tools import AIFunction
from ._types import ChatOptions, ChatResponse, ChatResponseUpdate
TAgent = TypeVar("TAgent", bound="AgentProtocol")
TChatClient = TypeVar("TChatClient", bound="ChatClientProtocol")
TContext = TypeVar("TContext")
class MiddlewareType(Enum):
"""Enum representing the type of middleware."""
AGENT = "agent"
FUNCTION = "function"
CHAT = "chat"
__all__ = [
"AgentMiddleware",
@@ -49,88 +35,271 @@ __all__ = [
"use_chat_middleware",
]
TAgent = TypeVar("TAgent", bound="AgentProtocol")
TChatClient = TypeVar("TChatClient", bound="ChatClientProtocol")
TContext = TypeVar("TContext")
@dataclass
class AgentRunContext:
class MiddlewareType(str, Enum):
"""Enum representing the type of middleware.
Used internally to identify and categorize middleware types.
"""
AGENT = "agent"
FUNCTION = "function"
CHAT = "chat"
class AgentRunContext(SerializationMixin):
"""Context object for agent middleware invocations.
This context is passed through the agent middleware pipeline and contains all information
about the agent invocation.
Attributes:
agent: The agent being invoked.
messages: The messages being sent to the agent.
is_streaming: Whether this is a streaming invocation.
metadata: Metadata dictionary for sharing data between agent middleware.
result: Agent execution result. Can be observed after calling next()
result: Agent execution result. Can be observed after calling ``next()``
to see the actual execution result or can be set to override the execution result.
For non-streaming: should be AgentRunResponse
For streaming: should be AsyncIterable[AgentRunResponseUpdate]
For non-streaming: should be AgentRunResponse.
For streaming: should be AsyncIterable[AgentRunResponseUpdate].
terminate: A flag indicating whether to terminate execution after current middleware.
When set to True, execution will stop as soon as control returns to framework.
kwargs: Additional keyword arguments passed to the agent run method.
Examples:
.. code-block:: python
from agent_framework import AgentMiddleware, AgentRunContext
class LoggingMiddleware(AgentMiddleware):
async def process(self, context: AgentRunContext, next):
print(f"Agent: {context.agent.name}")
print(f"Messages: {len(context.messages)}")
print(f"Streaming: {context.is_streaming}")
# Store metadata
context.metadata["start_time"] = time.time()
# Continue execution
await next(context)
# Access result after execution
print(f"Result: {context.result}")
"""
agent: "AgentProtocol"
messages: list[ChatMessage]
is_streaming: bool = False
metadata: dict[str, Any] = field(default_factory=dict) # type: ignore
result: AgentRunResponse | AsyncIterable[AgentRunResponseUpdate] | None = None
terminate: bool = False
kwargs: dict[str, Any] = field(default_factory=dict) # type: ignore
INJECTABLE: ClassVar[set[str]] = {"agent", "result"}
def __init__(
self,
agent: "AgentProtocol",
messages: list[ChatMessage],
is_streaming: bool = False,
metadata: dict[str, Any] | None = None,
result: AgentRunResponse | AsyncIterable[AgentRunResponseUpdate] | None = None,
terminate: bool = False,
kwargs: dict[str, Any] | None = None,
) -> None:
"""Initialize the AgentRunContext.
Args:
agent: The agent being invoked.
messages: The messages being sent to the agent.
is_streaming: Whether this is a streaming invocation.
metadata: Metadata dictionary for sharing data between agent middleware.
result: Agent execution result.
terminate: A flag indicating whether to terminate execution after current middleware.
kwargs: Additional keyword arguments passed to the agent run method.
"""
self.agent = agent
self.messages = messages
self.is_streaming = is_streaming
self.metadata = metadata if metadata is not None else {}
self.result = result
self.terminate = terminate
self.kwargs = kwargs if kwargs is not None else {}
@dataclass
class FunctionInvocationContext:
class FunctionInvocationContext(SerializationMixin):
"""Context object for function middleware invocations.
This context is passed through the function middleware pipeline and contains all information
about the function invocation.
Attributes:
function: The function being invoked.
arguments: The validated arguments for the function.
metadata: Metadata dictionary for sharing data between function middleware.
result: Function execution result. Can be observed after calling next()
result: Function execution result. Can be observed after calling ``next()``
to see the actual execution result or can be set to override the execution result.
terminate: A flag indicating whether to terminate execution after current middleware.
When set to True, execution will stop as soon as control returns to framework.
kwargs: Additional keyword arguments passed to the chat method that invoked this function.
Examples:
.. code-block:: python
from agent_framework import FunctionMiddleware, FunctionInvocationContext
class ValidationMiddleware(FunctionMiddleware):
async def process(self, context: FunctionInvocationContext, next):
print(f"Function: {context.function.name}")
print(f"Arguments: {context.arguments}")
# Validate arguments
if not self.validate(context.arguments):
context.result = {"error": "Validation failed"}
context.terminate = True
return
# Continue execution
await next(context)
"""
function: "AIFunction[Any, Any]"
arguments: "BaseModel"
metadata: dict[str, Any] = field(default_factory=dict) # type: ignore
result: Any = None
terminate: bool = False
kwargs: dict[str, Any] = field(default_factory=dict) # type: ignore
INJECTABLE: ClassVar[set[str]] = {"function", "arguments", "result"}
def __init__(
self,
function: "AIFunction[Any, Any]",
arguments: "BaseModel",
metadata: dict[str, Any] | None = None,
result: Any = None,
terminate: bool = False,
kwargs: dict[str, Any] | None = None,
) -> None:
"""Initialize the FunctionInvocationContext.
Args:
function: The function being invoked.
arguments: The validated arguments for the function.
metadata: Metadata dictionary for sharing data between function middleware.
result: Function execution result.
terminate: A flag indicating whether to terminate execution after current middleware.
kwargs: Additional keyword arguments passed to the chat method that invoked this function.
"""
self.function = function
self.arguments = arguments
self.metadata = metadata if metadata is not None else {}
self.result = result
self.terminate = terminate
self.kwargs = kwargs if kwargs is not None else {}
@dataclass
class ChatContext:
class ChatContext(SerializationMixin):
"""Context object for chat middleware invocations.
This context is passed through the chat middleware pipeline and contains all information
about the chat request.
Attributes:
chat_client: The chat client being invoked.
messages: The messages being sent to the chat client.
chat_options: The options for the chat request.
is_streaming: Whether this is a streaming invocation.
metadata: Metadata dictionary.
result: Chat execution result. Can be observed after calling next()
metadata: Metadata dictionary for sharing data between chat middleware.
result: Chat execution result. Can be observed after calling ``next()``
to see the actual execution result or can be set to override the execution result.
For non-streaming: should be ChatResponse
For streaming: should be AsyncIterable[ChatResponseUpdate]
For non-streaming: should be ChatResponse.
For streaming: should be AsyncIterable[ChatResponseUpdate].
terminate: A flag indicating whether to terminate execution after current middleware.
When set to True, execution will stop as soon as control returns to framework.
kwargs: Additional keyword arguments passed to the chat client.
Examples:
.. code-block:: python
from agent_framework import ChatMiddleware, ChatContext
class TokenCounterMiddleware(ChatMiddleware):
async def process(self, context: ChatContext, next):
print(f"Chat client: {context.chat_client.__class__.__name__}")
print(f"Messages: {len(context.messages)}")
print(f"Model: {context.chat_options.model}")
# Store metadata
context.metadata["input_tokens"] = self.count_tokens(context.messages)
# Continue execution
await next(context)
# Access result and count output tokens
if context.result:
context.metadata["output_tokens"] = self.count_tokens(context.result)
"""
chat_client: "ChatClientProtocol"
messages: "MutableSequence[ChatMessage]"
chat_options: "ChatOptions"
is_streaming: bool = False
metadata: dict[str, Any] = field(default_factory=dict) # type: ignore
result: "ChatResponse | AsyncIterable[ChatResponseUpdate] | None" = None
terminate: bool = False
kwargs: dict[str, Any] = field(default_factory=dict) # type: ignore
INJECTABLE: ClassVar[set[str]] = {"chat_client", "result"}
def __init__(
self,
chat_client: "ChatClientProtocol",
messages: "MutableSequence[ChatMessage]",
chat_options: "ChatOptions",
is_streaming: bool = False,
metadata: dict[str, Any] | None = None,
result: "ChatResponse | AsyncIterable[ChatResponseUpdate] | None" = None,
terminate: bool = False,
kwargs: dict[str, Any] | None = None,
) -> None:
"""Initialize the ChatContext.
Args:
chat_client: The chat client being invoked.
messages: The messages being sent to the chat client.
chat_options: The options for the chat request.
is_streaming: Whether this is a streaming invocation.
metadata: Metadata dictionary for sharing data between chat middleware.
result: Chat execution result.
terminate: A flag indicating whether to terminate execution after current middleware.
kwargs: Additional keyword arguments passed to the chat client.
"""
self.chat_client = chat_client
self.messages = messages
self.chat_options = chat_options
self.is_streaming = is_streaming
self.metadata = metadata if metadata is not None else {}
self.result = result
self.terminate = terminate
self.kwargs = kwargs if kwargs is not None else {}
class AgentMiddleware(ABC):
"""Abstract base class for agent middleware that can intercept agent invocations."""
"""Abstract base class for agent middleware that can intercept agent invocations.
Agent middleware allows you to intercept and modify agent invocations before and after
execution. You can inspect messages, modify context, override results, or terminate
execution early.
Note:
AgentMiddleware is an abstract base class. You must subclass it and implement
the ``process()`` method to create custom agent middleware.
Examples:
.. code-block:: python
from agent_framework import AgentMiddleware, AgentRunContext, ChatAgent
class RetryMiddleware(AgentMiddleware):
def __init__(self, max_retries: int = 3):
self.max_retries = max_retries
async def process(self, context: AgentRunContext, next):
for attempt in range(self.max_retries):
await next(context)
if context.result and not context.result.is_error:
break
print(f"Retry {attempt + 1}/{self.max_retries}")
# Use with an agent
agent = ChatAgent(chat_client=client, name="assistant", middleware=RetryMiddleware())
"""
@abstractmethod
async def process(
@@ -159,7 +328,46 @@ class AgentMiddleware(ABC):
class FunctionMiddleware(ABC):
"""Abstract base class for function middleware that can intercept function invocations."""
"""Abstract base class for function middleware that can intercept function invocations.
Function middleware allows you to intercept and modify function/tool invocations before
and after execution. You can validate arguments, cache results, log invocations, or
override function execution.
Note:
FunctionMiddleware is an abstract base class. You must subclass it and implement
the ``process()`` method to create custom function middleware.
Examples:
.. code-block:: python
from agent_framework import FunctionMiddleware, FunctionInvocationContext, ChatAgent
class CachingMiddleware(FunctionMiddleware):
def __init__(self):
self.cache = {}
async def process(self, context: FunctionInvocationContext, next):
cache_key = f"{context.function.name}:{context.arguments}"
# Check cache
if cache_key in self.cache:
context.result = self.cache[cache_key]
context.terminate = True
return
# Execute function
await next(context)
# Cache result
if context.result:
self.cache[cache_key] = context.result
# Use with an agent
agent = ChatAgent(chat_client=client, name="assistant", middleware=CachingMiddleware())
"""
@abstractmethod
async def process(
@@ -185,7 +393,41 @@ class FunctionMiddleware(ABC):
class ChatMiddleware(ABC):
"""Abstract base class for chat middleware that can intercept chat client requests."""
"""Abstract base class for chat middleware that can intercept chat client requests.
Chat middleware allows you to intercept and modify chat client requests before and after
execution. You can modify messages, add system prompts, log requests, or override
chat responses.
Note:
ChatMiddleware is an abstract base class. You must subclass it and implement
the ``process()`` method to create custom chat middleware.
Examples:
.. code-block:: python
from agent_framework import ChatMiddleware, ChatContext, ChatAgent
class SystemPromptMiddleware(ChatMiddleware):
def __init__(self, system_prompt: str):
self.system_prompt = system_prompt
async def process(self, context: ChatContext, next):
# Add system prompt to messages
from agent_framework import ChatMessage
context.messages.insert(0, ChatMessage(role="system", content=self.system_prompt))
# Continue execution
await next(context)
# Use with an agent
agent = ChatAgent(
chat_client=client, name="assistant", middleware=SystemPromptMiddleware("You are a helpful assistant.")
)
"""
@abstractmethod
async def process(
@@ -233,8 +475,9 @@ Middleware: TypeAlias = (
)
AgentMiddlewares: TypeAlias = AgentMiddleware | AgentMiddlewareCallable
# region Middleware type markers for decorators
# Middleware type markers for decorators
def agent_middleware(func: AgentMiddlewareCallable) -> AgentMiddlewareCallable:
"""Decorator to mark a function as agent middleware.
@@ -247,11 +490,21 @@ def agent_middleware(func: AgentMiddlewareCallable) -> AgentMiddlewareCallable:
Returns:
The same function with agent middleware marker.
Example:
@agent_middleware
async def my_middleware(context: AgentRunContext, next):
# Process agent invocation
await next(context)
Examples:
.. code-block:: python
from agent_framework import agent_middleware, AgentRunContext, ChatAgent
@agent_middleware
async def logging_middleware(context: AgentRunContext, next):
print(f"Before: {context.agent.name}")
await next(context)
print(f"After: {context.result}")
# Use with an agent
agent = ChatAgent(chat_client=client, name="assistant", middleware=logging_middleware)
"""
# Add marker attribute to identify this as agent middleware
func._middleware_type: MiddlewareType = MiddlewareType.AGENT # type: ignore
@@ -270,11 +523,21 @@ def function_middleware(func: FunctionMiddlewareCallable) -> FunctionMiddlewareC
Returns:
The same function with function middleware marker.
Example:
@function_middleware
async def my_middleware(context: FunctionInvocationContext, next):
# Process function invocation
await next(context)
Examples:
.. code-block:: python
from agent_framework import function_middleware, FunctionInvocationContext, ChatAgent
@function_middleware
async def logging_middleware(context: FunctionInvocationContext, next):
print(f"Calling: {context.function.name}")
await next(context)
print(f"Result: {context.result}")
# Use with an agent
agent = ChatAgent(chat_client=client, name="assistant", middleware=logging_middleware)
"""
# Add marker attribute to identify this as function middleware
func._middleware_type: MiddlewareType = MiddlewareType.FUNCTION # type: ignore
@@ -293,11 +556,21 @@ def chat_middleware(func: ChatMiddlewareCallable) -> ChatMiddlewareCallable:
Returns:
The same function with chat middleware marker.
Example:
@chat_middleware
async def my_middleware(context: ChatContext, next):
# Process chat invocation
await next(context)
Examples:
.. code-block:: python
from agent_framework import chat_middleware, ChatContext, ChatAgent
@chat_middleware
async def logging_middleware(context: ChatContext, next):
print(f"Messages: {len(context.messages)}")
await next(context)
print(f"Response: {context.result}")
# Use with an agent
agent = ChatAgent(chat_client=client, name="assistant", middleware=logging_middleware)
"""
# Add marker attribute to identify this as chat middleware
func._middleware_type: MiddlewareType = MiddlewareType.CHAT # type: ignore
@@ -307,6 +580,9 @@ def chat_middleware(func: ChatMiddlewareCallable) -> ChatMiddlewareCallable:
class MiddlewareWrapper(Generic[TContext]):
"""Generic wrapper to convert pure functions into middleware protocol objects.
This wrapper allows function-based middleware to be used alongside class-based middleware
by providing a unified interface.
Type Parameters:
TContext: The type of context object this middleware operates on.
"""
@@ -319,7 +595,10 @@ class MiddlewareWrapper(Generic[TContext]):
class BaseMiddlewarePipeline(ABC):
"""Base class for middleware pipeline execution."""
"""Base class for middleware pipeline execution.
Provides common functionality for building and executing middleware chains.
"""
def __init__(self) -> None:
"""Initialize the base middleware pipeline."""
@@ -327,12 +606,22 @@ class BaseMiddlewarePipeline(ABC):
@abstractmethod
def _register_middleware(self, middleware: Any) -> None:
"""Register a middleware item. Must be implemented by subclasses."""
"""Register a middleware item.
Must be implemented by subclasses.
Args:
middleware: The middleware to register.
"""
...
@property
def has_middlewares(self) -> bool:
"""Check if there are any middlewares registered."""
"""Check if there are any middlewares registered.
Returns:
True if middlewares are registered, False otherwise.
"""
return bool(self._middlewares)
def _register_middleware_with_wrapper(
@@ -342,6 +631,8 @@ class BaseMiddlewarePipeline(ABC):
) -> None:
"""Generic middleware registration with automatic wrapping.
Wraps callable middleware in a MiddlewareWrapper if needed.
Args:
middleware: The middleware instance or callable to register.
expected_type: The expected middleware base class type.
@@ -360,12 +651,12 @@ class BaseMiddlewarePipeline(ABC):
"""Create a chain of middleware handlers.
Args:
final_handler: The final handler to execute
result_container: Container to store the result
result_key: Key to use in the result container
final_handler: The final handler to execute.
result_container: Container to store the result.
result_key: Key to use in the result container.
Returns:
The first handler in the chain
The first handler in the chain.
"""
def create_next_handler(index: int) -> Callable[[Any], Awaitable[None]]:
@@ -398,12 +689,12 @@ class BaseMiddlewarePipeline(ABC):
"""Create a chain of middleware handlers for streaming operations.
Args:
final_handler: The final handler to execute
result_container: Container to store the result
result_key: Key to use in the result container
final_handler: The final handler to execute.
result_container: Container to store the result.
result_key: Key to use in the result container.
Returns:
The first handler in the chain
The first handler in the chain.
"""
def create_next_handler(index: int) -> Callable[[Any], Awaitable[None]]:
@@ -441,13 +732,17 @@ class BaseMiddlewarePipeline(ABC):
class AgentMiddlewarePipeline(BaseMiddlewarePipeline):
"""Executes agent middleware in a chain."""
"""Executes agent middleware in a chain.
Manages the execution of multiple agent middleware in sequence, allowing each middleware
to process the agent invocation and pass control to the next middleware in the chain.
"""
def __init__(self, middlewares: list[AgentMiddleware | AgentMiddlewareCallable] | None = None):
"""Initialize the agent middleware pipeline.
Args:
middlewares: List of agent middleware to include in the pipeline.
middlewares: The list of agent middleware to include in the pipeline.
"""
super().__init__()
self._middlewares: list[AgentMiddleware] = []
@@ -457,7 +752,11 @@ class AgentMiddlewarePipeline(BaseMiddlewarePipeline):
self._register_middleware(middleware)
def _register_middleware(self, middleware: AgentMiddleware | AgentMiddlewareCallable) -> None:
"""Register an agent middleware item."""
"""Register an agent middleware item.
Args:
middleware: The agent middleware to register.
"""
self._register_middleware_with_wrapper(middleware, AgentMiddleware)
async def execute(
@@ -562,13 +861,17 @@ class AgentMiddlewarePipeline(BaseMiddlewarePipeline):
class FunctionMiddlewarePipeline(BaseMiddlewarePipeline):
"""Executes function middleware in a chain."""
"""Executes function middleware in a chain.
Manages the execution of multiple function middleware in sequence, allowing each middleware
to process the function invocation and pass control to the next middleware in the chain.
"""
def __init__(self, middlewares: list[FunctionMiddleware | FunctionMiddlewareCallable] | None = None):
"""Initialize the function middleware pipeline.
Args:
middlewares: List of function middleware to include in the pipeline.
middlewares: The list of function middleware to include in the pipeline.
"""
super().__init__()
self._middlewares: list[FunctionMiddleware] = []
@@ -578,7 +881,11 @@ class FunctionMiddlewarePipeline(BaseMiddlewarePipeline):
self._register_middleware(middleware)
def _register_middleware(self, middleware: FunctionMiddleware | FunctionMiddlewareCallable) -> None:
"""Register a function middleware item."""
"""Register a function middleware item.
Args:
middleware: The function middleware to register.
"""
self._register_middleware_with_wrapper(middleware, FunctionMiddleware)
async def execute(
@@ -627,13 +934,17 @@ class FunctionMiddlewarePipeline(BaseMiddlewarePipeline):
class ChatMiddlewarePipeline(BaseMiddlewarePipeline):
"""Executes chat middleware in a chain."""
"""Executes chat middleware in a chain.
Manages the execution of multiple chat middleware in sequence, allowing each middleware
to process the chat request and pass control to the next middleware in the chain.
"""
def __init__(self, middlewares: list[ChatMiddleware | ChatMiddlewareCallable] | None = None):
"""Initialize the chat middleware pipeline.
Args:
middlewares: List of chat middleware to include in the pipeline.
middlewares: The list of chat middleware to include in the pipeline.
"""
super().__init__()
self._middlewares: list[ChatMiddleware] = []
@@ -643,7 +954,11 @@ class ChatMiddlewarePipeline(BaseMiddlewarePipeline):
self._register_middleware(middleware)
def _register_middleware(self, middleware: ChatMiddleware | ChatMiddlewareCallable) -> None:
"""Register a chat middleware item."""
"""Register a chat middleware item.
Args:
middleware: The chat middleware to register.
"""
self._register_middleware_with_wrapper(middleware, ChatMiddleware)
async def execute(
@@ -823,17 +1138,37 @@ def use_agent_middleware(agent_class: type[TAgent]) -> type[TAgent]:
"""Class decorator that adds middleware support to an agent class.
This decorator adds middleware functionality to any agent class.
It wraps the run() and run_stream() methods to provide middleware execution.
It wraps the ``run()`` and ``run_stream()`` methods to provide middleware execution.
The middleware execution can be terminated at any point by setting the
context.terminate property to True. Once set, the pipeline will stop executing
``context.terminate`` property to True. Once set, the pipeline will stop executing
further middleware as soon as control returns to the pipeline.
Note:
This decorator is already applied to built-in agent classes. You only need to use
it if you're creating custom agent implementations.
Args:
agent_class: The agent class to add middleware support to.
Returns:
The modified agent class with middleware support.
Examples:
.. code-block:: python
from agent_framework import use_agent_middleware
@use_agent_middleware
class CustomAgent:
async def run(self, messages, **kwargs):
# Agent implementation
pass
async def run_stream(self, messages, **kwargs):
# Streaming implementation
pass
"""
# Store original methods
original_run = agent_class.run # type: ignore[attr-defined]
@@ -965,13 +1300,33 @@ def use_chat_middleware(chat_client_class: type[TChatClient]) -> type[TChatClien
"""Class decorator that adds middleware support to a chat client class.
This decorator adds middleware functionality to any chat client class.
It wraps the get_response() and get_streaming_response() methods to provide middleware execution.
It wraps the ``get_response()`` and ``get_streaming_response()`` methods to provide middleware execution.
Note:
This decorator is already applied to built-in chat client classes. You only need to use
it if you're creating custom chat client implementations.
Args:
chat_client_class: The chat client class to add middleware support to.
Returns:
The modified chat client class with middleware support.
Examples:
.. code-block:: python
from agent_framework import use_chat_middleware
@use_chat_middleware
class CustomChatClient:
async def get_response(self, messages, **kwargs):
# Chat client implementation
pass
async def get_streaming_response(self, messages, **kwargs):
# Streaming implementation
pass
"""
# Store original methods
original_get_response = chat_client_class.get_response
@@ -1140,7 +1495,14 @@ def categorize_middleware(
def create_function_middleware_pipeline(
*middleware_sources: list[Middleware] | None,
) -> FunctionMiddlewarePipeline | None:
"""Create a function middleware pipeline from multiple middleware sources."""
"""Create a function middleware pipeline from multiple middleware sources.
Args:
*middleware_sources: Variable number of middleware sources.
Returns:
A FunctionMiddlewarePipeline if function middleware is found, None otherwise.
"""
middleware = categorize_middleware(*middleware_sources)
function_middlewares = middleware["function"]
return FunctionMiddlewarePipeline(function_middlewares) if function_middlewares else None # type: ignore[arg-type]
@@ -18,20 +18,66 @@ _CAMEL_TO_SNAKE_PATTERN = re.compile(r"(?<!^)(?=[A-Z])")
@runtime_checkable
class SerializationProtocol(Protocol):
"""Protocol for objects that support serialization and deserialization."""
"""Protocol for objects that support serialization and deserialization.
This protocol defines the interface for objects that can be converted to and from dictionaries.
Examples:
.. code-block:: python
from agent_framework import SerializationProtocol
class MySerializable:
def __init__(self, value: str):
self.value = value
def to_dict(self, **kwargs):
return {"value": self.value}
@classmethod
def from_dict(cls, value, **kwargs):
return cls(value["value"])
# Verify it implements the protocol
assert isinstance(MySerializable("test"), SerializationProtocol)
"""
def to_dict(self, **kwargs: Any) -> dict[str, Any]:
"""Convert the instance to a dictionary."""
"""Convert the instance to a dictionary.
Args:
kwargs: Additional keyword arguments for serialization.
Returns:
Dictionary representation of the instance.
"""
...
@classmethod
def from_dict(cls: type[TProtocol], value: MutableMapping[str, Any], /, **kwargs: Any) -> TProtocol:
"""Create an instance from a dictionary."""
"""Create an instance from a dictionary.
Args:
value: Dictionary containing the instance data (positional-only).
kwargs: Additional keyword arguments for deserialization.
Returns:
New instance of the class.
"""
...
def is_serializable(value: Any) -> bool:
"""Check if a value is JSON serializable."""
"""Check if a value is JSON serializable.
Args:
value: The value to check.
Returns:
True if the value is JSON serializable, False otherwise.
"""
return isinstance(value, (str, int, float, bool, type(None), list, dict))
@@ -47,8 +93,7 @@ class SerializationMixin:
Because we setup the __init__ method to handle MutableMapping,
we can pass in a dict to the second class and it will convert it to an instance of the first class.
Example:
Examples:
.. code-block:: python
class SerializableMixinType(SerializationMixin):
@@ -72,11 +117,10 @@ class SerializationMixin:
instance = MyClass.from_dict({"regular_param": "value", "param": {"param1": "value1", "param2": 42}})
A more complex use case involves a injectable dependency that is not serialized.
A more complex use case involves an injectable dependency that is not serialized.
In this case, the dependency is passed in via the dependencies parameter to from_dict/from_json.
Example:
Examples:
.. code-block:: python
from libary import Client
@@ -85,16 +129,6 @@ class SerializationMixin:
class MyClass(SerializationMixin):
INJECTABLE = {"client"}
def __init__(self, regular_param: str, client: Client) -> None:
self.client = client
self.regular_param = regular_param
json_of_class = MyClass(regular_param="value", client=Client()).to_json()
# this looks like: {"type": "my_class", "regular_param": "value"}
instance = MyClass.from_dict(json_of_class, dependencies={"my_class.client": Client()})
During serialization, the field listed as INJECTABLE (and also DEFAULT_EXCLUDE) will be excluded from the output.
Then in deserialization,
the dependencies dict is checked for any keys matching the formats:
@@ -114,7 +148,7 @@ class SerializationMixin:
"""Convert the instance and any nested objects to a dictionary.
Args:
exclude: Set of field names to exclude from serialization.
exclude: The set of field names to exclude from serialization.
exclude_none: Whether to exclude None values from the output. Defaults to True.
Returns:
@@ -180,7 +214,7 @@ class SerializationMixin:
"""Convert the instance to a JSON string.
Args:
exclude: Set of field names to exclude from serialization.
exclude: The set of field names to exclude from serialization.
exclude_none: Whether to exclude None values from the output. Defaults to True.
Returns:
@@ -195,9 +229,9 @@ class SerializationMixin:
"""Create an instance from a dictionary.
Args:
value: Dictionary containing the instance data (positional-only).
dependencies: Dictionary mapping dependency keys to values.
Keys should be in format "<type>.<parameter>" or "<type>.<dict-parameter>.<key>".
value: The dictionary containing the instance data (positional-only).
dependencies: The dictionary mapping dependency keys to values.
Keys should be in format ``"<type>.<parameter>"`` or ``"<type>.<dict-parameter>.<key>"``.
Returns:
New instance of the class.
@@ -248,9 +282,9 @@ class SerializationMixin:
"""Create an instance from a JSON string.
Args:
value: JSON string containing the instance data (positional-only).
dependencies: Dictionary mapping dependency keys to values.
Keys should be in format "<type>.<parameter>" or "<type>.<dict-parameter>.<key>".
value: The JSON string containing the instance data (positional-only).
dependencies: The dictionary mapping dependency keys to values.
Keys should be in format ``"<type>.<parameter>"`` or ``"<type>.<dict-parameter>.<key>"``.
Returns:
New instance of the class.
@@ -262,7 +296,7 @@ class SerializationMixin:
def _get_type_identifier(cls) -> str:
"""Get the type identifier for this class.
Returns the value of the 'type' class variable if present,
Returns the value of the ``type`` class variable if present,
otherwise returns a snake_cased version of the class name.
Returns:
@@ -35,9 +35,9 @@ AGENT_FRAMEWORK_USER_AGENT = f"{HTTP_USER_AGENT}/{version_info}" # type: ignore
def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None) -> dict[str, Any]:
"""Prepend "agent-framework" to the User-Agent in the headers.
When user agent telemetry is disabled, through the AZURE_TELEMETRY_DISABLED environment variable,
the User-Agent header will not include the agent-framework information, it will be sent back as is,
or as a empty dict when None is passed.
When user agent telemetry is disabled through the ``AGENT_FRAMEWORK_USER_AGENT_DISABLED``
environment variable, the User-Agent header will not include the agent-framework information.
It will be sent back as is, or as an empty dict when None is passed.
Args:
headers: The existing headers dictionary.
@@ -45,6 +45,20 @@ def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None)
Returns:
A new dict with "User-Agent" set to "agent-framework-python/{version}" if headers is None.
The modified headers dictionary with "agent-framework-python/{version}" prepended to the User-Agent.
Examples:
.. code-block:: python
from agent_framework import prepend_agent_framework_to_user_agent
# Add agent-framework to new headers
headers = prepend_agent_framework_to_user_agent()
print(headers["User-Agent"]) # "agent-framework-python/0.1.0"
# Prepend to existing headers
existing = {"User-Agent": "my-app/1.0"}
headers = prepend_agent_framework_to_user_agent(existing)
print(headers["User-Agent"]) # "agent-framework-python/0.1.0 my-app/1.0"
"""
if not IS_TELEMETRY_ENABLED:
return headers or {}
+123 -18
View File
@@ -17,6 +17,38 @@ class ChatMessageStoreProtocol(Protocol):
Implementations of this protocol are responsible for managing the storage of chat messages,
including handling large volumes of data by truncating or summarizing messages as necessary.
Examples:
.. code-block:: python
from agent_framework import ChatMessage
class MyMessageStore:
def __init__(self):
self._messages = []
async def list_messages(self) -> list[ChatMessage]:
return self._messages
async def add_messages(self, messages: Sequence[ChatMessage]) -> None:
self._messages.extend(messages)
@classmethod
async def deserialize(cls, serialized_store_state, **kwargs):
store = cls()
store._messages = serialized_store_state.get("messages", [])
return store
async def update_from_state(self, serialized_store_state, **kwargs) -> None:
self._messages = serialized_store_state.get("messages", [])
async def serialize(self, **kwargs):
return {"messages": self._messages}
# Use the custom store
store = MyMessageStore()
"""
async def list_messages(self) -> list[ChatMessage]:
@@ -27,21 +59,32 @@ class ChatMessageStoreProtocol(Protocol):
If the messages stored in the store become very large, it is up to the store to
truncate, summarize or otherwise limit the number of messages returned.
When using implementations of ChatMessageStoreProtocol, a new one should be created for each thread
When using implementations of ``ChatMessageStoreProtocol``, a new one should be created for each thread
since they may contain state that is specific to a thread.
"""
...
async def add_messages(self, messages: Sequence[ChatMessage]) -> None:
"""Adds messages to the store."""
"""Adds messages to the store.
Args:
messages: The sequence of ChatMessage objects to add to the store.
"""
...
@classmethod
async def deserialize(cls, serialized_store_state: Any, **kwargs: Any) -> "ChatMessageStoreProtocol":
"""Creates a new instance of the store from previously serialized state.
This method, together with serialize_state can be used to save and load messages from a persistent store
This method, together with ``serialize()`` can be used to save and load messages from a persistent store
if this store only has messages in memory.
Args:
serialized_store_state: The previously serialized state data containing messages.
**kwargs: Additional arguments for deserialization.
Returns:
A new instance of the store populated with messages from the serialized state.
"""
...
@@ -57,8 +100,14 @@ class ChatMessageStoreProtocol(Protocol):
async def serialize(self, **kwargs: Any) -> Any:
"""Serializes the current object's state.
This method, together with deserialize can be used to save and load messages from a persistent store
This method, together with ``deserialize()`` can be used to save and load messages from a persistent store
if this store only has messages in memory.
Args:
**kwargs: Additional arguments for serialization.
Returns:
The serialized state data that can be used with ``deserialize()``.
"""
...
@@ -107,13 +156,34 @@ class ChatMessageStore:
This implementation provides a simple, list-based storage for chat messages
with support for serialization and deserialization. It implements all the
required methods of the ChatMessageStoreProtocol protocol.
required methods of the ``ChatMessageStoreProtocol`` protocol.
The store maintains messages in memory and provides methods to serialize
and deserialize the state for persistence purposes.
Args:
messages: Optional initial list of ChatMessage objects to populate the store.
messages: The optional initial list of ChatMessage objects to populate the store.
Examples:
.. code-block:: python
from agent_framework import ChatMessageStore, ChatMessage
# Create an empty store
store = ChatMessageStore()
# Add messages
message = ChatMessage(role="user", content="Hello")
await store.add_messages([message])
# Retrieve messages
messages = await store.list_messages()
# Serialize for persistence
state = await store.serialize()
# Deserialize from saved state
restored_store = await ChatMessageStore.deserialize(state)
"""
def __init__(self, messages: Sequence[ChatMessage] | None = None):
@@ -188,7 +258,36 @@ TAgentThread = TypeVar("TAgentThread", bound="AgentThread")
class AgentThread:
"""The Agent thread class, this can represent both a locally managed thread or a thread managed by the service."""
"""The Agent thread class, this can represent both a locally managed thread or a thread managed by the service.
An ``AgentThread`` maintains the conversation state and message history for an agent interaction.
It can either use a service-managed thread (via ``service_thread_id``) or a local message store
(via ``message_store``), but not both.
Examples:
.. code-block:: python
from agent_framework import ChatAgent, ChatMessageStore
from agent_framework.openai import OpenAIChatClient
client = OpenAIChatClient(model="gpt-4o")
# Create agent with service-managed threads using a service_thread_id
service_agent = ChatAgent(name="assistant", client=client)
service_thread = await service_agent.get_new_thread(service_thread_id="thread_abc123")
# Create agent with service-managed threads using conversation_id
conversation_agent = ChatAgent(name="assistant", client=client, conversation_id="thread_abc123")
conversation_thread = await conversation_agent.get_new_thread()
# Create agent with custom message store factory
local_agent = ChatAgent(name="assistant", client=client, chat_message_store_factory=ChatMessageStore)
local_thread = await local_agent.get_new_thread()
# Serialize and restore thread state
state = await local_thread.serialize()
restored_thread = await local_agent.deserialize_thread(state)
"""
def __init__(
self,
@@ -197,15 +296,15 @@ class AgentThread:
message_store: ChatMessageStoreProtocol | None = None,
context_provider: AggregateContextProvider | None = None,
) -> None:
"""Initialize an AgentThread, do not use this method manually, always use: agent.get_new_thread().
"""Initialize an AgentThread, do not use this method manually, always use: ``agent.get_new_thread()``.
Args:
service_thread_id: Optional ID of the thread managed by the agent service.
message_store: Optional ChatMessageStore implementation for managing chat messages.
context_provider: Optional ContextProvider for the thread.
service_thread_id: The optional ID of the thread managed by the agent service.
message_store: The optional ChatMessageStore implementation for managing chat messages.
context_provider: The optional ContextProvider for the thread.
Note:
Either service_thread_id or message_store may be set, but not both.
Either ``service_thread_id`` or ``message_store`` may be set, but not both.
"""
if service_thread_id is not None and message_store is not None:
raise AgentThreadException("Only the service_thread_id or message_store may be set, but not both.")
@@ -218,7 +317,7 @@ class AgentThread:
def is_initialized(self) -> bool:
"""Indicates if the thread is initialized.
This means either the service_thread_id or the message_store is set.
This means either the ``service_thread_id`` or the ``message_store`` is set.
"""
return self._service_thread_id is not None or self._message_store is not None
@@ -231,7 +330,8 @@ class AgentThread:
def service_thread_id(self, service_thread_id: str | None) -> None:
"""Sets the ID of the current thread to support cases where the thread is owned by the agent service.
Note that either service_thread_id or message_store may be set, but not both.
Note:
Either ``service_thread_id`` or ``message_store`` may be set, but not both.
"""
if service_thread_id is None:
return
@@ -245,14 +345,15 @@ class AgentThread:
@property
def message_store(self) -> ChatMessageStoreProtocol | None:
"""Gets the ChatMessageStoreProtocol used by this thread."""
"""Gets the ``ChatMessageStoreProtocol`` used by this thread."""
return self._message_store
@message_store.setter
def message_store(self, message_store: ChatMessageStoreProtocol | None) -> None:
"""Sets the ChatMessageStoreProtocol used by this thread.
"""Sets the ``ChatMessageStoreProtocol`` used by this thread.
Note that either service_thread_id or message_store may be set, but not both.
Note:
Either ``service_thread_id`` or ``message_store`` may be set, but not both.
"""
if message_store is None:
return
@@ -266,7 +367,11 @@ class AgentThread:
self._message_store = message_store
async def on_new_messages(self, new_messages: ChatMessage | Sequence[ChatMessage]) -> None:
"""Invoked when a new message has been contributed to the chat by any participant."""
"""Invoked when a new message has been contributed to the chat by any participant.
Args:
new_messages: The new ChatMessage or sequence of ChatMessage objects to add to the thread.
"""
if self._service_thread_id is not None:
# If the thread messages are stored in the service there is nothing to do here,
# since invoking the service should already update the thread.
+343 -52
View File
@@ -89,7 +89,18 @@ _NOOP_HISTOGRAM = _NoOpHistogram()
def _parse_inputs(
inputs: "Contents | dict[str, Any] | str | list[Contents | dict[str, Any] | str] | None",
) -> list["Contents"]:
"""Parse the inputs for a tool, ensuring they are of type Contents."""
"""Parse the inputs for a tool, ensuring they are of type Contents.
Args:
inputs: The inputs to parse. Can be a single item or list of Contents, dicts, or strings.
Returns:
A list of Contents objects.
Raises:
ValueError: If an unsupported input type is encountered.
TypeError: If the input type is not supported.
"""
if inputs is None:
return []
@@ -133,10 +144,32 @@ def _parse_inputs(
class ToolProtocol(Protocol):
"""Represents a generic tool that can be specified to an AI service.
Parameters:
This protocol defines the interface that all tools must implement to be compatible
with the agent framework.
Attributes:
name: The name of the tool.
description: A description 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
@@ -154,10 +187,27 @@ 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.
Args:
name: The name of the tool.
description: A description of the tool.
additional_properties: Additional properties associated with the tool.
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)
"""
DEFAULT_EXCLUDE: ClassVar[set[str]] = {"additional_properties"}
@@ -196,6 +246,17 @@ class HostedCodeInterpreterTool(BaseTool):
This tool does not implement code interpretation itself. It serves as a marker to inform a service
that it is allowed to execute generated code if the service is capable of doing so.
Examples:
.. code-block:: python
from agent_framework import HostedCodeInterpreterTool
# Create a code interpreter tool
code_tool = HostedCodeInterpreterTool()
# With file inputs
code_tool_with_files = HostedCodeInterpreterTool(inputs=[{"file_id": "file-123"}, {"file_id": "file-456"}])
"""
def __init__(
@@ -235,7 +296,22 @@ class HostedCodeInterpreterTool(BaseTool):
class HostedWebSearchTool(BaseTool):
"""Represents a web search tool that can be specified to an AI service to enable it to perform web searches."""
"""Represents a web search tool that can be specified to an AI service to enable it to perform web searches.
Examples:
.. code-block:: python
from agent_framework import HostedWebSearchTool
# Create a basic web search tool
search_tool = HostedWebSearchTool()
# With location context
search_tool_with_location = HostedWebSearchTool(
description="Search the web for information",
additional_properties={"user_location": {"city": "Seattle", "country": "US"}},
)
"""
def __init__(
self,
@@ -265,13 +341,14 @@ class HostedWebSearchTool(BaseTool):
class HostedMCPSpecificApproval(TypedDict, total=False):
"""Represents the `specific` mode for a hosted tool.
"""Represents the specific mode for a hosted tool.
When using this mode, the user must specify which tools always or never require approval.
This is represented as a dictionary with two optional keys:
- `always_require_approval`: A sequence of tool names that always require approval.
- `never_require_approval`: A sequence of tool names that never require approval.
Attributes:
always_require_approval: A sequence of tool names that always require approval.
never_require_approval: A sequence of tool names that never require approval.
"""
always_require_approval: Collection[str] | None
@@ -279,7 +356,39 @@ class HostedMCPSpecificApproval(TypedDict, total=False):
class HostedMCPTool(BaseTool):
"""Represents a MCP tool that is managed and executed by the service."""
"""Represents a MCP tool that is managed and executed by the service.
Examples:
.. code-block:: python
from agent_framework import HostedMCPTool
# Create a basic MCP tool
mcp_tool = HostedMCPTool(
name="my_mcp_tool",
url="https://example.com/mcp",
)
# With approval mode and allowed tools
mcp_tool_with_approval = HostedMCPTool(
name="my_mcp_tool",
description="My MCP tool",
url="https://example.com/mcp",
approval_mode="always_require",
allowed_tools=["tool1", "tool2"],
headers={"Authorization": "Bearer token"},
)
# With specific approval mode
mcp_tool_specific = HostedMCPTool(
name="my_mcp_tool",
url="https://example.com/mcp",
approval_mode={
"always_require_approval": ["dangerous_tool"],
"never_require_approval": ["safe_tool"],
},
)
"""
def __init__(
self,
@@ -346,7 +455,23 @@ class HostedMCPTool(BaseTool):
class HostedFileSearchTool(BaseTool):
"""Represents a file search tool that can be specified to an AI service to enable it to perform file searches."""
"""Represents a file search tool that can be specified to an AI service to enable it to perform file searches.
Examples:
.. code-block:: python
from agent_framework import HostedFileSearchTool
# Create a basic file search tool
file_search = HostedFileSearchTool()
# With vector store inputs and max results
file_search_with_inputs = HostedFileSearchTool(
inputs=[{"vector_store_id": "vs_123"}],
max_results=10,
description="Search files in vector store",
)
"""
def __init__(
self,
@@ -387,7 +512,12 @@ class HostedFileSearchTool(BaseTool):
def _default_histogram() -> Histogram:
"""Get the default histogram for function invocation duration."""
"""Get the default histogram for function invocation duration.
Returns:
A Histogram instance for recording function invocation duration,
or a no-op histogram if observability is disabled.
"""
from .observability import OBSERVABILITY_SETTINGS # local import to avoid circulars
if not OBSERVABILITY_SETTINGS.ENABLED: # type: ignore[name-defined]
@@ -411,12 +541,49 @@ def _default_histogram() -> Histogram:
class AIFunction(BaseTool, Generic[ArgsT, ReturnT]):
"""A AITool that is callable as code.
This class wraps a Python function to make it callable by AI models with automatic
parameter validation and JSON schema generation.
Args:
name: The name of the function.
description: A description of the function.
additional_properties: Additional properties to set on the function.
func: The function to wrap. If None, returns a decorator.
func: The function to wrap.
input_model: The Pydantic model that defines the input parameters for the function.
Examples:
.. code-block:: python
from typing import Annotated
from pydantic import BaseModel
from agent_framework import AIFunction, ai_function
# Using the decorator with string annotations
@ai_function
def get_weather(
location: Annotated[str, "The city name"],
unit: Annotated[str, "Temperature unit"] = "celsius",
) -> str:
'''Get the weather for a location.'''
return f"Weather in {location}: 22°{unit[0].upper()}"
# Using direct instantiation with Field
class WeatherArgs(BaseModel):
location: Annotated[str, Field(description="The city name")]
unit: Annotated[str, Field(description="Temperature unit")] = "celsius"
weather_func = AIFunction(
name="get_weather",
description="Get the weather for a location",
func=lambda location, unit="celsius": f"Weather in {location}: 22°{unit[0].upper()}",
input_model=WeatherArgs,
)
# Invoke the function
result = await weather_func.invoke(arguments=WeatherArgs(location="Seattle"))
"""
INJECTABLE: ClassVar[set[str]] = {"func"}
@@ -466,7 +633,13 @@ class AIFunction(BaseTool, Generic[ArgsT, ReturnT]):
Args:
arguments: A Pydantic model instance containing the arguments for the function.
kwargs: keyword arguments to pass to the function, will not be used if `arguments` is provided.
kwargs: Keyword arguments to pass to the function, will not be used if ``arguments`` is provided.
Returns:
The result of the function execution.
Raises:
TypeError: If arguments is not an instance of the expected input model.
"""
global OBSERVABILITY_SETTINGS
from .observability import OBSERVABILITY_SETTINGS
@@ -530,11 +703,19 @@ class AIFunction(BaseTool, Generic[ArgsT, ReturnT]):
logger.info("Function duration: %fs", duration)
def parameters(self) -> dict[str, Any]:
"""Create the json schema of the parameters."""
"""Create the JSON schema of the parameters.
Returns:
A dictionary containing the JSON schema for the function's parameters.
"""
return self.input_model.model_json_schema()
def to_json_schema_spec(self) -> dict[str, Any]:
"""Convert a AIFunction to the JSON Schema function specification format."""
"""Convert a AIFunction to the JSON Schema function specification format.
Returns:
A dictionary containing the function specification in JSON Schema format.
"""
return {
"type": "function",
"function": {
@@ -554,7 +735,14 @@ def _tools_to_dict(
| None
),
) -> list[str | dict[str, Any]] | None:
"""Parse the tools to a dict."""
"""Parse the tools to a dict.
Args:
tools: The tools to parse. Can be a single tool or a sequence of tools.
Returns:
A list of tool specifications as dictionaries, or None if no tools provided.
"""
if not tools:
return None
if not isinstance(tools, list):
@@ -592,8 +780,14 @@ def _tools_to_dict(
def _parse_annotation(annotation: Any) -> Any:
"""Parse a type annotation and return the corresponding type.
If the second annotation (after the type) is a string, then we convert that to a pydantic Field description.
If the second annotation (after the type) is a string, then we convert that to a Pydantic Field description.
The rest are returned as-is, allowing for multiple annotations.
Args:
annotation: The type annotation to parse.
Returns:
The parsed annotation, potentially wrapped in Annotated with a Field.
"""
origin = get_origin(annotation)
if origin is not None:
@@ -617,26 +811,13 @@ def ai_function(
) -> AIFunction[Any, ReturnT]:
"""Decorate a function to turn it into a AIFunction that can be passed to models and executed automatically.
This function will create a Pydantic model from the function's signature,
which will be used to validate the arguments passed to the function.
And will be used to generate the JSON schema for the function's parameters.
In order to add descriptions to parameters, in your function signature,
use the `Annotated` type from `typing` and the `Field` class from `pydantic`:
This decorator creates a Pydantic model from the function's signature,
which will be used to validate the arguments passed to the function
and to generate the JSON schema for the function's parameters.
Example:
.. code-block:: python
from typing import Annotated
from pydantic import Field
def ai_function_example(
arg1: Annotated[str, Field(description="The first argument")],
arg2: Annotated[int, Field(description="The second argument")],
) -> str:
# An example function that takes two arguments and returns a string.
return f"arg1: {arg1}, arg2: {arg2}"
To add descriptions to parameters, use the ``Annotated`` type from ``typing``
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 wrap. If None, returns a decorator.
@@ -644,6 +825,38 @@ def ai_function(
description: A description of the tool. Defaults to the function's docstring.
additional_properties: Additional properties to set on the tool.
Returns:
An AIFunction instance that wraps the decorated function.
Examples:
.. code-block:: python
from typing import Annotated
from agent_framework import ai_function
# Using string annotations (recommended)
@ai_function
def get_weather(
location: Annotated[str, "The city name"],
unit: Annotated[str, "Temperature unit"] = "celsius",
) -> str:
'''Get the weather for a location.'''
return f"Weather in {location}: 22°{unit[0].upper()}"
# With custom name and description
@ai_function(name="custom_weather", description="Custom weather function")
def another_weather_func(location: str) -> str:
return f"Weather in {location}"
# Async functions are also supported
@ai_function
async def async_get_weather(location: str) -> str:
'''Get weather asynchronously.'''
# Simulate async operation
return f"Weather in {location}"
"""
def decorator(func: Callable[..., ReturnT | Awaitable[ReturnT]]) -> AIFunction[Any, ReturnT]:
@@ -689,7 +902,22 @@ async def _auto_invoke_function(
request_index: int | None = None,
middleware_pipeline: Any = None, # Optional MiddlewarePipeline
) -> "Contents":
"""Invoke a function call requested by the agent, applying filters that are defined in the agent."""
"""Invoke a function call requested by the agent, applying middleware that is defined.
Args:
function_call_content: The function call content from the model.
custom_args: Additional custom arguments to merge with parsed arguments.
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.
middleware_pipeline: Optional middleware pipeline to apply during execution.
Returns:
A FunctionResultContent containing the result or exception.
Raises:
KeyError: If the requested function is not found in the tool map.
"""
from ._types import FunctionResultContent
tool: AIFunction[BaseModel, Any] | None = tool_map.get(function_call_content.name)
@@ -775,6 +1003,18 @@ async def execute_function_calls(
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]",
middleware_pipeline: Any = None, # Optional MiddlewarePipeline to avoid circular imports
) -> list["Contents"]:
"""Execute multiple function calls concurrently.
Args:
custom_args: Custom arguments to pass to each function.
attempt_idx: The index of the current attempt iteration.
function_calls: A sequence of FunctionCallContent to execute.
tools: The tools available for execution.
middleware_pipeline: Optional middleware pipeline to apply during execution.
Returns:
A list of Contents containing the results of each function call.
"""
tool_map = _get_tool_map(tools)
# Run all function calls concurrently
return await asyncio.gather(*[
@@ -790,8 +1030,13 @@ async def execute_function_calls(
])
def update_conversation_id(kwargs: dict[str, Any], conversation_id: str | None) -> None:
"""Update kwargs with conversation id."""
def _update_conversation_id(kwargs: dict[str, Any], conversation_id: str | None) -> None:
"""Update kwargs with conversation id.
Args:
kwargs: The keyword arguments dictionary to update.
conversation_id: The conversation ID to set, or None to skip.
"""
if conversation_id is None:
return
if "chat_options" in kwargs:
@@ -802,15 +1047,14 @@ def update_conversation_id(kwargs: dict[str, Any], conversation_id: str | None)
def _handle_function_calls_response(
func: Callable[..., Awaitable["ChatResponse"]],
*,
max_iterations: int = 10,
) -> Callable[..., Awaitable["ChatResponse"]]:
"""Decorate the get_response method to enable function calls.
Args:
func: The get_response method to decorate.
max_iterations: The maximum number of function call iterations to perform.
Returns:
A decorated function that handles function calls automatically.
"""
def decorator(
@@ -835,10 +1079,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)
prepped_messages = prepare_messages(messages)
response: "ChatResponse | None" = None
fcc_messages: "list[ChatMessage]" = []
for attempt_idx in range(max_iterations):
for attempt_idx in range(instance_max_iterations):
response = await func(self, messages=prepped_messages, **kwargs)
# if there are function calls, we will handle them first
function_results = {
@@ -851,7 +1102,7 @@ def _handle_function_calls_response(
]
if response.conversation_id is not None:
update_conversation_id(kwargs, response.conversation_id)
_update_conversation_id(kwargs, response.conversation_id)
prepped_messages = []
tools = kwargs.get("tools")
@@ -909,15 +1160,14 @@ def _handle_function_calls_response(
def _handle_function_calls_streaming_response(
func: Callable[..., AsyncIterable["ChatResponseUpdate"]],
*,
max_iterations: int = 10,
) -> Callable[..., AsyncIterable["ChatResponseUpdate"]]:
"""Decorate the get_streaming_response method to handle function calls.
Args:
func: The get_streaming_response method to decorate.
max_iterations: The maximum number of function call iterations to perform.
Returns:
A decorated function that handles function calls in streaming mode.
"""
def decorator(
@@ -943,8 +1193,15 @@ 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)
prepped_messages = prepare_messages(messages)
for attempt_idx in range(max_iterations):
for attempt_idx in range(instance_max_iterations):
all_updates: list["ChatResponseUpdate"] = []
async for update in func(self, messages=prepped_messages, **kwargs):
all_updates.append(update)
@@ -971,7 +1228,7 @@ def _handle_function_calls_streaming_response(
# When conversation id is present, it means that messages are hosted on the server.
# In this case, we need to update kwargs with conversation id and also clear messages
if response.conversation_id is not None:
update_conversation_id(kwargs, response.conversation_id)
_update_conversation_id(kwargs, response.conversation_id)
prepped_messages = []
tools: Sequence[ToolProtocol | MutableMapping[str, Any]] | None = kwargs.get("tools")
@@ -1008,16 +1265,51 @@ def _handle_function_calls_streaming_response(
def use_function_invocation(
chat_client: type[TChatClient],
) -> type[TChatClient]:
"""Class decorator that enables tool calling for a chat client."""
"""Class decorator that enables tool calling for a chat client.
This decorator wraps the ``get_response`` and ``get_streaming_response`` methods
to automatically handle function calls from the model, execute them, and return
the results back to the model for further processing.
Args:
chat_client: The chat client class to decorate.
Returns:
The decorated chat client class with function invocation enabled.
Raises:
ChatClientInitializationError: If the chat client does not have the required methods.
Examples:
.. code-block:: python
from agent_framework import use_function_invocation, BaseChatClient
@use_function_invocation
class MyCustomClient(BaseChatClient):
async def get_response(self, messages, **kwargs):
# Implementation here
pass
async def get_streaming_response(self, messages, **kwargs):
# Implementation here
pass
# The client now automatically handles function calls
client = MyCustomClient()
"""
if getattr(chat_client, FUNCTION_INVOKING_CHAT_CLIENT_MARKER, False):
return chat_client
max_iterations = DEFAULT_MAX_ITERATIONS
# 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
max_iterations=max_iterations,
)
except AttributeError as ex:
raise ChatClientInitializationError(
@@ -1026,7 +1318,6 @@ def use_function_invocation(
try:
chat_client.get_streaming_response = _handle_function_calls_streaming_response( # type: ignore
func=chat_client.get_streaming_response,
max_iterations=max_iterations,
)
except AttributeError as ex:
raise ChatClientInitializationError(
+495 -7
View File
@@ -200,6 +200,34 @@ class UsageDetails(SerializationMixin):
output_token_count: The number of tokens in the output.
total_token_count: The total number of tokens used to produce the response.
additional_counts: A dictionary of additional token counts, can be set by passing kwargs.
Examples:
.. code-block:: python
from agent_framework import UsageDetails
# Create usage details
usage = UsageDetails(
input_token_count=100,
output_token_count=50,
total_token_count=150,
)
print(usage.total_token_count) # 150
# With additional counts
usage = UsageDetails(
input_token_count=100,
output_token_count=50,
total_token_count=150,
reasoning_tokens=25,
)
print(usage.additional_counts["reasoning_tokens"]) # 25
# Combine usage details
usage1 = UsageDetails(input_token_count=100, output_token_count=50)
usage2 = UsageDetails(input_token_count=200, output_token_count=100)
combined = usage1 + usage2
print(combined.input_token_count) # 300
"""
DEFAULT_EXCLUDE: ClassVar[set[str]] = {"_extra_counts"}
@@ -332,7 +360,17 @@ class UsageDetails(SerializationMixin):
class TextSpanRegion(SerializationMixin):
"""Represents a region of text that has been annotated."""
"""Represents a region of text that has been annotated.
Examples:
.. code-block:: python
from agent_framework import TextSpanRegion
# Create a text span region
region = TextSpanRegion(start_index=0, end_index=10)
print(region.type) # "text_span"
"""
def __init__(
self,
@@ -444,6 +482,20 @@ class CitationAnnotation(BaseAnnotation):
annotated_regions: A list of regions that have been annotated with this citation.
additional_properties: Optional additional properties associated with the content.
raw_representation: Optional raw representation of the content from an underlying implementation.
Examples:
.. code-block:: python
from agent_framework import CitationAnnotation, TextSpanRegion
# Create a citation annotation
citation = CitationAnnotation(
title="Agent Framework Documentation",
url="https://example.com/docs",
snippet="This is a relevant excerpt...",
annotated_regions=[TextSpanRegion(start_index=0, end_index=25)],
)
print(citation.title) # "Agent Framework Documentation"
"""
def __init__(
@@ -576,6 +628,21 @@ class TextContent(BaseContent):
annotations: Optional annotations associated with the content.
additional_properties: Optional additional properties associated with the content.
raw_representation: Optional raw representation of the content.
Examples:
.. code-block:: python
from agent_framework import TextContent
# Create basic text content
text = TextContent(text="Hello, world!")
print(text.text) # "Hello, world!"
# Concatenate text content
text1 = TextContent(text="Hello, ")
text2 = TextContent(text="world!")
combined = text1 + text2
print(combined.text) # "Hello, world!"
"""
def __init__(
@@ -701,6 +768,21 @@ class TextReasoningContent(BaseContent):
annotations: Optional annotations associated with the content.
additional_properties: Optional additional properties associated with the content.
raw_representation: Optional raw representation of the content.
Examples:
.. code-block:: python
from agent_framework import TextReasoningContent
# Create reasoning content
reasoning = TextReasoningContent(text="Let me think step by step...")
print(reasoning.text) # "Let me think step by step..."
# Concatenate reasoning content
reasoning1 = TextReasoningContent(text="First, ")
reasoning2 = TextReasoningContent(text="second, ")
combined = reasoning1 + reasoning2
print(combined.text) # "First, second, "
"""
def __init__(
@@ -823,6 +905,22 @@ class DataContent(BaseContent):
additional_properties: Optional additional properties associated with the content.
raw_representation: Optional raw representation of the content.
Examples:
.. code-block:: python
from agent_framework import DataContent
# Create from binary data
image_data = b"raw image bytes"
data_content = DataContent(data=image_data, media_type="image/png")
# Create from data URI
data_uri = "data:image/png;base64,iVBORw0KGgoAAAANS..."
data_content = DataContent(uri=data_uri)
# Check media type
if data_content.has_top_level_media_type("image"):
print("This is an image")
"""
@overload
@@ -960,6 +1058,26 @@ class UriContent(BaseContent):
additional_properties: Optional additional properties associated with the content.
raw_representation: Optional raw representation of the content.
Examples:
.. code-block:: python
from agent_framework import UriContent
# Create URI content for an image
image_uri = UriContent(
uri="https://example.com/image.png",
media_type="image/png",
)
# Create URI content for a document
doc_uri = UriContent(
uri="https://example.com/document.pdf",
media_type="application/pdf",
)
# Check if it's an image
if image_uri.has_top_level_media_type("image"):
print("This is an image URI")
"""
def __init__(
@@ -1028,7 +1146,22 @@ class ErrorContent(BaseContent):
additional_properties: Optional additional properties associated with the content.
raw_representation: Optional raw representation of the content.
Examples:
.. code-block:: python
from agent_framework import ErrorContent
# Create an error content
error = ErrorContent(
message="Failed to process request",
error_code="PROCESSING_ERROR",
details="The input format was invalid",
)
print(str(error)) # "Error PROCESSING_ERROR: Failed to process request"
# Error without code
simple_error = ErrorContent(message="Something went wrong")
print(str(simple_error)) # "Something went wrong"
"""
def __init__(
@@ -1082,6 +1215,28 @@ class FunctionCallContent(BaseContent):
additional_properties: Optional additional properties associated with the content.
raw_representation: Optional raw representation of the content.
Examples:
.. code-block:: python
from agent_framework import FunctionCallContent
# Create a function call
func_call = FunctionCallContent(
call_id="call_123",
name="get_weather",
arguments={"location": "Seattle", "unit": "celsius"},
)
# Parse arguments
args = func_call.parse_arguments()
print(args) # {"location": "Seattle", "unit": "celsius"}
# Create with string arguments (gradual completion)
func_call_partial = FunctionCallContent(
call_id="call_124",
name="search",
arguments='{"query": "weather"}',
)
"""
def __init__(
@@ -1170,6 +1325,23 @@ class FunctionResultContent(BaseContent):
additional_properties: Optional additional properties associated with the content.
raw_representation: Optional raw representation of the content.
Examples:
.. code-block:: python
from agent_framework import FunctionResultContent
# Create a successful function result
result = FunctionResultContent(
call_id="call_123",
result={"temperature": 22, "condition": "sunny"},
)
# Create a failed function result
failed_result = FunctionResultContent(
call_id="call_124",
result="Function execution failed",
exception=ValueError("Invalid location"),
)
"""
def __init__(
@@ -1216,6 +1388,20 @@ class UsageContent(BaseContent):
additional_properties: Optional additional properties associated with the content.
raw_representation: Optional raw representation of the content.
Examples:
.. code-block:: python
from agent_framework import UsageContent, UsageDetails
# Create usage content
usage = UsageContent(
details=UsageDetails(
input_token_count=100,
output_token_count=50,
total_token_count=150,
),
)
print(usage.details.total_token_count) # 150
"""
def __init__(
@@ -1250,6 +1436,14 @@ class HostedFileContent(BaseContent):
additional_properties: Optional additional properties associated with the content.
raw_representation: Optional raw representation of the content.
Examples:
.. code-block:: python
from agent_framework import HostedFileContent
# Create hosted file content
file_content = HostedFileContent(file_id="file-abc123")
print(file_content.file_id) # "file-abc123"
"""
def __init__(
@@ -1279,6 +1473,14 @@ class HostedVectorStoreContent(BaseContent):
additional_properties: Optional additional properties associated with the content.
raw_representation: Optional raw representation of the content.
Examples:
.. code-block:: python
from agent_framework import HostedVectorStoreContent
# Create hosted vector store content
vs_content = HostedVectorStoreContent(vector_store_id="vs-xyz789")
print(vs_content.vector_store_id) # "vs-xyz789"
"""
def __init__(
@@ -1333,7 +1535,26 @@ class BaseUserInputRequest(BaseContent):
class FunctionApprovalResponseContent(BaseContent):
"""Represents a response for user approval of a function call."""
"""Represents a response for user approval of a function call.
Examples:
.. code-block:: python
from agent_framework import FunctionApprovalResponseContent, FunctionCallContent
# Create a function approval response
func_call = FunctionCallContent(
call_id="call_123",
name="send_email",
arguments={"to": "user@example.com"},
)
response = FunctionApprovalResponseContent(
approved=False,
id="approval_001",
function_call=func_call,
)
print(response.approved) # False
"""
def __init__(
self,
@@ -1375,7 +1596,28 @@ class FunctionApprovalResponseContent(BaseContent):
class FunctionApprovalRequestContent(BaseContent):
"""Represents a request for user approval of a function call."""
"""Represents a request for user approval of a function call.
Examples:
.. code-block:: python
from agent_framework import FunctionApprovalRequestContent, FunctionCallContent
# Create a function approval request
func_call = FunctionCallContent(
call_id="call_123",
name="send_email",
arguments={"to": "user@example.com", "subject": "Hello"},
)
approval_request = FunctionApprovalRequestContent(
id="approval_001",
function_call=func_call,
)
# Create response
approval_response = approval_request.create_response(approved=True)
print(approval_response.approved) # True
"""
def __init__(
self,
@@ -1453,6 +1695,24 @@ class Role(SerializationMixin, metaclass=EnumLike):
USER: The role that provides user input for chat interactions.
ASSISTANT: The role that provides responses to system-instructed, user-prompted input.
TOOL: The role that provides additional information and references in response to tool use requests.
Examples:
.. code-block:: python
from agent_framework import Role
# Use predefined role constants
system_role = Role.SYSTEM
user_role = Role.USER
assistant_role = Role.ASSISTANT
tool_role = Role.TOOL
# Create custom role
custom_role = Role(value="custom")
# Compare roles
print(system_role == Role.SYSTEM) # True
print(system_role.value) # "system"
"""
# Constants configuration for EnumLike metaclass
@@ -1501,6 +1761,21 @@ class FinishReason(SerializationMixin, metaclass=EnumLike):
Attributes:
value: The string representation of the finish reason.
Examples:
.. code-block:: python
from agent_framework import FinishReason
# Use predefined finish reason constants
stop_reason = FinishReason.STOP # Normal completion
length_reason = FinishReason.LENGTH # Max tokens reached
tool_calls_reason = FinishReason.TOOL_CALLS # Tool calls triggered
filter_reason = FinishReason.CONTENT_FILTER # Content filter triggered
# Check finish reason
if stop_reason == FinishReason.STOP:
print("Response completed normally")
"""
# Constants configuration for EnumLike metaclass
@@ -1548,7 +1823,7 @@ class FinishReason(SerializationMixin, metaclass=EnumLike):
class ChatMessage(SerializationMixin):
"""Represents a chat message.
r"""Represents a chat message.
Attributes:
role: The role of the author of the message.
@@ -1558,6 +1833,38 @@ class ChatMessage(SerializationMixin):
additional_properties: Any additional properties associated with the chat message.
raw_representation: The raw representation of the chat message from an underlying implementation.
Examples:
.. code-block:: python
from agent_framework import ChatMessage, TextContent
# Create a message with text
user_msg = ChatMessage(role="user", text="What's the weather?")
print(user_msg.text) # "What's the weather?"
# Create a message with role string
system_msg = ChatMessage(role="system", text="You are a helpful assistant.")
# Create a message with contents
assistant_msg = ChatMessage(
role="assistant",
contents=[TextContent(text="The weather is sunny!")],
)
print(assistant_msg.text) # "The weather is sunny!"
# Serialization - to_dict and from_dict
msg_dict = user_msg.to_dict()
# {'type': 'chat_message', 'role': {'type': 'role', 'value': 'user'},
# 'contents': [{'type': 'text', 'text': "What's the weather?"}], 'additional_properties': {}}
restored_msg = ChatMessage.from_dict(msg_dict)
print(restored_msg.text) # "What's the weather?"
# Serialization - to_json and from_json
msg_json = user_msg.to_json()
# '{"type": "chat_message", "role": {"type": "role", "value": "user"}, "contents": [...], ...}'
restored_from_json = ChatMessage.from_json(msg_json)
print(restored_from_json.role.value) # "user"
"""
DEFAULT_EXCLUDE: ClassVar[set[str]] = {"raw_representation"}
@@ -1790,6 +2097,40 @@ class ChatResponse(SerializationMixin):
structured_output: The structured output of the chat response, if applicable.
additional_properties: Any additional properties associated with the chat response.
raw_representation: The raw representation of the chat response from an underlying implementation.
Examples:
.. code-block:: python
from agent_framework import ChatResponse, ChatMessage
# Create a simple text response
response = ChatResponse(text="Hello, how can I help you?")
print(response.text) # "Hello, how can I help you?"
# Create a response with messages
msg = ChatMessage(role="assistant", text="The weather is sunny.")
response = ChatResponse(
messages=[msg],
finish_reason="stop",
model_id="gpt-4",
)
# Combine streaming updates
updates = [...] # List of ChatResponseUpdate objects
response = ChatResponse.from_chat_response_updates(updates)
# Serialization - to_dict and from_dict
response_dict = response.to_dict()
# {'type': 'chat_response', 'messages': [...], 'model_id': 'gpt-4',
# 'finish_reason': {'type': 'finish_reason', 'value': 'stop'}}
restored_response = ChatResponse.from_dict(response_dict)
print(restored_response.model_id) # "gpt-4"
# Serialization - to_json and from_json
response_json = response.to_json()
# '{"type": "chat_response", "messages": [...], "model_id": "gpt-4", ...}'
restored_from_json = ChatResponse.from_json(response_json)
print(restored_from_json.text) # "The weather is sunny."
"""
DEFAULT_EXCLUDE: ClassVar[set[str]] = {"raw_representation", "additional_properties"}
@@ -1993,6 +2334,35 @@ class ChatResponseUpdate(SerializationMixin):
additional_properties: Any additional properties associated with the chat response update.
raw_representation: The raw representation of the chat response update from an underlying implementation.
Examples:
.. code-block:: python
from agent_framework import ChatResponseUpdate, TextContent
# Create a response update
update = ChatResponseUpdate(
contents=[TextContent(text="Hello")],
role="assistant",
message_id="msg_123",
)
print(update.text) # "Hello"
# Create update with text shorthand
update = ChatResponseUpdate(text="World!", role="assistant")
# Serialization - to_dict and from_dict
update_dict = update.to_dict()
# {'type': 'chat_response_update', 'contents': [{'type': 'text', 'text': 'Hello'}],
# 'role': {'type': 'role', 'value': 'assistant'}, 'message_id': 'msg_123'}
restored_update = ChatResponseUpdate.from_dict(update_dict)
print(restored_update.text) # "Hello"
# Serialization - to_json and from_json
update_json = update.to_json()
# '{"type": "chat_response_update", "contents": [{"type": "text", "text": "Hello"}], ...}'
restored_from_json = ChatResponseUpdate.from_json(update_json)
print(restored_from_json.message_id) # "msg_123"
"""
DEFAULT_EXCLUDE: ClassVar[set[str]] = {"raw_representation"}
@@ -2095,6 +2465,37 @@ class AgentRunResponse(SerializationMixin):
Provides one or more response messages and metadata about the response.
A typical response will contain a single message, but may contain multiple
messages in scenarios involving function calls, RAG retrievals, or complex logic.
Examples:
.. code-block:: python
from agent_framework import AgentRunResponse, ChatMessage
# Create agent response
msg = ChatMessage(role="assistant", text="Task completed successfully.")
response = AgentRunResponse(messages=[msg], response_id="run_123")
print(response.text) # "Task completed successfully."
# Access user input requests
user_requests = response.user_input_requests
print(len(user_requests)) # 0
# Combine streaming updates
updates = [...] # List of AgentRunResponseUpdate objects
response = AgentRunResponse.from_agent_run_response_updates(updates)
# Serialization - to_dict and from_dict
response_dict = response.to_dict()
# {'type': 'agent_run_response', 'messages': [...], 'response_id': 'run_123',
# 'additional_properties': {}}
restored_response = AgentRunResponse.from_dict(response_dict)
print(restored_response.response_id) # "run_123"
# Serialization - to_json and from_json
response_json = response.to_json()
# '{"type": "agent_run_response", "messages": [...], "response_id": "run_123", ...}'
restored_from_json = AgentRunResponse.from_json(response_json)
print(restored_from_json.text) # "Task completed successfully."
"""
DEFAULT_EXCLUDE: ClassVar[set[str]] = {"raw_representation"}
@@ -2217,7 +2618,37 @@ class AgentRunResponse(SerializationMixin):
class AgentRunResponseUpdate(SerializationMixin):
"""Represents a single streaming response chunk from an Agent."""
"""Represents a single streaming response chunk from an Agent.
Examples:
.. code-block:: python
from agent_framework import AgentRunResponseUpdate, TextContent
# Create an agent run update
update = AgentRunResponseUpdate(
contents=[TextContent(text="Processing...")],
role="assistant",
response_id="run_123",
)
print(update.text) # "Processing..."
# Check for user input requests
user_requests = update.user_input_requests
# Serialization - to_dict and from_dict
update_dict = update.to_dict()
# {'type': 'agent_run_response_update', 'contents': [{'type': 'text', 'text': 'Processing...'}],
# 'role': {'type': 'role', 'value': 'assistant'}, 'response_id': 'run_123'}
restored_update = AgentRunResponseUpdate.from_dict(update_dict)
print(restored_update.response_id) # "run_123"
# Serialization - to_json and from_json
update_json = update.to_json()
# '{"type": "agent_run_response_update", "contents": [{"type": "text", "text": "Processing..."}], ...}'
restored_from_json = AgentRunResponseUpdate.from_json(update_json)
print(restored_from_json.text) # "Processing..."
"""
DEFAULT_EXCLUDE: ClassVar[set[str]] = {"raw_representation"}
@@ -2294,7 +2725,25 @@ class AgentRunResponseUpdate(SerializationMixin):
class ToolMode(SerializationMixin, metaclass=EnumLike):
"""Defines if and how tools are used in a chat request."""
"""Defines if and how tools are used in a chat request.
Examples:
.. code-block:: python
from agent_framework import ToolMode
# Use predefined tool modes
auto_mode = ToolMode.AUTO # Model decides when to use tools
required_mode = ToolMode.REQUIRED_ANY # Model must use a tool
none_mode = ToolMode.NONE # No tools allowed
# Require a specific function
specific_mode = ToolMode.REQUIRED(function_name="get_weather")
print(specific_mode.required_function_name) # "get_weather"
# Compare modes
print(auto_mode == "auto") # True
"""
# Constants configuration for EnumLike metaclass
_constants: ClassVar[dict[str, tuple[str, ...]]] = {
@@ -2355,7 +2804,46 @@ class ToolMode(SerializationMixin, metaclass=EnumLike):
class ChatOptions(SerializationMixin):
"""Common request settings for AI services."""
"""Common request settings for AI services.
Examples:
.. code-block:: python
from agent_framework import ChatOptions, ai_function
# Create basic chat options
options = ChatOptions(
model_id="gpt-4",
temperature=0.7,
max_tokens=1000,
)
# With tools
@ai_function
def get_weather(location: str) -> str:
'''Get weather for a location.'''
return f"Weather in {location}"
options = ChatOptions(
model_id="gpt-4",
tools=get_weather,
tool_choice="auto",
)
# Require a specific tool to be called
options_required = ChatOptions(
model_id="gpt-4",
tools=get_weather,
tool_choice=ToolMode.REQUIRED(function_name="get_weather"),
)
# Combine options
base_options = ChatOptions(temperature=0.5)
extended_options = ChatOptions(max_tokens=500, tools=get_weather)
combined = base_options & extended_options
"""
DEFAULT_EXCLUDE: ClassVar[set[str]] = {"_tools"} # Internal field, use .tools property
@@ -185,25 +185,26 @@ class ConcurrentBuilder:
- `with_custom_aggregator(...)` overrides the default aggregator with an Executor or callback.
Usage:
```python
from agent_framework import ConcurrentBuilder
# Minimal: use default aggregator (returns list[ChatMessage])
workflow = ConcurrentBuilder().participants([agent1, agent2, agent3]).build()
.. code-block:: python
from agent_framework import ConcurrentBuilder
# Minimal: use default aggregator (returns list[ChatMessage])
workflow = ConcurrentBuilder().participants([agent1, agent2, agent3]).build()
# Custom aggregator via callback (sync or async). The callback receives
# list[AgentExecutorResponse] and its return value becomes the workflow's output.
def summarize(results):
return " | ".join(r.agent_run_response.messages[-1].text for r in results)
# Custom aggregator via callback (sync or async). The callback receives
# list[AgentExecutorResponse] and its return value becomes the workflow's output.
def summarize(results):
return " | ".join(r.agent_run_response.messages[-1].text for r in results)
workflow = ConcurrentBuilder().participants([agent1, agent2, agent3]).with_custom_aggregator(summarize).build()
workflow = ConcurrentBuilder().participants([agent1, agent2, agent3]).with_custom_aggregator(summarize).build()
# Enable checkpoint persistence so runs can resume
workflow = ConcurrentBuilder().participants([agent1, agent2, agent3]).with_checkpointing(storage).build()
```
# Enable checkpoint persistence so runs can resume
workflow = ConcurrentBuilder().participants([agent1, agent2, agent3]).with_checkpointing(storage).build()
"""
def __init__(self) -> None:
@@ -223,12 +224,13 @@ class ConcurrentBuilder:
TypeError: if any entry is not AgentProtocol or Executor
Example:
```python
wf = ConcurrentBuilder().participants([researcher_agent, marketer_agent, legal_agent]).build()
# Mixing agent(s) and executor(s) is supported
wf2 = ConcurrentBuilder().participants([researcher_agent, my_custom_executor]).build()
```
.. code-block:: python
wf = ConcurrentBuilder().participants([researcher_agent, marketer_agent, legal_agent]).build()
# Mixing agent(s) and executor(s) is supported
wf2 = ConcurrentBuilder().participants([researcher_agent, my_custom_executor]).build()
"""
if not participants:
raise ValueError("participants cannot be empty")
@@ -264,14 +266,15 @@ class ConcurrentBuilder:
If the callback returns a non-None value, it becomes the workflow's output.
Example:
```python
# Callback-based aggregator (string result)
async def summarize(results):
return " | ".join(r.agent_run_response.messages[-1].text for r in results)
.. code-block:: python
# Callback-based aggregator (string result)
async def summarize(results):
return " | ".join(r.agent_run_response.messages[-1].text for r in results)
wf = ConcurrentBuilder().participants([a1, a2, a3]).with_custom_aggregator(summarize).build()
```
wf = ConcurrentBuilder().participants([a1, a2, a3]).with_custom_aggregator(summarize).build()
"""
if isinstance(aggregator, Executor):
self._aggregator = aggregator
@@ -303,9 +306,10 @@ class ConcurrentBuilder:
ValueError: if no participants were defined
Example:
```python
workflow = ConcurrentBuilder().participants([agent1, agent2]).build()
```
.. code-block:: python
workflow = ConcurrentBuilder().participants([agent1, agent2]).build()
"""
if not self._participants:
raise ValueError("No participants provided. Call .participants([...]) first.")
@@ -75,32 +75,35 @@ class Executor(DictConvertible):
### Input Types
The types of messages an executor can process, discovered from handler method signatures:
```python
class MyExecutor(Executor):
@handler
async def handle_string(self, message: str, ctx: WorkflowContext) -> None:
# This executor can handle 'str' input types
```
.. code-block:: python
class MyExecutor(Executor):
@handler
async def handle_string(self, message: str, ctx: WorkflowContext) -> None:
# This executor can handle 'str' input types
Access via the `input_types` property.
### Output Types
The types of messages an executor can send to other executors via `ctx.send_message()`:
```python
class MyExecutor(Executor):
@handler
async def handle_data(self, message: str, ctx: WorkflowContext[int | bool]) -> None:
# This executor can send 'int' or 'bool' messages
```
.. code-block:: python
class MyExecutor(Executor):
@handler
async def handle_data(self, message: str, ctx: WorkflowContext[int | bool]) -> None:
# This executor can send 'int' or 'bool' messages
Access via the `output_types` property.
### Workflow Output Types
The types of data an executor can emit as workflow-level outputs via `ctx.yield_output()`:
```python
class MyExecutor(Executor):
@handler
async def process(self, message: str, ctx: WorkflowContext[int, str]) -> None:
# Can send 'int' messages AND yield 'str' workflow outputs
```
.. code-block:: python
class MyExecutor(Executor):
@handler
async def process(self, message: str, ctx: WorkflowContext[int, str]) -> None:
# Can send 'int' messages AND yield 'str' workflow outputs
Access via the `workflow_output_types` property.
## Handler Discovery
@@ -108,66 +111,73 @@ class Executor(DictConvertible):
### @handler Decorator
Marks methods that process incoming messages:
```python
class MyExecutor(Executor):
@handler
async def handle_text(self, message: str, ctx: WorkflowContext[str]) -> None:
await ctx.send_message(message.upper())
```
.. code-block:: python
class MyExecutor(Executor):
@handler
async def handle_text(self, message: str, ctx: WorkflowContext[str]) -> None:
await ctx.send_message(message.upper())
### Sub-workflow Request Interception
Use @handler methods to intercept sub-workflow requests:
```python
class ParentExecutor(Executor):
@handler
async def handle_domain_request(
self,
request: DomainRequest, # Subclass of RequestInfoMessage
ctx: WorkflowContext[RequestResponse[RequestInfoMessage, Any] | DomainRequest],
) -> None:
if self.is_allowed(request.domain):
response = RequestResponse(data=True, original_request=request, request_id=request.request_id)
await ctx.send_message(response, target_id=request.source_executor_id)
else:
await ctx.send_message(request) # Forward to external
```
.. code-block:: python
class ParentExecutor(Executor):
@handler
async def handle_domain_request(
self,
request: DomainRequest, # Subclass of RequestInfoMessage
ctx: WorkflowContext[RequestResponse[RequestInfoMessage, Any] | DomainRequest],
) -> None:
if self.is_allowed(request.domain):
response = RequestResponse(data=True, original_request=request, request_id=request.request_id)
await ctx.send_message(response, target_id=request.source_executor_id)
else:
await ctx.send_message(request) # Forward to external
## Context Types
Handler methods receive different WorkflowContext variants based on their type annotations:
### WorkflowContext (no type parameters)
For handlers that only perform side effects without sending messages or yielding outputs:
```python
class LoggingExecutor(Executor):
@handler
async def log_message(self, msg: str, ctx: WorkflowContext) -> None:
print(f"Received: {msg}") # Only logging, no outputs
```
.. code-block:: python
class LoggingExecutor(Executor):
@handler
async def log_message(self, msg: str, ctx: WorkflowContext) -> None:
print(f"Received: {msg}") # Only logging, no outputs
### WorkflowContext[T_Out]
Enables sending messages of type T_Out via `ctx.send_message()`:
```python
class ProcessorExecutor(Executor):
@handler
async def handler(self, msg: str, ctx: WorkflowContext[int]) -> None:
await ctx.send_message(42) # Can send int messages
```
.. code-block:: python
class ProcessorExecutor(Executor):
@handler
async def handler(self, msg: str, ctx: WorkflowContext[int]) -> None:
await ctx.send_message(42) # Can send int messages
### WorkflowContext[T_Out, T_W_Out]
Enables both sending messages (T_Out) and yielding workflow outputs (T_W_Out):
```python
class DualOutputExecutor(Executor):
@handler
async def handler(self, msg: str, ctx: WorkflowContext[int, str]) -> None:
await ctx.send_message(42) # Send int message
await ctx.yield_output("done") # Yield str workflow output
```
.. code-block:: python
class DualOutputExecutor(Executor):
@handler
async def handler(self, msg: str, ctx: WorkflowContext[int, str]) -> None:
await ctx.send_message(42) # Send int message
await ctx.yield_output("done") # Yield str workflow output
## Function Executors
Simple functions can be converted to executors using the `@executor` decorator:
```python
@executor
async def process_text(text: str, ctx: WorkflowContext[str]) -> None:
.. code-block:: python
@executor
async def process_text(text: str, ctx: WorkflowContext[str]) -> None:
await ctx.send_message(text.upper())
@@ -101,14 +101,15 @@ class SequentialBuilder:
- The final output is the conversation produced by the last participant
Usage:
```python
from agent_framework import SequentialBuilder
workflow = SequentialBuilder().participants([agent1, agent2, summarizer_exec]).build()
.. code-block:: python
# Enable checkpoint persistence
workflow = SequentialBuilder().participants([agent1, agent2]).with_checkpointing(storage).build()
```
from agent_framework import SequentialBuilder
workflow = SequentialBuilder().participants([agent1, agent2, summarizer_exec]).build()
# Enable checkpoint persistence
workflow = SequentialBuilder().participants([agent1, agent2]).with_checkpointing(storage).build()
"""
def __init__(self) -> None:
@@ -283,34 +283,38 @@ class WorkflowContext(Generic[T_Out, T_W_Out]):
### WorkflowContext (no parameters)
For executors that only perform side effects without sending messages or yielding outputs:
```python
async def log_handler(message: str, ctx: WorkflowContext) -> None:
print(f"Received: {message}") # Only side effects
```
.. code-block:: python
async def log_handler(message: str, ctx: WorkflowContext) -> None:
print(f"Received: {message}") # Only side effects
### WorkflowContext[T_Out]
Enables sending messages of type T_Out to other executors:
```python
async def processor(message: str, ctx: WorkflowContext[int]) -> None:
result = len(message)
await ctx.send_message(result) # Send int to downstream executors
```
.. code-block:: python
async def processor(message: str, ctx: WorkflowContext[int]) -> None:
result = len(message)
await ctx.send_message(result) # Send int to downstream executors
### WorkflowContext[T_Out, T_W_Out]
Enables both sending messages (T_Out) and yielding workflow outputs (T_W_Out):
```python
async def dual_output(message: str, ctx: WorkflowContext[int, str]) -> None:
await ctx.send_message(42) # Send int message
await ctx.yield_output("complete") # Yield str workflow output
```
.. code-block:: python
async def dual_output(message: str, ctx: WorkflowContext[int, str]) -> None:
await ctx.send_message(42) # Send int message
await ctx.yield_output("complete") # Yield str workflow output
### Union Types
Multiple types can be specified using union notation:
```python
async def flexible(message: str, ctx: WorkflowContext[int | str, bool | dict]) -> None:
await ctx.send_message("text") # or send 42
await ctx.yield_output(True) # or yield {"status": "done"}
```
.. code-block:: python
async def flexible(message: str, ctx: WorkflowContext[int | str, bool | dict]) -> None:
await ctx.send_message("text") # or send 42
await ctx.yield_output(True) # or yield {"status": "done"}
"""
def __init__(
@@ -63,27 +63,29 @@ class WorkflowExecutor(Executor):
### Output Forwarding
All outputs from the sub-workflow are automatically forwarded to the parent:
```python
# Sub-workflow yields outputs
await ctx.yield_output("sub-workflow result")
# WorkflowExecutor forwards to parent via ctx.send_message()
# Parent receives the output as a regular message
```
.. code-block:: python
# Sub-workflow yields outputs
await ctx.yield_output("sub-workflow result")
# WorkflowExecutor forwards to parent via ctx.send_message()
# Parent receives the output as a regular message
### Request/Response Coordination
When sub-workflows need external information:
```python
# Sub-workflow makes request
request = MyDataRequest(query="user info")
# RequestInfoExecutor emits RequestInfoEvent
# WorkflowExecutor sets source_executor_id and forwards to parent
request.source_executor_id = "child_workflow_executor_id"
# Parent workflow can handle via @handler for RequestInfoMessage subclasses,
# or directly forward to external source via a RequestInfoExecutor in the parent
# workflow.
```
.. code-block:: python
# Sub-workflow makes request
request = MyDataRequest(query="user info")
# RequestInfoExecutor emits RequestInfoEvent
# WorkflowExecutor sets source_executor_id and forwards to parent
request.source_executor_id = "child_workflow_executor_id"
# Parent workflow can handle via @handler for RequestInfoMessage subclasses,
# or directly forward to external source via a RequestInfoExecutor in the parent
# workflow.
### State Management
WorkflowExecutor maintains execution state across request/response cycles:
@@ -97,17 +99,20 @@ class WorkflowExecutor(Executor):
### Input Types
Matches the wrapped workflow's start executor input types:
```python
# If sub-workflow accepts str, WorkflowExecutor accepts str
workflow_executor = WorkflowExecutor(my_workflow, id="wrapper")
assert workflow_executor.input_types == my_workflow.input_types
```
.. code-block:: python
# If sub-workflow accepts str, WorkflowExecutor accepts str
workflow_executor = WorkflowExecutor(my_workflow, id="wrapper")
assert workflow_executor.input_types == my_workflow.input_types
### Output Types
Combines sub-workflow outputs with request coordination types:
```python
# Includes all sub-workflow output types
# Plus RequestInfoMessage if sub-workflow can make requests
.. code-block:: python
# Includes all sub-workflow output types
# Plus RequestInfoMessage if sub-workflow can make requests
output_types = workflow.output_types + [RequestInfoMessage] # if applicable
```
@@ -122,15 +127,16 @@ class WorkflowExecutor(Executor):
### Per-Execution State Isolation
Each sub-workflow invocation creates an isolated ExecutionContext:
```python
# Multiple concurrent invocations are supported
workflow_executor = WorkflowExecutor(my_workflow, id="concurrent_executor")
# Each invocation gets its own execution context
# Execution 1: processes input_1 independently
# Execution 2: processes input_2 independently
# No state interference between executions
```
.. code-block:: python
# Multiple concurrent invocations are supported
workflow_executor = WorkflowExecutor(my_workflow, id="concurrent_executor")
# Each invocation gets its own execution context
# Execution 1: processes input_1 independently
# Execution 2: processes input_2 independently
# No state interference between executions
### Request/Response Coordination
Responses are correctly routed to the originating execution:
@@ -151,23 +157,24 @@ class WorkflowExecutor(Executor):
- The wrapped workflow and its executors are stateless
- Executors use WorkflowContext state management instead of instance variables
- Any shared state is managed through WorkflowContext.get_shared_state/set_shared_state
```python
# Good: Stateless executor using context state
class StatelessExecutor(Executor):
@handler
async def process(self, data: str, ctx: WorkflowContext[str]) -> None:
# Use context state instead of instance variables
state = await ctx.get_state() or {}
state["processed"] = data
await ctx.set_state(state)
.. code-block:: python
# Good: Stateless executor using context state
class StatelessExecutor(Executor):
@handler
async def process(self, data: str, ctx: WorkflowContext[str]) -> None:
# Use context state instead of instance variables
state = await ctx.get_state() or {}
state["processed"] = data
await ctx.set_state(state)
# Avoid: Stateful executor with instance variables
class StatefulExecutor(Executor):
def __init__(self):
super().__init__(id="stateful")
self.data = [] # This will be shared across concurrent executions!
```
# Avoid: Stateful executor with instance variables
class StatefulExecutor(Executor):
def __init__(self):
super().__init__(id="stateful")
self.data = [] # This will be shared across concurrent executions!
## Integration with Parent Workflows
Parent workflows can intercept sub-workflow requests:
@@ -41,6 +41,16 @@ if TYPE_CHECKING: # pragma: no cover
FinishReason,
)
__all__ = [
"OBSERVABILITY_SETTINGS",
"OtelAttr",
"get_meter",
"get_tracer",
"setup_observability",
"use_agent_observability",
"use_observability",
]
TAgent = TypeVar("TAgent", bound="AgentProtocol")
TChatClient = TypeVar("TChatClient", bound="ChatClientProtocol")
@@ -527,13 +537,42 @@ def get_tracer(
schema_url: str | None = None,
attributes: dict[str, Any] | None = None,
) -> "trace.Tracer":
"""Returns a `Tracer` for use by the given instrumentation library.
This function is a convenience wrapper for
trace.get_tracer()
replicating the behavior of opentelemetry.trace.TracerProvider.get_tracer.
"""Returns a Tracer for use by the given instrumentation library.
This function is a convenience wrapper for trace.get_tracer() replicating
the behavior of opentelemetry.trace.TracerProvider.get_tracer.
If tracer_provider is omitted the current configured one is used.
Args:
instrumenting_module_name: The name of the instrumenting library.
Default is "agent_framework".
instrumenting_library_version: The version of the instrumenting library.
Default is the current agent_framework version.
schema_url: Optional schema URL for the emitted telemetry.
attributes: Optional attributes associated with the emitted telemetry.
Returns:
A Tracer instance for creating spans.
Examples:
.. code-block:: python
from agent_framework import get_tracer
# Get default tracer
tracer = get_tracer()
# Use tracer to create spans
with tracer.start_as_current_span("my_operation") as span:
span.set_attribute("custom.attribute", "value")
# Your operation here
pass
# Get tracer with custom module name
custom_tracer = get_tracer(
instrumenting_module_name="my_custom_module",
instrumenting_library_version="1.0.0",
)
"""
return trace.get_tracer(
instrumenting_module_name=instrumenting_module_name,
@@ -549,21 +588,44 @@ def get_meter(
schema_url: str | None = None,
attributes: dict[str, Any] | None = None,
) -> "metrics.Meter":
"""Returns a `Meter` for Agent Framework.
"""Returns a Meter for Agent Framework.
This is a convenience wrapper for
metrics.get_meter() replicating the behavior of
opentelemetry.metrics.get_meter().
This is a convenience wrapper for metrics.get_meter() replicating the behavior
of opentelemetry.metrics.get_meter().
Args:
name: Optional name, default is "agent_framework". The name of the
instrumenting library.
name: The name of the instrumenting library. Default is "agent_framework".
version: The version of agent_framework. Default is the current version
of the package.
schema_url: Optional schema URL of the emitted telemetry.
attributes: Optional attributes associated with the emitted telemetry.
version: Optional. The version of `agent_framework`, default is the
current version of the package.
Returns:
A Meter instance for recording metrics.
schema_url: Optional. Specifies the Schema URL of the emitted telemetry.
attributes: Optional. Attributes that are associated with the emitted telemetry.
Examples:
.. code-block:: python
from agent_framework import get_meter
# Get default meter
meter = get_meter()
# Create a counter metric
request_counter = meter.create_counter(
name="requests",
description="Number of requests",
unit="1",
)
request_counter.add(1, {"endpoint": "/api/chat"})
# Create a histogram metric
duration_histogram = meter.create_histogram(
name="request_duration",
description="Request duration in seconds",
unit="s",
)
duration_histogram.record(0.125, {"status": "success"})
"""
try:
return metrics.get_meter(name=name, version=version, schema_url=schema_url, attributes=attributes)
@@ -584,98 +646,75 @@ def setup_observability(
exporters: list["LogExporter | SpanExporter | MetricExporter"] | None = None,
vs_code_extension_port: int | None = None,
) -> None:
"""Convenient method to setup observability for the application.
"""Setup observability for the application with OpenTelemetry.
This method will create the exporters and the providers for the application,
based on the provided values and the environment variables.
This method creates the exporters and providers for the application based on
the provided values and environment variables.
Call this method once during application startup, before any telemetry is captured.
DO NOT call this method multiple times, as it may lead to unexpected behavior.
If you have configured the providers manually, calling this method will not have any effect:
```python
# Some where in your application startup code
trace.set_tracer_provider(TracerProvider(...))
# After the above call, calling setup_observability will not have any effect
setup_observability()
```
The reverse is also true:
```python
# Some where in your application startup code
setup_observability()
# After the above call, calling trace.set_tracer_provider will not have any effect
trace.set_tracer_provider(TracerProvider(...))
```
The OTel endpoint and the Application Insights connection string can be set through
environment variables or you can pass additional ones here. In the case where both
are present, non-duplicate values will be added:
## With environment variables
This method will read the settings from the environment:
```python
setup_observability()
```
## Without environment variables and use parameters
It is also possible to pass the settings directly:
```python
setup_observability(
enable_sensitive_data=True,
otlp_endpoint=["http://localhost:7431"],
applicationinsights_connection_string=["..."],
exporters=[...], # your custom exporters
vs_code_extension_port=4317,
)
```
## Mixed
When both environment variables and parameters are used, the following settings will get overridden:
- enable_sensitive_data
- vs_code_extension_port
The endpoints and connection strings will be combined, excluding duplicates.
```env
OTEL_ENDPOINT="http://localhost:7431"
```
```python
setup_observability(
enable_sensitive_data=True,
otlp_endpoint=["http://localhost:4317"],
)
```
Exporters will be created for both endpoints.
Note:
If you have configured the providers manually, calling this method will not
have any effect. The reverse is also true - if you call this method first,
subsequent provider configurations will not take effect.
Args:
enable_sensitive_data: Enable OpenTelemetry sensitive events.
If set, this will override the value set through the environment variable.
enable_sensitive_data: Enable OpenTelemetry sensitive events. Overrides
the environment variable if set. Default is None.
otlp_endpoint: The OpenTelemetry Protocol (OTLP) endpoint. Will be used
to create OTLPLogExporter, OTLPMetricExporter and OTLPSpanExporter.
Default is None.
otlp_endpoint: The OpenTelemetry Protocol (OTLP) endpoint. Default is None.
Will be used to create a `OTLPLogExporter`, `OTLPMetricExporter` and `OTLPSpanExporter`
applicationinsights_connection_string: The Azure Monitor connection string. Default is None.
Will be used to create AzureMonitorExporters.
applicationinsights_connection_string: The Azure Monitor connection string.
Will be used to create AzureMonitorExporters. Default is None.
credential: The credential to use for Azure Monitor Entra ID authentication.
Default is None.
exporters: A list of exporters, for logs, metrics or spans, or any combination.
These will be added directly, and allows you to customize the spans completely.
vs_code_extension_port: The port the AI Toolkit or AzureAI Foundry VS Code extensions are
listening on. When this is set, additional OTEL exporters will be created with endpoint
`http://localhost:{vs_code_extension_port}` unless this endpoint is already configured.
This will override the value set through the environment variable.
Default is None.
exporters: A list of exporters for logs, metrics or spans, or any combination.
These will be added directly, allowing complete customization. Default is None.
vs_code_extension_port: The port the AI Toolkit or AzureAI Foundry VS Code
extensions are listening on. When set, additional OTEL exporters will be
created with endpoint `http://localhost:{vs_code_extension_port}` unless
already configured. Overrides the environment variable if set. Default is None.
Examples:
.. code-block:: python
from agent_framework import setup_observability
# With environment variables
# Set ENABLE_OTEL=true, OTLP_ENDPOINT=http://localhost:4317
setup_observability()
# With parameters (no environment variables)
setup_observability(
enable_sensitive_data=True,
otlp_endpoint="http://localhost:4317",
)
# With Azure Monitor
setup_observability(
applicationinsights_connection_string="InstrumentationKey=...",
)
# With custom exporters
from opentelemetry.sdk.trace.export import ConsoleSpanExporter
setup_observability(
exporters=[ConsoleSpanExporter()],
)
# Mixed: combine environment variables and parameters
# Environment: OTLP_ENDPOINT=http://localhost:7431
# Code adds additional endpoint
setup_observability(
enable_sensitive_data=True,
otlp_endpoint="http://localhost:4317", # Both endpoints will be used
)
# VS Code extension integration
setup_observability(
vs_code_extension_port=4317, # Connects to AI Toolkit
)
"""
global OBSERVABILITY_SETTINGS
# Update the observability settings with the provided values
@@ -928,12 +967,53 @@ def _trace_get_streaming_response(
def use_observability(
chat_client: type[TChatClient],
) -> type[TChatClient]:
"""Class decorator that enables telemetry for a chat client.
"""Class decorator that enables OpenTelemetry observability for a chat client.
This needs to be applied on the class itself, not a instance of it.
This decorator automatically traces chat completion requests, captures metrics,
and logs events for the decorated chat client class.
To set the proper provider name, the chat client class should have a class variable
OTEL_PROVIDER_NAME.
Note:
This decorator must be applied to the class itself, not an instance.
The chat client class should have a class variable OTEL_PROVIDER_NAME to
set the proper provider name for telemetry.
Args:
chat_client: The chat client class to enable observability for.
Returns:
The decorated chat client class with observability enabled.
Raises:
ChatClientInitializationError: If the chat client does not have required
methods (get_response, get_streaming_response).
Examples:
.. code-block:: python
from agent_framework import use_observability, setup_observability
from agent_framework._clients import ChatClientProtocol
# Decorate a custom chat client class
@use_observability
class MyCustomChatClient:
OTEL_PROVIDER_NAME = "my_provider"
async def get_response(self, messages, **kwargs):
# Your implementation
pass
async def get_streaming_response(self, messages, **kwargs):
# Your implementation
pass
# Setup observability
setup_observability(otlp_endpoint="http://localhost:4317")
# Now all calls will be traced
client = MyCustomChatClient()
response = await client.get_response("Hello")
"""
if getattr(chat_client, OPEN_TELEMETRY_CHAT_CLIENT_MARKER, False):
# Already decorated
@@ -1107,7 +1187,54 @@ def _trace_agent_run_stream(
def use_agent_observability(
agent: type[TAgent],
) -> type[TAgent]:
"""Class decorator that enables telemetry for an agent."""
"""Class decorator that enables OpenTelemetry observability for an agent.
This decorator automatically traces agent run requests, captures events,
and logs interactions for the decorated agent class.
Note:
This decorator must be applied to the agent class itself, not an instance.
The agent class should have a class variable AGENT_SYSTEM_NAME to set the
proper system name for telemetry.
Args:
agent: The agent class to enable observability for.
Returns:
The decorated agent class with observability enabled.
Raises:
AgentInitializationError: If the agent does not have required methods
(run, run_stream).
Examples:
.. code-block:: python
from agent_framework import use_agent_observability, setup_observability
from agent_framework._agents import AgentProtocol
# Decorate a custom agent class
@use_agent_observability
class MyCustomAgent:
AGENT_SYSTEM_NAME = "my_agent_system"
async def run(self, messages=None, *, thread=None, **kwargs):
# Your implementation
pass
async def run_stream(self, messages=None, *, thread=None, **kwargs):
# Your implementation
pass
# Setup observability
setup_observability(otlp_endpoint="http://localhost:4317")
# Now all agent runs will be traced
agent = MyCustomAgent()
response = await agent.run("Perform a task")
"""
provider_name = str(getattr(agent, "AGENT_SYSTEM_NAME", "Unknown"))
try:
agent.run = _trace_agent_run(agent.run, provider_name) # type: ignore
+9
View File
@@ -41,6 +41,15 @@ dependencies = [
viz = [
"graphviz>=0.20.0"
]
all = [
"agent-framework-a2a",
"agent-framework-azure-ai",
"agent-framework-copilotstudio",
"agent-framework-mem0",
"agent-framework-redis",
"agent-framework-devui",
"graphviz>=0.20.0"
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
@@ -168,10 +168,10 @@ class RedisChatMessageStore:
- LTRIM operation is atomic for consistent message limits
Example:
```python
messages = [ChatMessage(role=Role.USER, text="Hello"), ChatMessage(role=Role.ASSISTANT, text="Hi there!")]
await store.add_messages(messages)
```
.. code-block:: python
messages = [ChatMessage(role="user", text="Hello"), ChatMessage(role="assistant", text="Hi there!")]
await store.add_messages(messages)
"""
if not messages:
return
@@ -200,10 +200,10 @@ class RedisChatMessageStore:
Returns empty list if no messages exist or if Redis connection fails.
Example:
```python
# Get all conversation history
messages = await store.list_messages()
```
.. code-block:: python
# Get all conversation history
messages = await store.list_messages()
"""
# Ensure any initial messages are persisted to Redis first
await self._ensure_initial_messages_added()
@@ -320,14 +320,14 @@ class RedisChatMessageStore:
- Consider exporting messages before clearing if backup is needed
Example:
```python
# Clear conversation history
await store.clear()
.. code-block:: python
# Verify messages are gone
messages = await store.list_messages()
assert len(messages) == 0
```
# Clear conversation history
await store.clear()
# Verify messages are gone
messages = await store.list_messages()
assert len(messages) == 0
"""
await self._redis_client.delete(self.redis_key)
+3 -1
View File
@@ -3,6 +3,8 @@ fmt = "ruff format"
format.ref = "fmt"
lint = "ruff check"
pyright = "pyright"
build = "python -m flit build"
publish = "uv publish"
clean-dist = "rm -rf dist"
build-package = "python -m flit build"
move-dist = "sh -c 'mkdir -p ../../dist && mv dist/* ../../dist/ 2>/dev/null || true'"
build = ["build-package", "move-dist"]
+27 -11
View File
@@ -206,16 +206,32 @@ dependencies = [
]
[package.optional-dependencies]
all = [
{ name = "agent-framework-a2a", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "agent-framework-azure-ai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "agent-framework-copilotstudio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "agent-framework-devui", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "agent-framework-mem0", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "agent-framework-redis", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "graphviz", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
viz = [
{ name = "graphviz", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
[package.metadata]
requires-dist = [
{ name = "agent-framework-a2a", marker = "extra == 'all'", editable = "packages/a2a" },
{ name = "agent-framework-azure-ai", marker = "extra == 'all'", editable = "packages/azure-ai" },
{ name = "agent-framework-copilotstudio", marker = "extra == 'all'", editable = "packages/copilotstudio" },
{ name = "agent-framework-devui", marker = "extra == 'all'", editable = "packages/devui" },
{ name = "agent-framework-mem0", marker = "extra == 'all'", editable = "packages/mem0" },
{ name = "agent-framework-redis", marker = "extra == 'all'", editable = "packages/redis" },
{ name = "aiofiles", specifier = ">=24.1.0" },
{ name = "azure-identity", specifier = ">=1,<2" },
{ name = "azure-monitor-opentelemetry", specifier = ">=1.7.0" },
{ name = "azure-monitor-opentelemetry-exporter", specifier = ">=1.0.0b41" },
{ name = "graphviz", marker = "extra == 'all'", specifier = ">=0.20.0" },
{ name = "graphviz", marker = "extra == 'viz'", specifier = ">=0.20.0" },
{ name = "mcp", extras = ["ws"], specifier = ">=1.13" },
{ name = "openai", specifier = ">=1.99.0" },
@@ -227,7 +243,7 @@ requires-dist = [
{ name = "pydantic-settings", specifier = ">=2,<3" },
{ name = "typing-extensions" },
]
provides-extras = ["viz"]
provides-extras = ["viz", "all"]
[[package]]
name = "agent-framework-devui"
@@ -1405,7 +1421,7 @@ name = "exceptiongroup"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" },
{ name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
wheels = [
@@ -2354,7 +2370,7 @@ wheels = [
[[package]]
name = "langfuse"
version = "3.6.0"
version = "3.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -2367,9 +2383,9 @@ dependencies = [
{ name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/77/36/c445ffa3a1ac856e9c54b2d9ee7969c5bfbea549c5389339f23b67d45653/langfuse-3.6.0.tar.gz", hash = "sha256:7b11f8510949f7b00afa6846e503ded80ddb5c6e2910e6fbcd773716bdcbec23", size = 189466, upload-time = "2025-10-01T12:01:18.475Z" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/ea/b1abad97af5e4dba0ea3135387efa139f11ac34e57da5a8b2ea14354bd95/langfuse-3.6.1.tar.gz", hash = "sha256:eac27ee5bbd8d05e7d665e822e0efb36766b20fe281930ff040f47eb22cc1b69", size = 189456, upload-time = "2025-10-02T08:33:17.363Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/10/fd8daf0a1847fe25c6804936ded4ab722cfbb98b5ce215de67ad8f515e9e/langfuse-3.6.0-py3-none-any.whl", hash = "sha256:672ac0db51780d10d98b32bd6c922fad5d5f6eea1344725537bbbfad10908b92", size = 350744, upload-time = "2025-10-01T12:01:16.402Z" },
{ url = "https://files.pythonhosted.org/packages/af/49/4eae7cd4a1005c77808b3d8e3174412c4e198c8fb776b8847b0223a5f504/langfuse-3.6.1-py3-none-any.whl", hash = "sha256:134e0007fcfdd9fb70b491c882bb431c8095b3f5cc5e865756f46a2abd3675a2", size = 350756, upload-time = "2025-10-02T08:33:15.607Z" },
]
[[package]]
@@ -4157,15 +4173,15 @@ wheels = [
[[package]]
name = "pyright"
version = "1.1.405"
version = "1.1.406"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodeenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fb/6c/ba4bbee22e76af700ea593a1d8701e3225080956753bee9750dcc25e2649/pyright-1.1.405.tar.gz", hash = "sha256:5c2a30e1037af27eb463a1cc0b9f6d65fec48478ccf092c1ac28385a15c55763", size = 4068319, upload-time = "2025-09-04T03:37:06.776Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f7/16/6b4fbdd1fef59a0292cbb99f790b44983e390321eccbc5921b4d161da5d1/pyright-1.1.406.tar.gz", hash = "sha256:c4872bc58c9643dac09e8a2e74d472c62036910b3bd37a32813989ef7576ea2c", size = 4113151, upload-time = "2025-10-02T01:04:45.488Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/1a/524f832e1ff1962a22a1accc775ca7b143ba2e9f5924bb6749dce566784a/pyright-1.1.405-py3-none-any.whl", hash = "sha256:a2cb13700b5508ce8e5d4546034cb7ea4aedb60215c6c33f56cec7f53996035a", size = 5905038, upload-time = "2025-09-04T03:37:04.913Z" },
{ url = "https://files.pythonhosted.org/packages/f6/a2/e309afbb459f50507103793aaef85ca4348b66814c86bc73908bdeb66d12/pyright-1.1.406-py3-none-any.whl", hash = "sha256:1d81fb43c2407bf566e97e57abb01c811973fdb21b2df8df59f870f688bdca71", size = 5980982, upload-time = "2025-10-02T01:04:43.137Z" },
]
[[package]]
@@ -4436,7 +4452,7 @@ wheels = [
[[package]]
name = "redisvl"
version = "0.8.2"
version = "0.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpath-ng", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -4449,9 +4465,9 @@ dependencies = [
{ name = "redis", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "tenacity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d6/26/f3a5128d96eeeb5af0fc345156e48971ce0ce99689b62ba01dc855744c61/redisvl-0.8.2.tar.gz", hash = "sha256:3938ddcd093507c4c427cb431ac9faaa8bb999bb2ca116cbd57e4b7334fe18eb", size = 573106, upload-time = "2025-08-26T15:23:40.356Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/86/50d8cbd7df4849a602a27ba475a3e426f26ec14d26bdce14ed77e120b66d/redisvl-0.9.1.tar.gz", hash = "sha256:a735ecf3238e804800b54a513b85a8cf4300fe6d111fb055bd75528f77dd5419", size = 606980, upload-time = "2025-10-01T23:25:51.293Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/16/a9eb70249c518b9b6a19efb32089bda8ecc146bafee360abd375eae7053e/redisvl-0.8.2-py3-none-any.whl", hash = "sha256:67b413387d72849d571723c95fa1183539d6fa60d6ac533513ee8e3e31874600", size = 152593, upload-time = "2025-08-26T15:23:38.393Z" },
{ url = "https://files.pythonhosted.org/packages/da/ee/3e8d5d8a734dd0df731567147b19f0326544f633a5efbab7de0e3625da62/redisvl-0.9.1-py3-none-any.whl", hash = "sha256:aaec441cfcb37ce7cced028dcf9a748337a27422dcaf1b494a4c6198f577dcf4", size = 160335, upload-time = "2025-10-01T23:25:49.812Z" },
]
[[package]]