Compare commits

...
Author SHA1 Message Date
65e77e52af update package versions (#3902)
Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
2026-02-13 00:00:57 +00:00
e064f943ae Python: Remove duplicate samples (#3899)
* Remove duplicate samples

* Correct paths

* Update readme

* Update readme

* Fix ruff

---------

Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
2026-02-12 23:46:41 +00:00
Tao ChenandGitHub 1441fd903c Python: Fix non-ascii chars in span attributes (#3894)
* Fix non-ascii chars in span attributes

* Comments
2026-02-12 22:53:32 +00:00
Evan MattsonandGitHub a276c1295a Python: Fix streamed workflow agent continuation context by finalizing AgentExecutor streams (#3882)
* Fix streamed workflow agent continuation context by finalizing AgentExecutor streams

* Fix stream handling

* Fixes

* Fix DevUI and tests
2026-02-12 22:45:46 +00:00
Evan MattsonandGitHub 2203fa0f8b Python: (ag-ui): fix Workflow.as_agent() streaming regression (#3875)
* fix Workflow.as_agent() streaming regression in ag-ui

* Address PR feedback

* PR feedback
2026-02-12 22:43:44 +00:00
Eduard van ValkenburgandGitHub 1e350ea22f Python: [BREAKING] PR2 — Wire context provider pipeline, remove old types, update all consumers (#3850)
* PR2: Wire context provider pipeline and update all internal consumers

- Replace AgentThread with AgentSession across all packages
- Replace ContextProvider with BaseContextProvider across all packages
- Replace context_provider param with context_providers (Sequence)
- Replace thread= with session= in run() signatures
- Replace get_new_thread() with create_session()
- Add get_session(service_session_id) to agent interface
- DurableAgentThread -> DurableAgentSession
- Remove _notify_thread_of_new_messages from WorkflowAgent
- Wire before_run/after_run context provider pipeline in RawAgent
- Auto-inject InMemoryHistoryProvider when no providers configured

* fix: update all tests for context provider pipeline, fix lazy-loaders, remove old test files

* refactor: update all sample files for context provider pipeline (AgentThread→AgentSession, ContextProvider→BaseContextProvider)

* fix: update remaining ag-ui references (client docstring, getting_started sample)

* fix: make get_session service_session_id keyword-only to avoid confusion with session_id

* refactor: rename _RunContext.thread_messages to session_messages

* refactor: remove _threads.py, _memory.py, and old provider files; migrate devui to use plain message lists

* rename: remove _new_ prefix from test files

* refactor: rewrite SlidingWindowChatMessageStore as SlidingWindowHistoryProvider(InMemoryHistoryProvider)

* fix: read full history from session state directly instead of reaching into provider internals

* fix: update stale .pyi stubs, sample imports, and README references for new provider types

* fix: remove stale message_store, _notify_thread_of_new_messages, and session_id.key references in samples

* refactor: merge context_providers and sessions sample folders into sessions, remove aggregate_context_provider

* refactor: UserInfoMemory stores state in session.state instead of instance attributes

* feat: add Pydantic BaseModel support to session state serialization

Pydantic models stored in session.state are now automatically serialized
via model_dump() and restored via model_validate() during to_dict()/from_dict()
round-trips. Models are auto-registered on first serialization; use
register_state_type() for cold-start deserialization.

Also export register_state_type as a public API.

* fix mem0

* Update sample README links and descriptions for session terminology

- Replace 'thread' with 'session' in sample descriptions across all READMEs
- Update file links for renamed samples (mem0_sessions, redis_sessions, etc.)
- Fix Threads section → Sessions section in main samples/README.md
- Update tools, middleware, workflows, durabletask, azure_functions READMEs
- Update architecture diagrams in concepts/tools/README.md
- Update migration guides (autogen, semantic-kernel)

* Fix broken Redis README link to renamed sample

* Fix Mem0 OSS client search: pass scoping params as direct kwargs

AsyncMemory (OSS) expects user_id/agent_id/run_id as direct kwargs,
while AsyncMemoryClient (Platform) expects them in a filters dict.
Adds tests for both client types.

Port of fix from #3844 to new Mem0ContextProvider.

* Fix rebase issues: restore missing _conversation_state.py and checkpoint decode logic

- Add back _conversation_state.py (encode/decode_chat_messages) lost in rebase
- Fix on_checkpoint_restore to decode cache/conversation with decode_chat_messages
- Fix on_checkpoint_restore to use decode_checkpoint_value for pending requests
- Add tests/workflow/__init__.py for relative import support
- Fix test_agent_executor checkpoint selection (checkpoints[1] not superstep)

* Add STORES_BY_DEFAULT ClassVar to skip redundant InMemoryHistoryProvider injection

Chat clients that store history server-side by default (OpenAI Responses API,
Azure AI Agent) now declare STORES_BY_DEFAULT = True. The agent checks this
during auto-injection and skips InMemoryHistoryProvider unless the user
explicitly sets store=False.

* Fix broken markdown links in azure_ai and redis READMEs

* Fix getting-started samples to use session API instead of removed thread/ContextProvider API

* updates to workflow as agent

* fix group chat import

* Rename Thread→Session throughout, fix service_session_id propagation, remove stale AGUIThread

- Fix: Propagate conversation_id from ChatResponse back to session.service_session_id
  in both streaming and non-streaming paths in _agents.py
- Rename AgentThreadException → AgentSessionException
- Remove stale AGUIThread from ag_ui lazy-loader
- Rename use_service_thread → use_service_session in ag-ui package
- Rename test functions from *_thread_* to *_session_*
- Rename sample files from *_thread* to *_session*
- Update docstrings and comments: thread → session
- Update _mcp.py kwargs filter: add 'session' alongside 'thread'
- Fix ContinuationToken docstring example: thread=thread → session=session
- Fix _clients.py docstring: 'Agent threads' → 'Agent sessions'

* Fix broken markdown links after thread→session file renames

* fix azure ai test
2026-02-12 21:00:32 +00:00
369 changed files with 7647 additions and 14589 deletions
+25 -1
View File
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.0.0b260212] - 2026-02-12
### Added
- **agent-framework-core**: Allow `AzureOpenAIResponsesClient` creation with Foundry project endpoint ([#3814](https://github.com/microsoft/agent-framework/pull/3814))
### Changed
- **agent-framework-core**: [BREAKING] Wire context provider pipeline, remove old types, update all consumers ([#3850](https://github.com/microsoft/agent-framework/pull/3850))
- **agent-framework-core**: [BREAKING] Checkpoint refactor: encode/decode, checkpoint format, etc ([#3744](https://github.com/microsoft/agent-framework/pull/3744))
- **agent-framework-core**: [BREAKING] Replace `Hosted*Tool` classes with tool methods ([#3634](https://github.com/microsoft/agent-framework/pull/3634))
- **agent-framework-core**: Replace Pydantic Settings with `TypedDict` + `load_settings()` ([#3843](https://github.com/microsoft/agent-framework/pull/3843))
- **agent-framework-core**: Centralize tool result parsing in `FunctionTool.invoke()` ([#3854](https://github.com/microsoft/agent-framework/pull/3854))
- **samples**: Restructure Python samples into progressive 01-05 layout ([#3862](https://github.com/microsoft/agent-framework/pull/3862))
- **samples**: Adopt `AzureOpenAIResponsesClient`, reorganize orchestration examples, and fix workflow/orchestration bugs ([#3873](https://github.com/microsoft/agent-framework/pull/3873))
### Fixed
- **agent-framework-core**: Fix non-ascii chars in span attributes ([#3894](https://github.com/microsoft/agent-framework/pull/3894))
- **agent-framework-core**: Fix streamed workflow agent continuation context by finalizing `AgentExecutor` streams ([#3882](https://github.com/microsoft/agent-framework/pull/3882))
- **agent-framework-ag-ui**: Fix `Workflow.as_agent()` streaming regression ([#3875](https://github.com/microsoft/agent-framework/pull/3875))
- **agent-framework-declarative**: Fix declarative package powerfx import crash and `response_format` kwarg error ([#3841](https://github.com/microsoft/agent-framework/pull/3841))
## [1.0.0b260210] - 2026-02-10
### Added
@@ -622,7 +645,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260210...HEAD
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260212...HEAD
[1.0.0b260212]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260210...python-1.0.0b260212
[1.0.0b260210]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260130...python-1.0.0b260210
[1.0.0b260130]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260128...python-1.0.0b260130
[1.0.0b260128]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260127...python-1.0.0b260128
+1 -1
View File
@@ -233,7 +233,7 @@ if __name__ == "__main__":
asyncio.run(main())
```
For more advanced orchestration patterns including Sequential, Concurrent, Group Chat, Handoff, and Magentic orchestrations, see the [orchestration samples](samples/02-agents/orchestrations).
For more advanced orchestration patterns including Sequential, Concurrent, Group Chat, Handoff, and Magentic orchestrations, see the [orchestration samples](samples/03-workflows/orchestrations).
## More Examples & Samples
@@ -31,7 +31,7 @@ from a2a.types import Role as A2ARole
from agent_framework import (
AgentResponse,
AgentResponseUpdate,
AgentThread,
AgentSession,
BaseAgent,
Content,
ContinuationToken,
@@ -211,7 +211,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: Literal[False] = ...,
thread: AgentThread | None = None,
session: AgentSession | None = None,
continuation_token: A2AContinuationToken | None = None,
background: bool = False,
**kwargs: Any,
@@ -223,7 +223,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: Literal[True],
thread: AgentThread | None = None,
session: AgentSession | None = None,
continuation_token: A2AContinuationToken | None = None,
background: bool = False,
**kwargs: Any,
@@ -234,7 +234,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
continuation_token: A2AContinuationToken | None = None,
background: bool = False,
**kwargs: Any,
@@ -246,7 +246,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
Keyword Args:
stream: Whether to stream the response. Defaults to False.
thread: The conversation thread associated with the message(s).
session: The conversation session associated with the message(s).
continuation_token: Optional token to resume a long-running task
instead of starting a new one.
background: When True, in-progress task updates surface continuation
+2 -2
View File
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260210"
version = "1.0.0b260212"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0b260210",
"agent-framework-core>=1.0.0b260212",
"a2a-sdk>=0.3.5",
]
@@ -18,7 +18,7 @@ class AgentConfig:
self,
state_schema: Any | None = None,
predict_state_config: dict[str, dict[str, str]] | None = None,
use_service_thread: bool = False,
use_service_session: bool = False,
require_confirmation: bool = True,
):
"""Initialize agent configuration.
@@ -26,12 +26,12 @@ class AgentConfig:
Args:
state_schema: Optional state schema for state management; accepts dict or Pydantic model/class
predict_state_config: Configuration for predictive state updates
use_service_thread: Whether the agent thread is service-managed
use_service_session: Whether the agent session is service-managed
require_confirmation: Whether predictive updates require user confirmation before applying
"""
self.state_schema = self._normalize_state_schema(state_schema)
self.predict_state_config = predict_state_config or {}
self.use_service_thread = use_service_thread
self.use_service_session = use_service_session
self.require_confirmation = require_confirmation
@staticmethod
@@ -77,7 +77,7 @@ class AgentFrameworkAgent:
state_schema: Any | None = None,
predict_state_config: dict[str, dict[str, str]] | None = None,
require_confirmation: bool = True,
use_service_thread: bool = False,
use_service_session: bool = False,
):
"""Initialize the AG-UI compatible agent wrapper.
@@ -88,7 +88,7 @@ class AgentFrameworkAgent:
state_schema: Optional state schema for state management; accepts dict or Pydantic model/class
predict_state_config: Configuration for predictive state updates
require_confirmation: Whether predictive updates require user confirmation before applying
use_service_thread: Whether the agent thread is service-managed
use_service_session: Whether the agent session is service-managed
"""
self.agent = agent
self.name = name or getattr(agent, "name", "agent")
@@ -97,7 +97,7 @@ class AgentFrameworkAgent:
self.config = AgentConfig(
state_schema=state_schema,
predict_state_config=predict_state_config,
use_service_thread=use_service_thread,
use_service_session=use_service_session,
require_confirmation=require_confirmation,
)
@@ -171,11 +171,11 @@ class AGUIChatClient(
client = AGUIChatClient(endpoint="http://localhost:8888/")
agent = Agent(name="assistant", client=client)
thread = await agent.get_new_thread()
session = agent.create_session()
# Agent automatically maintains history and sends full context
response = await agent.run("Hello!", thread=thread)
response2 = await agent.run("How are you?", thread=thread)
response = await agent.run("Hello!", session=session)
response2 = await agent.run("How are you?", session=session)
Streaming usage:
@@ -7,7 +7,7 @@ from __future__ import annotations
import json
import logging
import uuid
from collections.abc import Awaitable
from collections.abc import AsyncIterable, Awaitable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, cast
@@ -27,7 +27,7 @@ from ag_ui.core import (
ToolCallStartEvent,
)
from agent_framework import (
AgentThread,
AgentSession,
Content,
Message,
SupportsAgentRun,
@@ -172,12 +172,12 @@ class FlowState:
tool_call_id: str | None = None # Current tool call being streamed
tool_call_name: str | None = None # Name of current tool call
waiting_for_approval: bool = False # Stop after approval request
current_state: dict[str, Any] = field(default_factory=dict) # Shared state
current_state: dict[str, Any] = field(default_factory=dict) # pyright: ignore[reportUnknownVariableType]
accumulated_text: str = "" # For MessagesSnapshotEvent
pending_tool_calls: list[dict[str, Any]] = field(default_factory=list) # For MessagesSnapshotEvent
tool_calls_by_id: dict[str, dict[str, Any]] = field(default_factory=dict)
tool_results: list[dict[str, Any]] = field(default_factory=list)
tool_calls_ended: set[str] = field(default_factory=set) # Track which tool calls have been ended
pending_tool_calls: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
tool_calls_by_id: dict[str, dict[str, Any]] = field(default_factory=dict) # pyright: ignore[reportUnknownVariableType]
tool_results: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
tool_calls_ended: set[str] = field(default_factory=set) # pyright: ignore[reportUnknownVariableType]
def get_tool_name(self, call_id: str | None) -> str | None:
"""Get tool name by call ID."""
@@ -191,6 +191,40 @@ class FlowState:
return [tc for tc in self.pending_tool_calls if tc.get("id") not in self.tool_calls_ended]
async def _normalize_response_stream(response_stream: Any) -> AsyncIterable[Any]:
"""Normalize agent streaming return types to an async iterable.
Supports:
- ResponseStream (standard agent stream type)
- AsyncIterable[AgentResponseUpdate] (workflow-style stream)
- Awaitable that resolves to either of the above
"""
if isinstance(response_stream, Awaitable):
resolved_stream = await cast(Awaitable[Any], response_stream)
if isinstance(resolved_stream, ResponseStream):
# AG-UI consumes update iteration only; ResponseStream finalizers are not used here.
return cast(AsyncIterable[Any], resolved_stream)
if isinstance(resolved_stream, AsyncIterable):
return cast(AsyncIterable[Any], resolved_stream)
resolved_type = f"{type(resolved_stream).__module__}.{type(resolved_stream).__name__}"
raise AgentExecutionException(
"Agent did not return a streaming AsyncIterable response. "
f"Awaitable resolved to unsupported type: {resolved_type}."
)
if isinstance(response_stream, ResponseStream):
# AG-UI consumes update iteration only; ResponseStream finalizers are not used here.
return cast(AsyncIterable[Any], response_stream)
if isinstance(response_stream, AsyncIterable):
return cast(AsyncIterable[Any], response_stream)
stream_type = f"{type(response_stream).__module__}.{type(response_stream).__name__}"
raise AgentExecutionException(
f"Agent did not return a streaming AsyncIterable response. Received unsupported type: {stream_type}."
)
def _create_state_context_message(
current_state: dict[str, Any],
state_schema: dict[str, Any],
@@ -460,7 +494,7 @@ def _emit_approval_request(
parent_message_id=flow.message_id,
)
)
args = {
args: dict[str, Any] = {
"function_name": func_name,
"function_call_id": func_call_id,
"function_arguments": make_json_safe(func_call.parse_arguments()) or {},
@@ -515,7 +549,8 @@ def _is_confirm_changes_response(messages: list[Any]) -> bool:
if not messages:
return False
last = messages[-1]
if not last.additional_properties.get("is_tool_result", False):
additional_properties = cast(dict[str, Any], getattr(last, "additional_properties", {}) or {})
if not additional_properties.get("is_tool_result", False):
return False
# Parse the content to check if it has the confirm_changes structure
@@ -523,6 +558,8 @@ def _is_confirm_changes_response(messages: list[Any]) -> bool:
if getattr(content, "type", None) == "text" and content.text:
try:
result = json.loads(content.text)
if not isinstance(result, dict):
continue
# confirm_changes results have 'accepted' and 'steps' keys
if "accepted" in result and "steps" in result:
return True
@@ -548,13 +585,19 @@ def _handle_step_based_approval(messages: list[Any]) -> list[BaseEvent]:
message = "Acknowledged."
else:
try:
result = json.loads(approval_text)
accepted = result.get("accepted", False)
steps = result.get("steps", [])
parsed_result = json.loads(approval_text)
result: dict[str, Any] = cast(dict[str, Any], parsed_result) if isinstance(parsed_result, dict) else {}
accepted = bool(result.get("accepted", False))
steps_raw = result.get("steps", [])
steps: list[dict[str, Any]] = []
if isinstance(steps_raw, list):
for step_raw in cast(list[Any], steps_raw):
if isinstance(step_raw, dict):
steps.append(cast(dict[str, Any], step_raw))
if accepted:
# Generate acceptance message with step descriptions
enabled_steps = [s for s in steps if s.get("status") == "enabled"]
enabled_steps: list[dict[str, Any]] = [step for step in steps if step.get("status") == "enabled"]
if enabled_steps:
message_parts = [f"Executing {len(enabled_steps)} approved steps:\n\n"]
for i, step in enumerate(enabled_steps, 1):
@@ -678,8 +721,9 @@ def _convert_approval_results_to_tool_messages(messages: list[Any]) -> None:
result.append(msg)
continue
function_results = [c for c in (msg.contents or []) if getattr(c, "type", None) == "function_result"]
other_contents = [c for c in (msg.contents or []) if getattr(c, "type", None) != "function_result"]
msg_contents = cast(list[Content], getattr(msg, "contents", None) or [])
function_results: list[Content] = [content for content in msg_contents if content.type == "function_result"]
other_contents: list[Content] = [content for content in msg_contents if content.type != "function_result"]
if not function_results:
result.append(msg)
@@ -695,7 +739,7 @@ def _convert_approval_results_to_tool_messages(messages: list[Any]) -> None:
# Then user message with remaining content (if any)
if other_contents:
result.append(Message(role=msg.role, contents=other_contents))
result.append(Message(role="user", contents=other_contents))
messages[:] = result
@@ -765,21 +809,24 @@ async def run_agent_stream(
if input_data.get("state"):
flow.current_state = dict(input_data["state"])
state_schema = cast(dict[str, Any], getattr(config, "state_schema", {}) or {})
predict_state_config = cast(dict[str, dict[str, str]], getattr(config, "predict_state_config", {}) or {})
# Apply schema defaults for missing state keys
if config.state_schema:
for key, schema in config.state_schema.items():
if state_schema:
for key, schema in state_schema.items():
if key in flow.current_state:
continue
if isinstance(schema, dict) and schema.get("type") == "array":
if isinstance(schema, dict) and cast(dict[str, Any], schema).get("type") == "array":
flow.current_state[key] = []
else:
flow.current_state[key] = {}
# Initialize predictive state handler if configured
predictive_handler: PredictiveStateHandler | None = None
if config.predict_state_config:
if predict_state_config:
predictive_handler = PredictiveStateHandler(
predict_state_config=config.predict_state_config,
predict_state_config=predict_state_config,
current_state=flow.current_state,
)
@@ -789,11 +836,11 @@ async def run_agent_stream(
# Check for structured output mode (skip text content)
skip_text = False
response_format = None
from agent_framework import Agent
if isinstance(agent, Agent):
response_format = agent.default_options.get("response_format")
response_format: type[Any] | None = None
default_options = getattr(agent, "default_options", None)
if isinstance(default_options, dict):
typed_default_options = cast(dict[str, Any], default_options)
response_format = cast(type[Any] | None, typed_default_options.get("response_format"))
skip_text = response_format is not None
# Handle empty messages (emit RunStarted immediately since no agent response)
@@ -809,12 +856,12 @@ async def run_agent_stream(
register_additional_client_tools(agent, client_tools)
tools = merge_tools(server_tools, client_tools)
# Create thread (with service thread support)
if config.use_service_thread:
# Create session (with service session support)
if config.use_service_session:
supplied_thread_id = input_data.get("thread_id") or input_data.get("threadId")
thread = AgentThread(service_thread_id=supplied_thread_id)
session = AgentSession(service_session_id=supplied_thread_id)
else:
thread = AgentThread()
session = AgentSession()
# Inject metadata for AG-UI orchestration (Feature #2: Azure-safe truncation)
base_metadata: dict[str, Any] = {
@@ -823,16 +870,17 @@ async def run_agent_stream(
}
if flow.current_state:
base_metadata["current_state"] = flow.current_state
thread.metadata = _build_safe_metadata(base_metadata) # type: ignore[attr-defined]
session.metadata = _build_safe_metadata(base_metadata) # type: ignore[attr-defined]
# Build run kwargs (Feature #6: Azure store flag when metadata present)
run_kwargs: dict[str, Any] = {"thread": thread}
run_kwargs: dict[str, Any] = {"session": session}
if tools:
run_kwargs["tools"] = tools
# Filter out AG-UI internal metadata keys before passing to chat client
# These are used internally for orchestration and should not be sent to the LLM provider
client_metadata = {
k: v for k, v in (getattr(thread, "metadata", None) or {}).items() if k not in AG_UI_INTERNAL_METADATA_KEYS
session_metadata = cast(dict[str, Any], getattr(session, "metadata", None) or {})
client_metadata: dict[str, Any] = {
k: v for k, v in session_metadata.items() if k not in AG_UI_INTERNAL_METADATA_KEYS
}
safe_metadata = _build_safe_metadata(client_metadata) if client_metadata else {}
if safe_metadata:
@@ -863,19 +911,14 @@ async def run_agent_stream(
# Inject state context message so the model knows current application state
# This is critical for shared state scenarios where the UI state needs to be visible
if config.state_schema and flow.current_state:
messages = _inject_state_context(messages, flow.current_state, config.state_schema)
if state_schema and flow.current_state:
messages = _inject_state_context(messages, flow.current_state, state_schema)
# Stream from agent - emit RunStarted after first update to get service IDs
run_started_emitted = False
all_updates: list[Any] = [] # Collect for structured output processing
response_stream = agent.run(messages, stream=True, **run_kwargs)
if isinstance(response_stream, ResponseStream):
stream = response_stream
else:
stream = await cast(Awaitable[ResponseStream[Any, Any]], response_stream)
if not isinstance(stream, ResponseStream):
raise AgentExecutionException("Chat client did not return a ResponseStream.")
stream = await _normalize_response_stream(response_stream)
async for update in stream:
# Collect updates for structured output processing
if response_format is not None:
@@ -891,18 +934,18 @@ async def run_agent_stream(
# NOW emit RunStarted with proper IDs
yield RunStartedEvent(run_id=run_id, thread_id=thread_id)
# Emit PredictState custom event if configured
if config.predict_state_config:
if predict_state_config:
predict_state_value = [
{
"state_key": state_key,
"tool": cfg["tool"],
"tool_argument": cfg["tool_argument"],
}
for state_key, cfg in config.predict_state_config.items()
for state_key, cfg in predict_state_config.items()
]
yield CustomEvent(name="PredictState", value=predict_state_value)
# Emit initial state snapshot only if we have both state_schema and state
if config.state_schema and flow.current_state:
if state_schema and flow.current_state:
yield StateSnapshotEvent(snapshot=flow.current_state)
run_started_emitted = True
@@ -933,17 +976,17 @@ async def run_agent_stream(
# If no updates at all, still emit RunStarted
if not run_started_emitted:
yield RunStartedEvent(run_id=run_id, thread_id=thread_id)
if config.predict_state_config:
if predict_state_config:
predict_state_value = [
{
"state_key": state_key,
"tool": cfg["tool"],
"tool_argument": cfg["tool_argument"],
}
for state_key, cfg in config.predict_state_config.items()
for state_key, cfg in predict_state_config.items()
]
yield CustomEvent(name="PredictState", value=predict_state_value)
if config.state_schema and flow.current_state:
if state_schema and flow.current_state:
yield StateSnapshotEvent(snapshot=flow.current_state)
# Process structured output if response_format is set
@@ -951,31 +994,33 @@ async def run_agent_stream(
from agent_framework import AgentResponse
from pydantic import BaseModel
logger.info(f"Processing structured output, update count: {len(all_updates)}")
final_response = AgentResponse.from_updates(all_updates, output_format_type=response_format)
if not (isinstance(response_format, type) and issubclass(response_format, BaseModel)):
logger.warning("Skipping structured output parsing: response_format is not a Pydantic model type.")
else:
logger.info(f"Processing structured output, update count: {len(all_updates)}")
final_response = AgentResponse.from_updates(all_updates, output_format_type=response_format)
if final_response.value and isinstance(final_response.value, BaseModel):
response_dict = final_response.value.model_dump(mode="json", exclude_none=True)
logger.info(f"Received structured output keys: {list(response_dict.keys())}")
if final_response.value and isinstance(final_response.value, BaseModel):
response_dict = final_response.value.model_dump(mode="json", exclude_none=True)
logger.info(f"Received structured output keys: {list(response_dict.keys())}")
# Extract state updates - if no state_schema, all non-message fields are state
state_keys = (
set(config.state_schema.keys()) if config.state_schema else set(response_dict.keys()) - {"message"}
)
state_updates = {k: v for k, v in response_dict.items() if k in state_keys}
# Extract state updates - if no state_schema, all non-message fields are state
state_keys = set(state_schema.keys()) if state_schema else set(response_dict.keys()) - {"message"}
state_updates = {k: v for k, v in response_dict.items() if k in state_keys}
if state_updates:
flow.current_state.update(state_updates)
yield StateSnapshotEvent(snapshot=flow.current_state)
logger.info(f"Emitted StateSnapshotEvent with updates: {list(state_updates.keys())}")
if state_updates:
flow.current_state.update(state_updates)
yield StateSnapshotEvent(snapshot=flow.current_state)
logger.info(f"Emitted StateSnapshotEvent with updates: {list(state_updates.keys())}")
# Emit message field as text if present
if "message" in response_dict and response_dict["message"]:
message_id = generate_event_id()
yield TextMessageStartEvent(message_id=message_id, role="assistant")
yield TextMessageContentEvent(message_id=message_id, delta=response_dict["message"])
yield TextMessageEndEvent(message_id=message_id)
logger.info(f"Emitted conversational message with length={len(response_dict['message'])}")
# Emit message field as text if present
message_text = response_dict.get("message")
if isinstance(message_text, str) and message_text:
message_id = generate_event_id()
yield TextMessageStartEvent(message_id=message_id, role="assistant")
yield TextMessageContentEvent(message_id=message_id, delta=message_text)
yield TextMessageEndEvent(message_id=message_id)
logger.info(f"Emitted conversational message with length={len(message_text)}")
# Feature #1: Emit ToolCallEndEvent for declaration-only tools (tools without results)
pending_without_end = flow.get_pending_without_end()
@@ -989,8 +1034,8 @@ async def run_agent_stream(
yield ToolCallEndEvent(tool_call_id=tool_call_id)
# For predictive tools with require_confirmation, emit confirm_changes
if config.require_confirmation and config.predict_state_config and tool_name:
is_predictive_tool = any(cfg["tool"] == tool_name for cfg in config.predict_state_config.values())
if config.require_confirmation and predict_state_config and tool_name:
is_predictive_tool = any(cfg["tool"] == tool_name for cfg in predict_state_config.values())
if is_predictive_tool:
logger.info(f"Emitting confirm_changes for predictive tool '{tool_name}'")
# Extract state value from tool arguments for StateSnapshot
@@ -1071,7 +1116,7 @@ async def run_agent_stream(
last_call_id = last_result.get("toolCallId")
last_tool_name = flow.get_tool_name(last_call_id)
if not _should_suppress_intermediate_snapshot(
last_tool_name, config.predict_state_config, config.require_confirmation
last_tool_name, predict_state_config, config.require_confirmation
):
yield _build_messages_snapshot(flow, snapshot_messages)
@@ -4,10 +4,10 @@
This demonstrates the HYBRID pattern matching .NET AGUIClient implementation:
1. AgentThread Pattern (like .NET):
- Create thread with agent.get_new_thread()
- Pass thread to agent.run(stream=True) on each turn
- Thread automatically maintains conversation history via message_store
1. AgentSession Pattern (like .NET):
- Create session with agent.create_session()
- Pass session to agent.run(stream=True) on each turn
- Session maintains conversation context via context providers
2. Hybrid Tool Execution:
- AGUIChatClient uses function invocation mixin
@@ -15,7 +15,7 @@ This demonstrates the HYBRID pattern matching .NET AGUIClient implementation:
- Server may also have its own tools that execute server-side
- Both work together: server LLM decides which tool to call, decorator handles client execution
This matches .NET pattern: thread maintains state, tools execute on appropriate side.
This matches .NET pattern: session maintains state, tools execute on appropriate side.
"""
from __future__ import annotations
@@ -59,13 +59,13 @@ async def main():
This matches the .NET pattern from Program.cs where:
- AIAgent agent = chatClient.CreateAIAgent(tools: [...])
- AgentThread thread = agent.GetNewThread()
- RunStreamingAsync(messages, thread)
- AgentSession session = agent.CreateSession()
- RunStreamingAsync(messages, session)
Python equivalent:
- agent = Agent(client=AGUIChatClient(...), tools=[...])
- thread = agent.get_new_thread() # Creates thread with message_store
- agent.run(message, stream=True, thread=thread) # Thread accumulates history
- session = agent.create_session() # Creates session
- agent.run(message, stream=True, session=session) # Session tracks context
"""
server_url = os.environ.get("AGUI_SERVER_URL", "http://127.0.0.1:5100/")
@@ -74,7 +74,7 @@ async def main():
print("=" * 70)
print(f"\nServer: {server_url}")
print("\nThis example demonstrates:")
print(" 1. AgentThread maintains conversation state (like .NET)")
print(" 1. AgentSession maintains conversation state (like .NET)")
print(" 2. Client-side tools execute locally via function invocation mixin")
print(" 3. Server may have additional tools that execute server-side")
print(" 4. HYBRID: Client and server tools work together simultaneously\n")
@@ -90,8 +90,8 @@ async def main():
tools=[get_weather],
)
# Create a thread to maintain conversation state (like .NET AgentThread)
thread = agent.get_new_thread()
# Create a session to maintain conversation state (like .NET AgentSession)
session = agent.create_session()
print("=" * 70)
print("CONVERSATION WITH HISTORY")
@@ -99,21 +99,21 @@ async def main():
# Turn 1: Introduce
print("\nUser: My name is Alice and I live in Seattle\n")
async for chunk in agent.run("My name is Alice and I live in Seattle", stream=True, thread=thread):
async for chunk in agent.run("My name is Alice and I live in Seattle", stream=True, session=session):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
# Turn 2: Ask about name (tests history)
print("User: What's my name?\n")
async for chunk in agent.run("What's my name?", stream=True, thread=thread):
async for chunk in agent.run("What's my name?", stream=True, session=session):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
# Turn 3: Ask about location (tests history)
print("User: Where do I live?\n")
async for chunk in agent.run("Where do I live?", stream=True, thread=thread):
async for chunk in agent.run("Where do I live?", stream=True, session=session):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
@@ -123,7 +123,7 @@ async def main():
async for chunk in agent.run(
"What's the weather forecast for today in Seattle?",
stream=True,
thread=thread,
session=session,
):
if chunk.text:
print(chunk.text, end="", flush=True)
@@ -131,56 +131,11 @@ async def main():
# Turn 5: Test server-side tool (get_time_zone is server-side only)
print("User: What time zone is Seattle in?\n")
async for chunk in agent.run("What time zone is Seattle in?", stream=True, thread=thread):
async for chunk in agent.run("What time zone is Seattle in?", stream=True, session=session):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
# Show thread state
if thread.message_store:
def _preview_for_message(m) -> str:
# Prefer plain text when present
if getattr(m, "text", ""):
t = m.text
return (t[:60] + "...") if len(t) > 60 else t
# Build from contents when no direct text
parts: list[str] = []
for c in getattr(m, "contents", []) or []:
content_type = getattr(c, "type", None)
if content_type == "function_call":
args = getattr(c, "arguments", None)
if isinstance(args, dict):
try:
import json as _json
args_str = _json.dumps(args)
except Exception:
args_str = str(args)
else:
args_str = str(args or "{}")
parts.append(f"tool_call {getattr(c, 'name', '?')} {args_str}")
elif content_type == "function_result":
call_id = getattr(c, "call_id", "?")
result = getattr(c, "result", None)
parts.append(f"tool_result[{call_id}]: {str(result)[:40]}")
elif content_type == "text":
text = getattr(c, "text", None)
if text:
parts.append(text)
else:
typename = getattr(c, "type", c.__class__.__name__)
parts.append(f"<{typename}>")
preview = " | ".join(parts) if parts else ""
return (preview[:60] + "...") if len(preview) > 60 else preview
messages = await thread.message_store.list_messages()
print(f"\n[THREAD STATE] {len(messages)} messages in thread's message_store")
for i, msg in enumerate(messages[-6:], 1): # Show last 6
role = msg.role if hasattr(msg.role, "value") else str(msg.role)
text_preview = _preview_for_message(msg)
print(f" {i}. [{role}]: {text_preview}")
except ConnectionError as e:
print(f"\n\033[91mConnection Error: {e}\033[0m")
print("\nMake sure an AG-UI server is running at the specified endpoint.")
+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "agent-framework-ag-ui"
version = "1.0.0b260210"
version = "1.0.0b260212"
description = "AG-UI protocol integration for Agent Framework"
readme = "README.md"
license-files = ["LICENSE"]
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0b260210",
"agent-framework-core>=1.0.0b260212",
"ag-ui-protocol>=0.1.9",
"fastapi>=0.115.0",
"uvicorn>=0.30.0"
+10 -10
View File
@@ -11,7 +11,7 @@ import pytest
from agent_framework import (
AgentResponse,
AgentResponseUpdate,
AgentThread,
AgentSession,
BaseChatClient,
ChatOptions,
ChatResponse,
@@ -49,8 +49,8 @@ class StreamingChatClientStub(
super().__init__(function_middleware=[])
self._stream_fn = stream_fn
self._response_fn = response_fn
self.last_thread: AgentThread | None = None
self.last_service_thread_id: str | None = None
self.last_session: AgentSession | None = None
self.last_service_session_id: str | None = None
@overload
def get_response(
@@ -90,8 +90,8 @@ class StreamingChatClientStub(
options: OptionsCoT | ChatOptions[Any] | None = None,
**kwargs: Any,
) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]:
self.last_thread = kwargs.get("thread")
self.last_service_thread_id = self.last_thread.service_thread_id if self.last_thread else None
self.last_session = kwargs.get("session")
self.last_service_session_id = self.last_session.service_session_id if self.last_session else None
return cast(
Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]],
super().get_response(
@@ -178,7 +178,7 @@ class StubAgent(SupportsAgentRun):
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: Literal[False] = ...,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]]: ...
@@ -188,7 +188,7 @@ class StubAgent(SupportsAgentRun):
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: Literal[True],
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
@@ -197,7 +197,7 @@ class StubAgent(SupportsAgentRun):
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]:
if stream:
@@ -218,8 +218,8 @@ class StubAgent(SupportsAgentRun):
return _get_response()
def get_new_thread(self, **kwargs: Any) -> AgentThread:
return AgentThread()
def create_session(self, **kwargs: Any) -> AgentSession:
return AgentSession()
# Fixtures
@@ -444,13 +444,7 @@ async def test_thread_metadata_tracking(streaming_chat_client_stub):
async for event in wrapper.run_agent(input_data):
events.append(event)
# AG-UI internal metadata should be stored in thread.metadata
thread = agent.client.last_thread
thread_metadata = thread.metadata if thread and hasattr(thread, "metadata") else {}
assert thread_metadata.get("ag_ui_thread_id") == "test_thread_123"
assert thread_metadata.get("ag_ui_run_id") == "test_run_456"
# Internal metadata should NOT be passed to chat client options
# AG-UI internal metadata should NOT be passed to chat client options
options_metadata = captured_options.get("metadata", {})
assert "ag_ui_thread_id" not in options_metadata
assert "ag_ui_run_id" not in options_metadata
@@ -488,15 +482,7 @@ async def test_state_context_injection(streaming_chat_client_stub):
async for event in wrapper.run_agent(input_data):
events.append(event)
# Current state should be stored in thread.metadata
thread = agent.client.last_thread
thread_metadata = thread.metadata if thread and hasattr(thread, "metadata") else {}
current_state = thread_metadata.get("current_state")
if isinstance(current_state, str):
current_state = json.loads(current_state)
assert current_state == {"document": "Test content"}
# Internal metadata should NOT be passed to chat client options
# Current state should NOT be passed to chat client options
options_metadata = captured_options.get("metadata", {})
assert "current_state" not in options_metadata
@@ -611,11 +597,11 @@ async def test_json_decode_error_in_tool_result(streaming_chat_client_stub):
assert len(tool_events) == 0
async def test_agent_with_use_service_thread_is_false(streaming_chat_client_stub):
"""Test that when use_service_thread is False, the AgentThread used to run the agent is NOT set to the service thread ID."""
async def test_agent_with_use_service_session_is_false(streaming_chat_client_stub):
"""Test that when use_service_session is False, the AgentSession used to run the agent is NOT set to the service session ID."""
from agent_framework.ag_ui import AgentFrameworkAgent
request_service_thread_id: str | None = None
request_service_session_id: str | None = None
async def stream_fn(
messages: MutableSequence[Message], chat_options: ChatOptions, **kwargs: Any
@@ -625,42 +611,42 @@ async def test_agent_with_use_service_thread_is_false(streaming_chat_client_stub
)
agent = Agent(client=streaming_chat_client_stub(stream_fn))
wrapper = AgentFrameworkAgent(agent=agent, use_service_thread=False)
wrapper = AgentFrameworkAgent(agent=agent, use_service_session=False)
input_data = {"messages": [{"role": "user", "content": "Hi"}], "thread_id": "conv_123456"}
events: list[Any] = []
async for event in wrapper.run_agent(input_data):
events.append(event)
assert request_service_thread_id is None # type: ignore[attr-defined] (service_thread_id should be set)
assert request_service_session_id is None # type: ignore[attr-defined] (service_session_id should be set)
async def test_agent_with_use_service_thread_is_true(streaming_chat_client_stub):
"""Test that when use_service_thread is True, the AgentThread used to run the agent is set to the service thread ID."""
async def test_agent_with_use_service_session_is_true(streaming_chat_client_stub):
"""Test that when use_service_session is True, the AgentSession used to run the agent is set to the service session ID."""
from agent_framework.ag_ui import AgentFrameworkAgent
request_service_thread_id: str | None = None
request_service_session_id: str | None = None
async def stream_fn(
messages: MutableSequence[Message], chat_options: ChatOptions, **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
nonlocal request_service_thread_id
thread = kwargs.get("thread")
request_service_thread_id = thread.service_thread_id if thread else None
nonlocal request_service_session_id
session = kwargs.get("session")
request_service_session_id = session.service_session_id if session else None
yield ChatResponseUpdate(
contents=[Content.from_text(text="Response")], response_id="resp_67890", conversation_id="conv_12345"
)
agent = Agent(client=streaming_chat_client_stub(stream_fn))
wrapper = AgentFrameworkAgent(agent=agent, use_service_thread=True)
wrapper = AgentFrameworkAgent(agent=agent, use_service_session=True)
input_data = {"messages": [{"role": "user", "content": "Hi"}], "thread_id": "conv_123456"}
events: list[Any] = []
async for event in wrapper.run_agent(input_data):
events.append(event)
request_service_thread_id = agent.client.last_service_thread_id
assert request_service_thread_id == "conv_123456" # type: ignore[attr-defined] (service_thread_id should be set)
request_service_session_id = agent.client.last_service_session_id
assert request_service_session_id == "conv_123456" # type: ignore[attr-defined] (service_session_id should be set)
async def test_function_approval_mode_executes_tool(streaming_chat_client_stub):
@@ -6,6 +6,7 @@ import json
import pytest
from agent_framework import Agent, ChatResponseUpdate, Content
from agent_framework.orchestrations import SequentialBuilder
from fastapi import FastAPI, Header, HTTPException
from fastapi.params import Depends
from fastapi.testclient import TestClient
@@ -165,6 +166,28 @@ async def test_endpoint_event_streaming(build_chat_client):
assert found_run_finished
async def test_endpoint_with_workflow_as_agent_stream_output(build_chat_client):
"""Test endpoint handles workflow-as-agent stream outputs."""
app = FastAPI()
brainstorm_agent = Agent(name="brainstorm", instructions="Brainstorm ideas", client=build_chat_client("Idea"))
reviewer_agent = Agent(name="reviewer", instructions="Review ideas", client=build_chat_client("Review"))
agent = SequentialBuilder(participants=[brainstorm_agent, reviewer_agent]).build().as_agent()
add_agent_framework_fastapi_endpoint(app, agent, path="/workflow-like")
client = TestClient(app)
response = client.post("/workflow-like", json={"messages": [{"role": "user", "content": "Hello"}]})
assert response.status_code == 200
content = response.content.decode("utf-8")
lines = [line for line in content.split("\n") if line.startswith("data: ")]
event_types = [json.loads(line[6:]).get("type") for line in lines]
assert "RUN_STARTED" in event_types
assert "TEXT_MESSAGE_CONTENT" in event_types
assert "RUN_FINISHED" in event_types
async def test_endpoint_error_handling(build_chat_client):
"""Test endpoint error handling during request parsing."""
app = FastAPI()
+52 -1
View File
@@ -2,11 +2,13 @@
"""Tests for _run.py helper functions and FlowState."""
import pytest
from ag_ui.core import (
TextMessageEndEvent,
TextMessageStartEvent,
)
from agent_framework import Content, Message
from agent_framework import AgentResponseUpdate, Content, Message, ResponseStream
from agent_framework.exceptions import AgentExecutionException
from agent_framework_ag_ui._run import (
FlowState,
@@ -16,6 +18,7 @@ from agent_framework_ag_ui._run import (
_emit_tool_result,
_has_only_tool_calls,
_inject_state_context,
_normalize_response_stream,
_should_suppress_intermediate_snapshot,
)
@@ -179,6 +182,54 @@ class TestFlowState:
assert result[0]["id"] == "call_2"
class TestNormalizeResponseStream:
"""Tests for _normalize_response_stream helper."""
async def test_accepts_response_stream(self):
"""Accept standard ResponseStream values."""
async def _stream():
yield AgentResponseUpdate(contents=[Content.from_text("hello")], role="assistant")
stream = await _normalize_response_stream(ResponseStream(_stream()))
updates = [update async for update in stream]
assert len(updates) == 1
assert updates[0].contents[0].text == "hello"
async def test_accepts_async_iterable(self):
"""Accept workflow-style async generator streams."""
async def _stream():
yield AgentResponseUpdate(contents=[Content.from_text("hello")], role="assistant")
stream = await _normalize_response_stream(_stream())
updates = [update async for update in stream]
assert len(updates) == 1
assert updates[0].contents[0].text == "hello"
async def test_accepts_awaitable_resolving_to_async_iterable(self):
"""Accept awaitables that resolve to async iterable streams."""
async def _stream():
yield AgentResponseUpdate(contents=[Content.from_text("hello")], role="assistant")
async def _resolve():
return _stream()
stream = await _normalize_response_stream(_resolve())
updates = [update async for update in stream]
assert len(updates) == 1
assert updates[0].contents[0].text == "hello"
async def test_rejects_non_stream_values(self):
"""Reject unsupported stream return values."""
with pytest.raises(AgentExecutionException):
await _normalize_response_stream("not-a-stream")
class TestCreateStateContextMessage:
"""Tests for _create_state_context_message function."""
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260210"
version = "1.0.0b260212"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0b260210",
"agent-framework-core>=1.0.0b260212",
"anthropic>=0.70.0,<1",
]
@@ -2,8 +2,7 @@
import importlib.metadata
from ._context_provider import _AzureAISearchContextProvider
from ._search_provider import AzureAISearchContextProvider, AzureAISearchSettings
from ._context_provider import AzureAISearchContextProvider, AzureAISearchSettings
try:
__version__ = importlib.metadata.version(__name__)
@@ -13,6 +12,5 @@ except importlib.metadata.PackageNotFoundError:
__all__ = [
"AzureAISearchContextProvider",
"AzureAISearchSettings",
"_AzureAISearchContextProvider",
"__version__",
]
@@ -2,21 +2,20 @@
"""New-pattern Azure AI Search context provider using BaseContextProvider.
This module provides ``_AzureAISearchContextProvider``, a side-by-side implementation of
:class:`AzureAISearchContextProvider` built on the new :class:`BaseContextProvider` hooks
pattern. It will replace the existing class in PR2.
This module provides ``AzureAISearchContextProvider``, built on the new
:class:`BaseContextProvider` hooks pattern.
"""
from __future__ import annotations
import sys
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any, ClassVar, Literal
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypedDict
from agent_framework import AGENT_FRAMEWORK_USER_AGENT, Message
from agent_framework._logging import get_logger
from agent_framework._sessions import AgentSession, BaseContextProvider, SessionContext
from agent_framework._settings import load_settings
from agent_framework._settings import SecretString, load_settings
from agent_framework.exceptions import ServiceInitializationError
from azure.core.credentials import AzureKeyCredential
from azure.core.credentials_async import AsyncTokenCredential
@@ -43,8 +42,6 @@ from azure.search.documents.models import (
VectorizedQuery,
)
from ._search_provider import AzureAISearchSettings
if TYPE_CHECKING:
from agent_framework._agents import SupportsAgentRun
from azure.search.documents.knowledgebases.aio import KnowledgeBaseRetrievalClient
@@ -111,16 +108,34 @@ logger = get_logger(__name__)
_DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT = 10
class _AzureAISearchContextProvider(BaseContextProvider):
class AzureAISearchSettings(TypedDict, total=False):
"""Settings for Azure AI Search Context Provider with auto-loading from environment.
The settings are first loaded from environment variables with the prefix 'AZURE_SEARCH_'.
If the environment variables are not found, the settings can be loaded from a .env file.
Keys:
endpoint: Azure AI Search endpoint URL.
Can be set via environment variable AZURE_SEARCH_ENDPOINT.
index_name: Name of the search index.
Can be set via environment variable AZURE_SEARCH_INDEX_NAME.
knowledge_base_name: Name of an existing Knowledge Base (for agentic mode).
Can be set via environment variable AZURE_SEARCH_KNOWLEDGE_BASE_NAME.
api_key: API key for authentication (optional, use managed identity if not provided).
Can be set via environment variable AZURE_SEARCH_API_KEY.
"""
endpoint: str | None
index_name: str | None
knowledge_base_name: str | None
api_key: SecretString | None
class AzureAISearchContextProvider(BaseContextProvider):
"""Azure AI Search context provider using the new BaseContextProvider hooks pattern.
Retrieves relevant context from Azure AI Search using semantic or agentic search
modes. This is the new-pattern equivalent of :class:`AzureAISearchContextProvider`.
Note:
This class uses a temporary ``_`` prefix to coexist with the existing
:class:`AzureAISearchContextProvider`. It will replace the existing class
in PR2.
modes.
"""
_DEFAULT_SEARCH_CONTEXT_PROMPT: ClassVar[str] = "Use the following context to answer the question:"
@@ -179,10 +194,18 @@ class _AzureAISearchContextProvider(BaseContextProvider):
"""
super().__init__(source_id)
# Determine which fields are required based on mode
required: list[str | tuple[str, ...]] = ["endpoint"]
if mode == "semantic":
required.append("index_name")
elif mode == "agentic":
required.append(("index_name", "knowledge_base_name"))
# Load settings from environment/file
settings = load_settings(
AzureAISearchSettings,
env_prefix="AZURE_SEARCH_",
required_fields=required,
endpoint=endpoint,
index_name=index_name,
knowledge_base_name=knowledge_base_name,
@@ -191,32 +214,11 @@ class _AzureAISearchContextProvider(BaseContextProvider):
env_file_encoding=env_file_encoding,
)
if not settings.get("endpoint"):
if mode == "agentic" and settings.get("index_name") and not model_deployment_name:
raise ServiceInitializationError(
"Azure AI Search endpoint is required. Set via 'endpoint' parameter "
"or 'AZURE_SEARCH_ENDPOINT' environment variable."
"model_deployment_name is required for agentic mode when creating Knowledge Base from index."
)
if mode == "semantic":
if not settings.get("index_name"):
raise ServiceInitializationError(
"Azure AI Search index name is required for semantic mode. "
"Set via 'index_name' parameter or 'AZURE_SEARCH_INDEX_NAME' environment variable."
)
elif mode == "agentic":
if settings.get("index_name") and settings.get("knowledge_base_name"):
raise ServiceInitializationError(
"For agentic mode, provide either 'index_name' OR 'knowledge_base_name', not both."
)
if not settings.get("index_name") and not settings.get("knowledge_base_name"):
raise ServiceInitializationError(
"For agentic mode, provide either 'index_name' or 'knowledge_base_name'."
)
if settings.get("index_name") and not model_deployment_name:
raise ServiceInitializationError(
"model_deployment_name is required for agentic mode when creating Knowledge Base from index."
)
resolved_credential: AzureKeyCredential | AsyncTokenCredential
if credential:
resolved_credential = credential
@@ -621,4 +623,4 @@ class _AzureAISearchContextProvider(BaseContextProvider):
return text
__all__ = ["_AzureAISearchContextProvider"]
__all__ = ["AzureAISearchContextProvider"]
@@ -1,991 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import sys
from collections.abc import Awaitable, Callable, MutableSequence
from typing import TYPE_CHECKING, Any, Literal
from agent_framework import AGENT_FRAMEWORK_USER_AGENT, Context, ContextProvider, Message
from agent_framework._logging import get_logger
from agent_framework._settings import SecretString, load_settings
from agent_framework.exceptions import ServiceInitializationError
from azure.core.credentials import AzureKeyCredential
from azure.core.credentials_async import AsyncTokenCredential
from azure.core.exceptions import ResourceNotFoundError
from azure.search.documents.aio import SearchClient
from azure.search.documents.indexes.aio import SearchIndexClient
from azure.search.documents.indexes.models import (
AzureOpenAIVectorizerParameters,
KnowledgeBase,
KnowledgeBaseAzureOpenAIModel,
KnowledgeRetrievalLowReasoningEffort,
KnowledgeRetrievalMediumReasoningEffort,
KnowledgeRetrievalMinimalReasoningEffort,
KnowledgeRetrievalOutputMode,
KnowledgeRetrievalReasoningEffort,
KnowledgeSourceReference,
SearchIndexKnowledgeSource,
SearchIndexKnowledgeSourceParameters,
)
from azure.search.documents.models import (
QueryCaptionType,
QueryType,
VectorizableTextQuery,
VectorizedQuery,
)
# Type checking imports for optional agentic mode dependencies
if TYPE_CHECKING:
from azure.search.documents.knowledgebases.aio import KnowledgeBaseRetrievalClient
from azure.search.documents.knowledgebases.models import (
KnowledgeBaseMessage,
KnowledgeBaseMessageTextContent,
KnowledgeBaseRetrievalRequest,
KnowledgeRetrievalIntent,
KnowledgeRetrievalSemanticIntent,
)
from azure.search.documents.knowledgebases.models import (
KnowledgeRetrievalLowReasoningEffort as KBRetrievalLowReasoningEffort,
)
from azure.search.documents.knowledgebases.models import (
KnowledgeRetrievalMediumReasoningEffort as KBRetrievalMediumReasoningEffort,
)
from azure.search.documents.knowledgebases.models import (
KnowledgeRetrievalMinimalReasoningEffort as KBRetrievalMinimalReasoningEffort,
)
from azure.search.documents.knowledgebases.models import (
KnowledgeRetrievalOutputMode as KBRetrievalOutputMode,
)
from azure.search.documents.knowledgebases.models import (
KnowledgeRetrievalReasoningEffort as KBRetrievalReasoningEffort,
)
# Runtime imports for agentic mode (optional dependency)
try:
from azure.search.documents.knowledgebases.aio import KnowledgeBaseRetrievalClient
from azure.search.documents.knowledgebases.models import (
KnowledgeBaseMessage,
KnowledgeBaseMessageTextContent,
KnowledgeBaseRetrievalRequest,
KnowledgeRetrievalIntent,
KnowledgeRetrievalSemanticIntent,
)
from azure.search.documents.knowledgebases.models import (
KnowledgeRetrievalLowReasoningEffort as KBRetrievalLowReasoningEffort,
)
from azure.search.documents.knowledgebases.models import (
KnowledgeRetrievalMediumReasoningEffort as KBRetrievalMediumReasoningEffort,
)
from azure.search.documents.knowledgebases.models import (
KnowledgeRetrievalMinimalReasoningEffort as KBRetrievalMinimalReasoningEffort,
)
from azure.search.documents.knowledgebases.models import (
KnowledgeRetrievalOutputMode as KBRetrievalOutputMode,
)
from azure.search.documents.knowledgebases.models import (
KnowledgeRetrievalReasoningEffort as KBRetrievalReasoningEffort,
)
_agentic_retrieval_available = True
except ImportError:
_agentic_retrieval_available = False
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
if sys.version_info >= (3, 11):
from typing import Self, TypedDict # pragma: no cover
else:
from typing_extensions import Self, TypedDict # pragma: no cover
"""Azure AI Search Context Provider for Agent Framework.
This module provides context providers for Azure AI Search integration with two modes:
- Agentic: Recommended for most scenarios. Uses Knowledge Bases for query planning and
multi-hop reasoning. Slightly slower with more token consumption, but more accurate.
- Semantic: Fast hybrid search (vector + keyword) with semantic ranker. Best for simple
queries where speed is critical.
See: https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/foundry-iq-boost-response-relevance-by-36-with-agentic-retrieval/4470720
"""
# Module-level constants
logger = get_logger("agent_framework.azure")
_DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT = 10
class AzureAISearchSettings(TypedDict, total=False):
"""Settings for Azure AI Search Context Provider with auto-loading from environment.
The settings are first loaded from environment variables with the prefix 'AZURE_SEARCH_'.
If the environment variables are not found, the settings can be loaded from a .env file.
Keyword Args:
endpoint: Azure AI Search endpoint URL.
Can be set via environment variable AZURE_SEARCH_ENDPOINT.
index_name: Name of the search index.
Can be set via environment variable AZURE_SEARCH_INDEX_NAME.
knowledge_base_name: Name of an existing Knowledge Base (for agentic mode).
Can be set via environment variable AZURE_SEARCH_KNOWLEDGE_BASE_NAME.
api_key: API key for authentication (optional, use managed identity if not provided).
Can be set via environment variable AZURE_SEARCH_API_KEY.
env_file_path: If provided, the .env settings are read from this file path location.
env_file_encoding: The encoding of the .env file, defaults to 'utf-8'.
Examples:
.. code-block:: python
from agent_framework_aisearch import AzureAISearchSettings
# Using environment variables
# Set AZURE_SEARCH_ENDPOINT=https://mysearch.search.windows.net
# Set AZURE_SEARCH_INDEX_NAME=my-index
settings = AzureAISearchSettings()
# Or passing parameters directly
settings = AzureAISearchSettings(
endpoint="https://mysearch.search.windows.net",
index_name="my-index",
)
# Or loading from a .env file
settings = AzureAISearchSettings(env_file_path="path/to/.env")
"""
endpoint: str | None
index_name: str | None
knowledge_base_name: str | None
api_key: SecretString | None
class AzureAISearchContextProvider(ContextProvider):
"""Azure AI Search Context Provider with hybrid search and semantic ranking.
This provider retrieves relevant documents from Azure AI Search to provide context
to the AI agent. It supports two modes:
- **agentic**: Recommended for most scenarios. Uses Knowledge Bases for query planning
and multi-hop reasoning. Slightly slower with more token consumption, but provides
more accurate results (up to 36% improvement in response relevance).
- **semantic** (default): Fast hybrid search combining vector and keyword search
with semantic reranking. Best for simple queries where speed is critical.
Examples:
Using environment variables (recommended):
.. code-block:: python
from agent_framework_aisearch import AzureAISearchContextProvider
from azure.identity.aio import DefaultAzureCredential
# Set AZURE_SEARCH_ENDPOINT and AZURE_SEARCH_INDEX_NAME in environment
search_provider = AzureAISearchContextProvider(credential=DefaultAzureCredential())
Semantic hybrid search with API key:
.. code-block:: python
# Direct API key string
search_provider = AzureAISearchContextProvider(
endpoint="https://mysearch.search.windows.net",
index_name="my-index",
api_key="my-api-key",
mode="semantic",
)
Loading from .env file:
.. code-block:: python
# Load settings from a .env file
search_provider = AzureAISearchContextProvider(
credential=DefaultAzureCredential(), env_file_path="path/to/.env"
)
Agentic retrieval for complex queries:
.. code-block:: python
# Use agentic mode for multi-hop reasoning
# Note: azure_openai_resource_url is the OpenAI endpoint for Knowledge Base model calls,
# which is different from azure_ai_project_endpoint (the AI Foundry project endpoint)
search_provider = AzureAISearchContextProvider(
endpoint="https://mysearch.search.windows.net",
index_name="my-index",
credential=DefaultAzureCredential(),
mode="agentic",
azure_openai_resource_url="https://myresource.openai.azure.com",
model_deployment_name="gpt-4o",
knowledge_base_name="my-knowledge-base",
)
"""
_DEFAULT_SEARCH_CONTEXT_PROMPT = "Use the following context to answer the question:"
def __init__(
self,
endpoint: str | None = None,
index_name: str | None = None,
api_key: str | AzureKeyCredential | None = None,
credential: AsyncTokenCredential | None = None,
*,
mode: Literal["semantic", "agentic"] = "semantic",
top_k: int = 5,
semantic_configuration_name: str | None = None,
vector_field_name: str | None = None,
embedding_function: Callable[[str], Awaitable[list[float]]] | None = None,
context_prompt: str | None = None,
# Agentic mode parameters (Knowledge Base)
azure_openai_resource_url: str | None = None,
model_deployment_name: str | None = None,
model_name: str | None = None,
knowledge_base_name: str | None = None,
retrieval_instructions: str | None = None,
azure_openai_api_key: str | None = None,
knowledge_base_output_mode: Literal["extractive_data", "answer_synthesis"] = "extractive_data",
retrieval_reasoning_effort: Literal["minimal", "medium", "low"] = "minimal",
agentic_message_history_count: int = _DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
) -> None:
"""Initialize Azure AI Search Context Provider.
Args:
endpoint: Azure AI Search endpoint URL.
Can also be set via environment variable AZURE_SEARCH_ENDPOINT.
index_name: Name of the search index to query.
Can also be set via environment variable AZURE_SEARCH_INDEX_NAME.
api_key: API key for authentication (string or AzureKeyCredential).
Can also be set via environment variable AZURE_SEARCH_API_KEY.
credential: AsyncTokenCredential for managed identity authentication.
Use this for Entra ID authentication instead of api_key.
mode: Search mode - "semantic" for hybrid search with semantic ranking (fast)
or "agentic" for multi-hop reasoning (slower). Default: "semantic".
top_k: Maximum number of documents to retrieve. Only applies to semantic mode.
In agentic mode, the server-side Knowledge Base determines retrieval based on
query complexity and reasoning effort. Default: 5.
semantic_configuration_name: Name of semantic configuration in the index.
Required for semantic ranking. If None, uses index default.
vector_field_name: Name of the vector field in the index for hybrid search.
Required if using vector search. Default: None (keyword search only).
embedding_function: Async function to generate embeddings for vector search.
Signature: async def embed(text: str) -> list[float]
Required if vector_field_name is specified and no server-side vectorization.
context_prompt: Custom prompt to prepend to retrieved context.
Default: "Use the following context to answer the question:"
azure_openai_resource_url: Azure OpenAI resource URL for Knowledge Base model calls.
Required when using agentic mode with index_name (to auto-create Knowledge Base).
Not required when using an existing knowledge_base_name.
Example: "https://myresource.openai.azure.com"
model_deployment_name: Model deployment name in Azure OpenAI for Knowledge Base.
Required when using agentic mode with index_name (to auto-create Knowledge Base).
Not required when using an existing knowledge_base_name.
model_name: The underlying model name (e.g., "gpt-4o", "gpt-4o-mini").
If not provided, defaults to model_deployment_name. Used for Knowledge Base configuration.
knowledge_base_name: Name of an existing Knowledge Base to use.
Required for agentic mode if not providing index_name.
Supports KBs with any source type (web, blob, index, etc.).
retrieval_instructions: Custom instructions for the Knowledge Base's
retrieval planning. Only used in agentic mode.
azure_openai_api_key: Azure OpenAI API key for Knowledge Base to call the model.
Only needed when using API key authentication instead of managed identity.
knowledge_base_output_mode: Output mode for Knowledge Base retrieval. Only used in agentic mode.
"extractive_data": Returns raw chunks without synthesis (default, recommended for agent integration).
"answer_synthesis": Returns synthesized answer from the LLM.
Some knowledge sources require answer_synthesis mode. Default: "extractive_data".
retrieval_reasoning_effort: Reasoning effort for Knowledge Base query planning. Only used in agentic mode.
"minimal": Fastest, basic query planning.
"medium": Moderate reasoning with some query decomposition.
"low": Lower reasoning effort than medium.
Default: "minimal".
agentic_message_history_count: Number of recent messages from conversation history to send to
the Knowledge Base. This context helps with query planning in agentic mode, allowing the
Knowledge Base to understand the conversation flow and generate better retrieval queries.
There is no technical limit - adjust based on your use case. Default: 10.
env_file_path: Path to environment file for loading settings.
env_file_encoding: Encoding of the environment file.
Examples:
.. code-block:: python
from agent_framework_aisearch import AzureAISearchContextProvider
from azure.identity.aio import DefaultAzureCredential
# Using environment variables
# Set AZURE_SEARCH_ENDPOINT=https://mysearch.search.windows.net
# Set AZURE_SEARCH_INDEX_NAME=my-index
credential = DefaultAzureCredential()
provider = AzureAISearchContextProvider(credential=credential)
# Or passing parameters directly
provider = AzureAISearchContextProvider(
endpoint="https://mysearch.search.windows.net",
index_name="my-index",
credential=credential,
)
# Or loading from a .env file
provider = AzureAISearchContextProvider(credential=credential, env_file_path="path/to/.env")
"""
# Load settings from environment/file
settings = load_settings(
AzureAISearchSettings,
env_prefix="AZURE_SEARCH_",
endpoint=endpoint,
index_name=index_name,
knowledge_base_name=knowledge_base_name,
api_key=api_key if isinstance(api_key, str) else None,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
# Validate required parameters
if not settings.get("endpoint"):
raise ServiceInitializationError(
"Azure AI Search endpoint is required. Set via 'endpoint' parameter "
"or 'AZURE_SEARCH_ENDPOINT' environment variable."
)
# Validate index_name and knowledge_base_name based on mode
# Note: settings["field"] / settings.get("field") contains the resolved value (explicit param OR env var)
if mode == "semantic":
# Semantic mode: always requires index_name
if not settings.get("index_name"):
raise ServiceInitializationError(
"Azure AI Search index name is required for semantic mode. "
"Set via 'index_name' parameter or 'AZURE_SEARCH_INDEX_NAME' environment variable."
)
elif mode == "agentic":
# Agentic mode: requires exactly ONE of index_name or knowledge_base_name
if settings.get("index_name") and settings.get("knowledge_base_name"):
raise ServiceInitializationError(
"For agentic mode, provide either 'index_name' OR 'knowledge_base_name', not both. "
"Use 'index_name' to auto-create a Knowledge Base, or 'knowledge_base_name' to use an existing one."
)
if not settings.get("index_name") and not settings.get("knowledge_base_name"):
raise ServiceInitializationError(
"For agentic mode, provide either 'index_name' (to auto-create Knowledge Base) "
"or 'knowledge_base_name' (to use existing Knowledge Base). "
"Set via parameters or environment variables "
"AZURE_SEARCH_INDEX_NAME / AZURE_SEARCH_KNOWLEDGE_BASE_NAME."
)
# If using index_name to create KB, model config is required
if settings.get("index_name") and not model_deployment_name:
raise ServiceInitializationError(
"model_deployment_name is required for agentic mode when creating Knowledge Base from index. "
"This is the Azure OpenAI deployment used by the Knowledge Base for query planning."
)
# Determine the credential to use
resolved_credential: AzureKeyCredential | AsyncTokenCredential
if credential:
# AsyncTokenCredential takes precedence
resolved_credential = credential
elif isinstance(api_key, AzureKeyCredential):
resolved_credential = api_key
elif resolved_api_key := settings.get("api_key"):
resolved_credential = AzureKeyCredential(resolved_api_key.get_secret_value())
else:
raise ServiceInitializationError(
"Azure credential is required. Provide 'api_key' or 'credential' parameter "
"or set 'AZURE_SEARCH_API_KEY' environment variable."
)
self.endpoint: str = settings["endpoint"] # type: ignore[assignment] # validated above
self.index_name = settings.get("index_name")
self.credential = resolved_credential
self.mode = mode
self.top_k = top_k
self.semantic_configuration_name = semantic_configuration_name
self.vector_field_name = vector_field_name
self.embedding_function = embedding_function
self.context_prompt = context_prompt or self._DEFAULT_SEARCH_CONTEXT_PROMPT
# Agentic mode parameters (Knowledge Base)
self.azure_openai_resource_url = azure_openai_resource_url
self.azure_openai_deployment_name = model_deployment_name
# If model_name not provided, default to deployment name
self.model_name = model_name or model_deployment_name
# Use resolved KB name (from explicit param or env var)
self.knowledge_base_name = settings.get("knowledge_base_name")
self.retrieval_instructions = retrieval_instructions
self.azure_openai_api_key = azure_openai_api_key
self.knowledge_base_output_mode = knowledge_base_output_mode
self.retrieval_reasoning_effort = retrieval_reasoning_effort
self.agentic_message_history_count = agentic_message_history_count
# Determine if using existing Knowledge Base or auto-creating from index
# Since validation ensures exactly one of index_name/knowledge_base_name for agentic mode:
# - knowledge_base_name provided: use existing KB
# - index_name provided: auto-create KB from index
self._use_existing_knowledge_base = False
if mode == "agentic":
if settings.get("knowledge_base_name"):
# Use existing KB directly (supports any source type: web, blob, index, etc.)
self._use_existing_knowledge_base = True
else:
# Auto-generate KB name from index name
self.knowledge_base_name = f"{settings.get('index_name', '')}-kb"
# Auto-discover vector field if not specified
self._auto_discovered_vector_field = False
self._use_vectorizable_query = False # Will be set to True if server-side vectorization detected
if not vector_field_name and mode == "semantic":
# Attempt to auto-discover vector field from index schema
# This will be done lazily on first search to avoid blocking initialization
pass
# Validation
if vector_field_name and not embedding_function:
raise ValueError("embedding_function is required when vector_field_name is specified")
if mode == "agentic":
if not _agentic_retrieval_available:
raise ImportError(
"Agentic retrieval requires azure-search-documents >= 11.7.0b1 with Knowledge Base support. "
"Please upgrade: pip install azure-search-documents>=11.7.0b1"
)
# Only require OpenAI resource URL if NOT using existing KB
# (existing KB already has its model configuration)
# Note: model_deployment_name is already validated at initialization
if not self._use_existing_knowledge_base and not self.azure_openai_resource_url:
raise ValueError(
"azure_openai_resource_url is required for agentic mode when creating Knowledge Base from index. "
"This should be your Azure OpenAI endpoint (e.g., 'https://myresource.openai.azure.com')"
)
# Create search client for semantic mode (only if index_name is available)
self._search_client: SearchClient | None = None
if self.index_name:
self._search_client = SearchClient(
endpoint=self.endpoint,
index_name=self.index_name,
credential=self.credential,
user_agent=AGENT_FRAMEWORK_USER_AGENT,
)
# Create index client and retrieval client for agentic mode (Knowledge Base)
self._index_client: SearchIndexClient | None = None
self._retrieval_client: KnowledgeBaseRetrievalClient | None = None
if mode == "agentic":
self._index_client = SearchIndexClient(
endpoint=self.endpoint,
credential=self.credential,
user_agent=AGENT_FRAMEWORK_USER_AGENT,
)
# Retrieval client will be created after Knowledge Base initialization
self._knowledge_base_initialized = False
async def __aenter__(self) -> Self:
"""Async context manager entry."""
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: Any,
) -> None:
"""Async context manager exit - cleanup clients.
Args:
exc_type: Exception type if an error occurred.
exc_val: Exception value if an error occurred.
exc_tb: Exception traceback if an error occurred.
"""
# Close retrieval client if it was created
if self._retrieval_client is not None:
await self._retrieval_client.close()
self._retrieval_client = None
@override
async def invoking(
self,
messages: Message | MutableSequence[Message],
**kwargs: Any,
) -> Context:
"""Retrieve relevant context from Azure AI Search before model invocation.
Args:
messages: User messages to use for context retrieval.
**kwargs: Additional arguments (unused).
Returns:
Context object with retrieved documents as messages.
"""
# Convert to list and filter to USER/ASSISTANT messages with text only
messages_list = [messages] if isinstance(messages, Message) else list(messages)
def get_role_value(role: str | Any) -> str:
return role.value if hasattr(role, "value") else str(role)
filtered_messages = [
msg
for msg in messages_list
if msg and msg.text and msg.text.strip() and get_role_value(msg.role) in ["user", "assistant"]
]
if not filtered_messages:
return Context()
# Perform search based on mode
if self.mode == "semantic":
# Semantic mode: flatten messages to single query
query = "\n".join(msg.text for msg in filtered_messages)
search_result_parts = await self._semantic_search(query)
else: # agentic
# Agentic mode: pass recent messages as conversation history
recent_messages = filtered_messages[-self.agentic_message_history_count :]
search_result_parts = await self._agentic_search(recent_messages)
# Format results as context - return multiple messages for each result part
if not search_result_parts:
return Context()
# Create context messages: first message with prompt, then one message per result part
context_messages = [Message(role="user", text=self.context_prompt)]
context_messages.extend([Message(role="user", text=part) for part in search_result_parts])
return Context(messages=context_messages)
def _find_vector_fields(self, index: Any) -> list[str]:
"""Find all fields that can store vectors (have dimensions defined).
Args:
index: SearchIndex object from Azure Search.
Returns:
List of vector field names.
"""
return [
field.name
for field in index.fields
if field.vector_search_dimensions is not None and field.vector_search_dimensions > 0
]
def _find_vectorizable_fields(self, index: Any, vector_fields: list[str]) -> list[str]:
"""Find vector fields that have auto-vectorization configured.
These are fields that have a vectorizer in their profile, meaning the index
can automatically vectorize text queries without needing a client-side embedding function.
Args:
index: SearchIndex object from Azure Search.
vector_fields: List of vector field names.
Returns:
List of vectorizable field names (subset of vector_fields).
"""
vectorizable_fields: list[str] = []
# Check if index has vector search configuration
if not index.vector_search or not index.vector_search.profiles:
return vectorizable_fields
# For each vector field, check if it has a vectorizer configured
for field in index.fields:
if field.name in vector_fields and field.vector_search_profile_name:
# Find the profile for this field
profile = next(
(p for p in index.vector_search.profiles if p.name == field.vector_search_profile_name), None
)
if profile and hasattr(profile, "vectorizer_name") and profile.vectorizer_name:
# This field has server-side vectorization configured
vectorizable_fields.append(field.name)
return vectorizable_fields
async def _auto_discover_vector_field(self) -> None:
"""Auto-discover vector field from index schema.
Attempts to find vector fields in the index and detect which have server-side
vectorization configured. Prioritizes vectorizable fields (which can auto-embed text)
over regular vector fields (which require client-side embedding).
"""
if self._auto_discovered_vector_field or self.vector_field_name:
return # Already discovered or manually specified
try:
# Use existing index client or create temporary one
if not self._index_client:
self._index_client = SearchIndexClient(
endpoint=self.endpoint,
credential=self.credential,
user_agent=AGENT_FRAMEWORK_USER_AGENT,
)
index_client = self._index_client
# Get index schema (index_name is guaranteed to be set for semantic mode)
if not self.index_name:
logger.warning("Cannot auto-discover vector field: index_name is not set.")
self._auto_discovered_vector_field = True
return
index = await index_client.get_index(self.index_name)
# Step 1: Find all vector fields
vector_fields = self._find_vector_fields(index)
if not vector_fields:
# No vector fields found - keyword search only
logger.info(f"No vector fields found in index '{self.index_name}'. Using keyword-only search.")
self._auto_discovered_vector_field = True
return
# Step 2: Find which vector fields have server-side vectorization
vectorizable_fields = self._find_vectorizable_fields(index, vector_fields)
# Step 3: Decide which field to use
if vectorizable_fields:
# Prefer vectorizable fields (server-side embedding)
if len(vectorizable_fields) == 1:
self.vector_field_name = vectorizable_fields[0]
self._auto_discovered_vector_field = True
self._use_vectorizable_query = True # Use VectorizableTextQuery
logger.info(
f"Auto-discovered vectorizable field '{self.vector_field_name}' "
f"with server-side vectorization. No embedding_function needed."
)
else:
# Multiple vectorizable fields
logger.warning(
f"Multiple vectorizable fields found: {vectorizable_fields}. "
f"Please specify vector_field_name explicitly. Using keyword-only search."
)
elif len(vector_fields) == 1:
# Single vector field without vectorizer - needs client-side embedding
self.vector_field_name = vector_fields[0]
self._auto_discovered_vector_field = True
self._use_vectorizable_query = False
if not self.embedding_function:
logger.warning(
f"Auto-discovered vector field '{self.vector_field_name}' without server-side vectorization. "
f"Provide embedding_function for vector search, or it will fall back to keyword-only search."
)
self.vector_field_name = None
else:
# Multiple vector fields without vectorizers
logger.warning(
f"Multiple vector fields found: {vector_fields}. "
f"Please specify vector_field_name explicitly. Using keyword-only search."
)
except Exception as e:
# Log warning but continue with keyword search
logger.warning(f"Failed to auto-discover vector field: {e}. Using keyword-only search.")
self._auto_discovered_vector_field = True # Mark as attempted
async def _semantic_search(self, query: str) -> list[str]:
"""Perform semantic hybrid search with semantic ranking.
This is the recommended mode for most use cases. It combines:
- Vector search (if embedding_function provided)
- Keyword search (BM25)
- Semantic reranking (if semantic_configuration_name provided)
Args:
query: Search query text.
Returns:
List of formatted search result strings, one per document.
"""
# Auto-discover vector field if not already done
await self._auto_discover_vector_field()
vector_queries: list[VectorizableTextQuery | VectorizedQuery] = []
# Build vector query based on server-side vectorization or client-side embedding
if self.vector_field_name:
# Use larger k for vector query when semantic reranker is enabled for better ranking quality
vector_k = max(self.top_k, 50) if self.semantic_configuration_name else self.top_k
if self._use_vectorizable_query:
# Server-side vectorization: Index will auto-embed the text query
vector_queries = [
VectorizableTextQuery(
text=query,
k_nearest_neighbors=vector_k,
fields=self.vector_field_name,
)
]
elif self.embedding_function:
# Client-side embedding: We provide the vector
query_vector = await self.embedding_function(query)
vector_queries = [
VectorizedQuery(
vector=query_vector,
k_nearest_neighbors=vector_k,
fields=self.vector_field_name,
)
]
# else: vector_field_name is set but no vectorization available - skip vector search
# Build search parameters
search_params: dict[str, Any] = {
"search_text": query,
"top": self.top_k,
}
if vector_queries:
search_params["vector_queries"] = vector_queries
# Add semantic ranking if configured
if self.semantic_configuration_name:
search_params["query_type"] = QueryType.SEMANTIC
search_params["semantic_configuration_name"] = self.semantic_configuration_name
search_params["query_caption"] = QueryCaptionType.EXTRACTIVE
# Execute search (search client is guaranteed to exist for semantic mode)
if not self._search_client:
raise RuntimeError("Search client is not initialized. This should not happen in semantic mode.")
results = await self._search_client.search(**search_params) # type: ignore[reportUnknownVariableType]
# Format results with citations
formatted_results: list[str] = []
async for doc in results: # type: ignore[reportUnknownVariableType]
# Extract document ID for citation
doc_id = doc.get("id") or doc.get("@search.id") # type: ignore[reportUnknownVariableType]
# Use full document chunks with citation
doc_text: str = self._extract_document_text(doc, doc_id=doc_id) # type: ignore[reportUnknownArgumentType]
if doc_text:
formatted_results.append(doc_text) # type: ignore[reportUnknownArgumentType]
return formatted_results
async def _ensure_knowledge_base(self) -> None:
"""Ensure Knowledge Base and knowledge source are created or use existing KB.
This method is idempotent - it will only create resources if they don't exist.
Note: Azure SDK uses KnowledgeAgent classes internally, but the feature
is marketed as "Knowledge Bases" in Azure AI Search.
"""
if self._knowledge_base_initialized:
return
# Runtime validation
if not self.knowledge_base_name:
raise ValueError("knowledge_base_name is required for agentic mode")
knowledge_base_name = self.knowledge_base_name
# Path 1: Use existing Knowledge Base directly (no index needed)
# This supports KB with any source type (web, blob, index, etc.)
if self._use_existing_knowledge_base:
# Just create the retrieval client - KB already exists with its own sources
if _agentic_retrieval_available and self._retrieval_client is None:
self._retrieval_client = KnowledgeBaseRetrievalClient(
endpoint=self.endpoint,
knowledge_base_name=knowledge_base_name,
credential=self.credential,
user_agent=AGENT_FRAMEWORK_USER_AGENT,
)
self._knowledge_base_initialized = True
return
# Path 2: Auto-create Knowledge Base from search index
# Requires index_client and OpenAI configuration
if not self._index_client:
raise ValueError("Index client is required when creating Knowledge Base from index")
if not self.azure_openai_resource_url:
raise ValueError("azure_openai_resource_url is required when creating Knowledge Base from index")
if not self.azure_openai_deployment_name:
raise ValueError("model_deployment_name is required when creating Knowledge Base from index")
if not self.index_name:
raise ValueError("index_name is required when creating Knowledge Base from index")
# Step 1: Create or get knowledge source from index
knowledge_source_name = f"{self.index_name}-source"
try:
# Try to get existing knowledge source
await self._index_client.get_knowledge_source(knowledge_source_name)
except ResourceNotFoundError:
# Create new knowledge source if it doesn't exist
knowledge_source = SearchIndexKnowledgeSource(
name=knowledge_source_name,
description=f"Knowledge source for {self.index_name} search index",
search_index_parameters=SearchIndexKnowledgeSourceParameters(
search_index_name=self.index_name,
),
)
await self._index_client.create_knowledge_source(knowledge_source)
# Step 2: Create or update Knowledge Base
# Always create/update to ensure configuration is current
aoai_params = AzureOpenAIVectorizerParameters(
resource_url=self.azure_openai_resource_url,
deployment_name=self.azure_openai_deployment_name,
model_name=self.model_name,
api_key=self.azure_openai_api_key,
)
# Map output mode string to SDK enum
output_mode = (
KnowledgeRetrievalOutputMode.EXTRACTIVE_DATA
if self.knowledge_base_output_mode == "extractive_data"
else KnowledgeRetrievalOutputMode.ANSWER_SYNTHESIS
)
# Map reasoning effort string to SDK class
reasoning_effort_map: dict[str, KnowledgeRetrievalReasoningEffort] = {
"minimal": KnowledgeRetrievalMinimalReasoningEffort(),
"medium": KnowledgeRetrievalMediumReasoningEffort(),
"low": KnowledgeRetrievalLowReasoningEffort(),
}
reasoning_effort = reasoning_effort_map[self.retrieval_reasoning_effort]
knowledge_base = KnowledgeBase(
name=knowledge_base_name,
description=f"Knowledge Base for multi-hop retrieval across {self.index_name}",
knowledge_sources=[
KnowledgeSourceReference(
name=knowledge_source_name,
)
],
models=[KnowledgeBaseAzureOpenAIModel(azure_open_ai_parameters=aoai_params)],
output_mode=output_mode,
retrieval_reasoning_effort=reasoning_effort,
)
await self._index_client.create_or_update_knowledge_base(knowledge_base)
self._knowledge_base_initialized = True
# Create retrieval client now that Knowledge Base is initialized
if _agentic_retrieval_available and self._retrieval_client is None:
self._retrieval_client = KnowledgeBaseRetrievalClient(
endpoint=self.endpoint,
knowledge_base_name=knowledge_base_name,
credential=self.credential,
user_agent=AGENT_FRAMEWORK_USER_AGENT,
)
async def _agentic_search(self, messages: list[Message]) -> list[str]:
"""Perform agentic retrieval with multi-hop reasoning using Knowledge Bases.
This mode uses query planning and is slightly slower than semantic search,
but provides more accurate results through intelligent retrieval.
This method uses Azure AI Search Knowledge Bases which:
1. Analyze the query and plan sub-queries
2. Retrieve relevant documents across multiple sources
3. Perform multi-hop reasoning with an LLM
4. Synthesize a comprehensive answer with references
Args:
messages: Conversation history to use for retrieval context.
Returns:
List of answer parts from the Knowledge Base, one per content item.
"""
# Ensure Knowledge Base is initialized
await self._ensure_knowledge_base()
# Map reasoning effort string to SDK class (for retrieval requests)
reasoning_effort_map: dict[str, KBRetrievalReasoningEffort] = {
"minimal": KBRetrievalMinimalReasoningEffort(),
"medium": KBRetrievalMediumReasoningEffort(),
"low": KBRetrievalLowReasoningEffort(),
}
reasoning_effort = reasoning_effort_map[self.retrieval_reasoning_effort]
# Map output mode string to SDK enum (for retrieval requests)
output_mode = (
KBRetrievalOutputMode.EXTRACTIVE_DATA
if self.knowledge_base_output_mode == "extractive_data"
else KBRetrievalOutputMode.ANSWER_SYNTHESIS
)
# For minimal reasoning, use intents API; for medium/low, use messages API
if self.retrieval_reasoning_effort == "minimal":
# Minimal reasoning uses intents with a single search query
query = "\n".join(msg.text for msg in messages if msg.text)
intents: list[KnowledgeRetrievalIntent] = [KnowledgeRetrievalSemanticIntent(search=query)]
retrieval_request = KnowledgeBaseRetrievalRequest(
intents=intents,
retrieval_reasoning_effort=reasoning_effort,
output_mode=output_mode,
include_activity=True,
)
else:
# Medium/low reasoning uses messages with conversation history
kb_messages = [
KnowledgeBaseMessage(
role=msg.role if hasattr(msg.role, "value") else str(msg.role),
content=[KnowledgeBaseMessageTextContent(text=msg.text)],
)
for msg in messages
if msg.text
]
retrieval_request = KnowledgeBaseRetrievalRequest(
messages=kb_messages,
retrieval_reasoning_effort=reasoning_effort,
output_mode=output_mode,
include_activity=True,
)
# Use reusable retrieval client
if not self._retrieval_client:
raise RuntimeError("Retrieval client not initialized. Ensure Knowledge Base is set up correctly.")
# Perform retrieval via Knowledge Base
retrieval_result = await self._retrieval_client.retrieve(retrieval_request=retrieval_request)
# Extract answer parts from response
if retrieval_result.response and len(retrieval_result.response) > 0:
# Get the assistant's response (last message)
assistant_message = retrieval_result.response[-1]
if assistant_message.content:
# Extract all text content items as separate parts
answer_parts: list[str] = []
for content_item in assistant_message.content:
# Check if this is a text content item
if isinstance(content_item, KnowledgeBaseMessageTextContent) and content_item.text:
answer_parts.append(content_item.text)
if answer_parts:
return answer_parts
# Fallback if no answer generated
return ["No results found from Knowledge Base."]
def _extract_document_text(self, doc: dict[str, Any], doc_id: str | None = None) -> str:
"""Extract readable text from a search document with optional citation.
Args:
doc: Search result document.
doc_id: Optional document ID for citation.
Returns:
Formatted document text with citation if doc_id provided.
"""
# Try common text field names
text = ""
for field in ["content", "text", "description", "body", "chunk"]:
if doc.get(field):
text = str(doc[field])
break
# Fallback: concatenate all string fields
if not text:
text_parts: list[str] = []
for key, value in doc.items():
if isinstance(value, str) and not key.startswith("@") and key != "id":
text_parts.append(f"{key}: {value}")
text = " | ".join(text_parts) if text_parts else ""
# Add citation if document ID provided
if doc_id and text:
return f"[Source: {doc_id}] {text}"
return text
@@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260210"
version = "1.0.0b260212"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0b260210",
"agent-framework-core>=1.0.0b260212",
"azure-search-documents==11.7.0b2",
]
@@ -7,9 +7,9 @@ from unittest.mock import AsyncMock, patch
import pytest
from agent_framework import Message
from agent_framework._sessions import AgentSession, SessionContext
from agent_framework.exceptions import ServiceInitializationError
from agent_framework.exceptions import ServiceInitializationError, SettingNotFoundError
from agent_framework_azure_ai_search._context_provider import _AzureAISearchContextProvider
from agent_framework_azure_ai_search._context_provider import AzureAISearchContextProvider
# -- Helpers -------------------------------------------------------------------
@@ -56,7 +56,7 @@ def mock_search_client_empty() -> AsyncMock:
return client
def _make_provider(**overrides) -> _AzureAISearchContextProvider:
def _make_provider(**overrides) -> AzureAISearchContextProvider:
"""Create a semantic-mode provider with mocked internals (skips auto-discovery)."""
defaults = {
"source_id": "aisearch",
@@ -65,7 +65,7 @@ def _make_provider(**overrides) -> _AzureAISearchContextProvider:
"api_key": "test-key",
}
defaults.update(overrides)
provider = _AzureAISearchContextProvider(**defaults)
provider = AzureAISearchContextProvider(**defaults)
provider._auto_discovered_vector_field = True # skip auto-discovery
return provider
@@ -88,8 +88,8 @@ class TestInitSemantic:
assert provider.source_id == "my-source"
def test_missing_endpoint_raises(self) -> None:
with patch.dict(os.environ, {}, clear=True), pytest.raises(ServiceInitializationError, match="endpoint"):
_AzureAISearchContextProvider(
with patch.dict(os.environ, {}, clear=True), pytest.raises(SettingNotFoundError, match="endpoint"):
AzureAISearchContextProvider(
source_id="s",
endpoint=None,
index_name="idx",
@@ -97,8 +97,8 @@ class TestInitSemantic:
)
def test_missing_index_name_semantic_raises(self) -> None:
with pytest.raises(ServiceInitializationError, match="index name"):
_AzureAISearchContextProvider(
with pytest.raises(SettingNotFoundError, match="index_name"):
AzureAISearchContextProvider(
source_id="s",
endpoint="https://test.search.windows.net",
index_name=None,
@@ -112,7 +112,7 @@ class TestInitSemantic:
"AZURE_SEARCH_API_KEY": "env-key",
}
with patch.dict(os.environ, env, clear=False):
provider = _AzureAISearchContextProvider(source_id="env-test")
provider = AzureAISearchContextProvider(source_id="env-test")
assert provider.endpoint == "https://env.search.windows.net"
assert provider.index_name == "env-index"
@@ -124,8 +124,8 @@ class TestInitAgenticValidation:
"""Initialization validation tests for agentic mode."""
def test_both_index_and_kb_raises(self) -> None:
with pytest.raises(ServiceInitializationError, match="not both"):
_AzureAISearchContextProvider(
with pytest.raises(SettingNotFoundError, match="multiple were set"):
AzureAISearchContextProvider(
source_id="s",
endpoint="https://test.search.windows.net",
index_name="idx",
@@ -137,8 +137,8 @@ class TestInitAgenticValidation:
)
def test_neither_index_nor_kb_raises(self) -> None:
with pytest.raises(ServiceInitializationError, match="provide either"):
_AzureAISearchContextProvider(
with pytest.raises(SettingNotFoundError, match="none was set"):
AzureAISearchContextProvider(
source_id="s",
endpoint="https://test.search.windows.net",
api_key="key",
@@ -147,7 +147,7 @@ class TestInitAgenticValidation:
def test_missing_model_deployment_name_raises(self) -> None:
with pytest.raises(ServiceInitializationError, match="model_deployment_name"):
_AzureAISearchContextProvider(
AzureAISearchContextProvider(
source_id="s",
endpoint="https://test.search.windows.net",
index_name="idx",
@@ -158,7 +158,7 @@ class TestInitAgenticValidation:
def test_vector_field_without_embedding_raises(self) -> None:
with pytest.raises(ValueError, match="embedding_function"):
_AzureAISearchContextProvider(
AzureAISearchContextProvider(
source_id="s",
endpoint="https://test.search.windows.net",
index_name="idx",
File diff suppressed because it is too large Load Diff
@@ -9,7 +9,7 @@ from typing import Any, Generic, cast
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
Agent,
ContextProvider,
BaseContextProvider,
FunctionTool,
MiddlewareTypes,
normalize_tools,
@@ -176,7 +176,7 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]):
| None = None,
default_options: OptionsCoT | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
context_provider: ContextProvider | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
) -> Agent[OptionsCoT]:
"""Create a new agent on the Azure AI service and return a Agent.
@@ -195,7 +195,7 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]):
default_options: A TypedDict containing default chat options for the agent.
These options are applied to every run unless overridden.
middleware: List of middleware to intercept agent and function invocations.
context_provider: Context provider to include during agent invocation.
context_providers: Context providers to include during agent invocation.
Returns:
Agent: A Agent instance configured with the created agent.
@@ -259,7 +259,7 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]):
normalized_tools,
default_options=default_options,
middleware=middleware,
context_provider=context_provider,
context_providers=context_providers,
)
async def get_agent(
@@ -273,7 +273,7 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]):
| None = None,
default_options: OptionsCoT | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
context_provider: ContextProvider | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
) -> Agent[OptionsCoT]:
"""Retrieve an existing agent from the service and return a Agent.
@@ -289,7 +289,7 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]):
default_options: A TypedDict containing default chat options for the agent.
These options are applied to every run unless overridden.
middleware: List of middleware to intercept agent and function invocations.
context_provider: Context provider to include during agent invocation.
context_providers: Context providers to include during agent invocation.
Returns:
Agent: A Agent instance configured with the retrieved agent.
@@ -316,7 +316,7 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]):
normalized_tools,
default_options=default_options,
middleware=middleware,
context_provider=context_provider,
context_providers=context_providers,
)
def as_agent(
@@ -329,7 +329,7 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]):
| None = None,
default_options: OptionsCoT | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
context_provider: ContextProvider | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
) -> Agent[OptionsCoT]:
"""Wrap an existing Agent SDK object as a Agent without making HTTP calls.
@@ -343,7 +343,7 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]):
default_options: A TypedDict containing default chat options for the agent.
These options are applied to every run unless overridden.
middleware: List of middleware to intercept agent and function invocations.
context_provider: Context provider to include during agent invocation.
context_providers: Context providers to include during agent invocation.
Returns:
Agent: A Agent instance configured with the agent.
@@ -373,7 +373,7 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]):
normalized_tools,
default_options=default_options,
middleware=middleware,
context_provider=context_provider,
context_providers=context_providers,
)
def _to_chat_agent_from_agent(
@@ -382,7 +382,7 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]):
provided_tools: Sequence[FunctionTool | MutableMapping[str, Any]] | None = None,
default_options: OptionsCoT | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
context_provider: ContextProvider | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
) -> Agent[OptionsCoT]:
"""Create a Agent from an Agent SDK object.
@@ -392,7 +392,7 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]):
default_options: A TypedDict containing default chat options for the agent.
These options are applied to every run unless overridden.
middleware: List of middleware to intercept agent and function invocations.
context_provider: Context provider to include during agent invocation.
context_providers: Context providers to include during agent invocation.
"""
# Create the underlying client
client = AzureAIAgentClient(
@@ -416,7 +416,7 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]):
tools=merged_tools,
default_options=default_options, # type: ignore[arg-type]
middleware=middleware,
context_provider=context_provider,
context_providers=context_providers,
)
def _merge_tools(
@@ -15,14 +15,13 @@ from agent_framework import (
Agent,
Annotation,
BaseChatClient,
BaseContextProvider,
ChatAndFunctionMiddlewareTypes,
ChatMessageStoreProtocol,
ChatMiddlewareLayer,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
Content,
ContextProvider,
FunctionInvocationConfiguration,
FunctionInvocationLayer,
FunctionTool,
@@ -211,6 +210,7 @@ class AzureAIAgentClient(
"""Azure AI Agent Chat client with middleware, telemetry, and function invocation support."""
OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai" # type: ignore[reportIncompatibleVariableOverride, misc]
STORES_BY_DEFAULT: ClassVar[bool] = True # type: ignore[reportIncompatibleVariableOverride, misc]
# region Hosted Tool Factory Methods
@@ -1434,8 +1434,7 @@ class AzureAIAgentClient(
| Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]]
| None = None,
default_options: AzureAIAgentOptionsT | Mapping[str, Any] | None = None,
chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None,
context_provider: ContextProvider | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
**kwargs: Any,
) -> Agent[AzureAIAgentOptionsT]:
@@ -1455,8 +1454,7 @@ class AzureAIAgentClient(
instructions: Optional instructions for the agent.
tools: The tools to use for the request.
default_options: A TypedDict containing chat options.
chat_message_store_factory: Factory function to create an instance of ChatMessageStoreProtocol.
context_provider: Context providers to include during agent invocation.
context_providers: Context providers to include during agent invocation.
middleware: List of middleware to intercept agent and function invocations.
kwargs: Any additional keyword arguments.
@@ -1470,8 +1468,7 @@ class AzureAIAgentClient(
instructions=instructions,
tools=tools,
default_options=default_options,
chat_message_store_factory=chat_message_store_factory,
context_provider=context_provider,
context_providers=context_providers,
middleware=middleware,
**kwargs,
)
@@ -9,10 +9,9 @@ from typing import Any, ClassVar, Generic, Literal, TypedDict, TypeVar, cast
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
Agent,
BaseContextProvider,
ChatAndFunctionMiddlewareTypes,
ChatMessageStoreProtocol,
ChatMiddlewareLayer,
ContextProvider,
FunctionInvocationConfiguration,
FunctionInvocationLayer,
FunctionTool,
@@ -808,8 +807,7 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
| Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]]
| None = None,
default_options: AzureAIClientOptionsT | Mapping[str, Any] | None = None,
chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None,
context_provider: ContextProvider | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
**kwargs: Any,
) -> Agent[AzureAIClientOptionsT]:
@@ -829,8 +827,7 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
instructions: Optional instructions for the agent.
tools: The tools to use for the request.
default_options: A TypedDict containing chat options.
chat_message_store_factory: Factory function to create an instance of ChatMessageStoreProtocol.
context_provider: Context providers to include during agent invocation.
context_providers: Context providers to include during agent invocation.
middleware: List of middleware to intercept agent and function invocations.
kwargs: Any additional keyword arguments.
@@ -844,8 +841,7 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
instructions=instructions,
tools=tools,
default_options=default_options,
chat_message_store_factory=chat_message_store_factory,
context_provider=context_provider,
context_providers=context_providers,
middleware=middleware,
**kwargs,
)
@@ -9,7 +9,7 @@ from typing import Any, Generic
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
Agent,
ContextProvider,
BaseContextProvider,
FunctionTool,
MiddlewareTypes,
get_logger,
@@ -168,7 +168,7 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
| None = None,
default_options: OptionsCoT | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
context_provider: ContextProvider | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
) -> Agent[OptionsCoT]:
"""Create a new agent on the Azure AI service and return a local Agent wrapper.
@@ -182,7 +182,7 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
default_options: A TypedDict containing default chat options for the agent.
These options are applied to every run unless overridden.
middleware: List of middleware to intercept agent and function invocations.
context_provider: Context provider to include during agent invocation.
context_providers: Context providers to include during agent invocation.
Returns:
Agent: A Agent instance configured with the created agent.
@@ -255,7 +255,7 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
normalized_tools,
default_options=default_options,
middleware=middleware,
context_provider=context_provider,
context_providers=context_providers,
)
async def get_agent(
@@ -270,7 +270,7 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
| None = None,
default_options: OptionsCoT | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
context_provider: ContextProvider | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
) -> Agent[OptionsCoT]:
"""Retrieve an existing agent from the Azure AI service and return a local Agent wrapper.
@@ -284,7 +284,7 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
default_options: A TypedDict containing default chat options for the agent.
These options are applied to every run unless overridden.
middleware: List of middleware to intercept agent and function invocations.
context_provider: Context provider to include during agent invocation.
context_providers: Context providers to include during agent invocation.
Returns:
Agent: A Agent instance configured with the retrieved agent.
@@ -317,7 +317,7 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
normalize_tools(tools),
default_options=default_options,
middleware=middleware,
context_provider=context_provider,
context_providers=context_providers,
)
def as_agent(
@@ -330,7 +330,7 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
| None = None,
default_options: OptionsCoT | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
context_provider: ContextProvider | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
) -> Agent[OptionsCoT]:
"""Wrap an SDK agent version object into a Agent without making HTTP calls.
@@ -342,7 +342,7 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
default_options: A TypedDict containing default chat options for the agent.
These options are applied to every run unless overridden.
middleware: List of middleware to intercept agent and function invocations.
context_provider: Context provider to include during agent invocation.
context_providers: Context providers to include during agent invocation.
Returns:
Agent: A Agent instance configured with the agent version.
@@ -361,7 +361,7 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
normalize_tools(tools),
default_options=default_options,
middleware=middleware,
context_provider=context_provider,
context_providers=context_providers,
)
def _to_chat_agent_from_details(
@@ -370,7 +370,7 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
provided_tools: Sequence[FunctionTool | MutableMapping[str, Any]] | None = None,
default_options: OptionsCoT | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
context_provider: ContextProvider | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
) -> Agent[OptionsCoT]:
"""Create a Agent from an AgentVersionDetails.
@@ -381,7 +381,7 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
default_options: A TypedDict containing default chat options for the agent.
These options are applied to every run unless overridden.
middleware: List of middleware to intercept agent and function invocations.
context_provider: Context provider to include during agent invocation.
context_providers: Context providers to include during agent invocation.
"""
if not isinstance(details.definition, PromptAgentDefinition):
raise ValueError("Agent definition must be PromptAgentDefinition to get a Agent.")
@@ -409,7 +409,7 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
tools=merged_tools,
default_options=default_options, # type: ignore[arg-type]
middleware=middleware,
context_provider=context_provider,
context_providers=context_providers,
)
def _merge_tools(
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Azure AI Foundry integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260210"
version = "1.0.0b260212"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0b260210",
"agent-framework-core>=1.0.0b260212",
"azure-ai-agents == 1.2.0b5",
"aiohttp",
]
@@ -11,7 +11,7 @@ from agent_framework import (
Agent,
AgentResponse,
AgentResponseUpdate,
AgentThread,
AgentSession,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
@@ -1524,24 +1524,24 @@ async def test_azure_ai_chat_client_agent_basic_run_streaming() -> None:
@pytest.mark.flaky
@skip_if_azure_ai_integration_tests_disabled
async def test_azure_ai_chat_client_agent_thread_persistence() -> None:
"""Test Agent thread persistence across runs with AzureAIAgentClient."""
"""Test Agent session persistence across runs with AzureAIAgentClient."""
async with Agent(
client=AzureAIAgentClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant with good memory.",
) as agent:
# Create a new thread that will be reused
thread = agent.get_new_thread()
# Create a new session that will be reused
session = agent.create_session()
# First message - establish context
first_response = await agent.run(
"Remember this number: 42. What number did I just tell you to remember?", thread=thread
"Remember this number: 42. What number did I just tell you to remember?", session=session
)
assert isinstance(first_response, AgentResponse)
assert "42" in first_response.text
# Second message - test conversation memory
second_response = await agent.run(
"What number did I tell you to remember in my previous message?", thread=thread
"What number did I tell you to remember in my previous message?", session=session
)
assert isinstance(second_response, AgentResponse)
assert "42" in second_response.text
@@ -1555,16 +1555,16 @@ async def test_azure_ai_chat_client_agent_existing_thread_id() -> None:
client=AzureAIAgentClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant with good memory.",
) as first_agent:
# Start a conversation and get the thread ID
thread = first_agent.get_new_thread()
first_response = await first_agent.run("My name is Alice. Remember this.", thread=thread)
# Start a conversation and get the session ID
session = first_agent.create_session()
first_response = await first_agent.run("My name is Alice. Remember this.", session=session)
# Validate first response
assert isinstance(first_response, AgentResponse)
assert first_response.text is not None
# The thread ID is set after the first response
existing_thread_id = thread.service_thread_id
existing_thread_id = session.service_session_id
assert existing_thread_id is not None
# Now continue with the same thread ID in a new agent instance
@@ -1572,11 +1572,11 @@ async def test_azure_ai_chat_client_agent_existing_thread_id() -> None:
client=AzureAIAgentClient(thread_id=existing_thread_id, credential=AzureCliCredential()),
instructions="You are a helpful assistant with good memory.",
) as second_agent:
# Create a thread with the existing ID
thread = AgentThread(service_thread_id=existing_thread_id)
# Create a session with the existing ID
session = AgentSession(service_session_id=existing_thread_id)
# Ask about the previous conversation
response2 = await second_agent.run("What is my name?", thread=thread)
response2 = await second_agent.run("What is my name?", session=session)
# Validate that the agent remembers the previous conversation
assert isinstance(response2, AgentResponse)
@@ -1473,39 +1473,39 @@ async def test_integration_agent_hosted_code_interpreter_tool():
@pytest.mark.flaky
@skip_if_azure_ai_integration_tests_disabled
async def test_integration_agent_existing_thread():
"""Test Azure Responses Client agent with existing thread to continue conversations across agent instances."""
# First conversation - capture the thread
preserved_thread = None
async def test_integration_agent_existing_session():
"""Test Azure Responses Client agent with existing session to continue conversations across agent instances."""
# First conversation - capture the session
preserved_session = None
async with (
temporary_chat_client(agent_name="af-int-test-existing-thread") as client,
temporary_chat_client(agent_name="af-int-test-existing-session") as client,
Agent(
client=client,
instructions="You are a helpful assistant with good memory.",
) as first_agent,
):
# Start a conversation and capture the thread
thread = first_agent.get_new_thread()
first_response = await first_agent.run("My hobby is photography. Remember this.", thread=thread, store=True)
# Start a conversation and capture the session
session = first_agent.create_session()
first_response = await first_agent.run("My hobby is photography. Remember this.", session=session, store=True)
assert isinstance(first_response, AgentResponse)
assert first_response.text is not None
# Preserve the thread for reuse
preserved_thread = thread
# Preserve the session for reuse
preserved_session = session
# Second conversation - reuse the thread in a new agent instance
if preserved_thread:
# Second conversation - reuse the session in a new agent instance
if preserved_session:
async with (
temporary_chat_client(agent_name="af-int-test-existing-thread-2") as client,
temporary_chat_client(agent_name="af-int-test-existing-session-2") as client,
Agent(
client=client,
instructions="You are a helpful assistant with good memory.",
) as second_agent,
):
# Reuse the preserved thread
second_response = await second_agent.run("What is my hobby?", thread=preserved_thread)
# Reuse the preserved session
second_response = await second_agent.run("What is my hobby?", session=preserved_session)
assert isinstance(second_response, AgentResponse)
assert second_response.text is not None
@@ -135,8 +135,8 @@ class AgentFunctionApp(DFAppBase):
@app.orchestration_trigger(context_name="context")
def my_orchestration(context):
writer = app.get_agent(context, "WeatherAgent")
thread = writer.get_new_thread()
forecast_task = writer.run("What's the forecast?", thread=thread)
session = writer.create_session()
forecast_task = writer.run("What's the forecast?", session=session)
forecast = yield forecast_task
return forecast
@@ -9,7 +9,7 @@ from collections.abc import Callable
from typing import TYPE_CHECKING, Any, TypeAlias
import azure.durable_functions as df
from agent_framework import AgentThread, get_logger
from agent_framework import AgentSession, get_logger
from agent_framework_durabletask import (
DurableAgentExecutor,
RunRequest,
@@ -178,11 +178,11 @@ class AzureFunctionsAgentExecutor(DurableAgentExecutor[AgentTask]):
self,
agent_name: str,
run_request: RunRequest,
thread: AgentThread | None = None,
session: AgentSession | None = None,
) -> AgentTask:
# Resolve session
session_id = self._create_session_id(agent_name, thread)
session_id = self._create_session_id(agent_name, session)
entity_id = df.EntityId(
name=session_id.entity_name,
@@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260210"
version = "1.0.0b260212"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0b260210",
"agent-framework-core>=1.0.0b260212",
"agent-framework-durabletask",
"azure-functions",
"azure-functions-durable",
@@ -214,10 +214,10 @@ class TestAzureFunctionsFireAndForget:
context.call_entity = Mock(return_value=_create_entity_task())
agent = DurableAIAgent(executor, "TestAgent")
thread = agent.get_new_thread()
session = agent.create_session()
# Run with wait_for_response=False
result = agent.run("Test message", thread=thread, options={"wait_for_response": False})
result = agent.run("Test message", session=session, options={"wait_for_response": False})
# Verify signal_entity was called and call_entity was not
assert context.signal_entity.call_count == 1
@@ -232,9 +232,9 @@ class TestAzureFunctionsFireAndForget:
context.signal_entity = Mock()
agent = DurableAIAgent(executor, "TestAgent")
thread = agent.get_new_thread()
session = agent.create_session()
result = agent.run("Test message", thread=thread, options={"wait_for_response": False})
result = agent.run("Test message", session=session, options={"wait_for_response": False})
# Task should be immediately complete
assert isinstance(result, AgentTask)
@@ -246,9 +246,9 @@ class TestAzureFunctionsFireAndForget:
context.signal_entity = Mock()
agent = DurableAIAgent(executor, "TestAgent")
thread = agent.get_new_thread()
session = agent.create_session()
result = agent.run("Test message", thread=thread, options={"wait_for_response": False})
result = agent.run("Test message", session=session, options={"wait_for_response": False})
# Get the result
response = result.result
@@ -267,9 +267,9 @@ class TestAzureFunctionsFireAndForget:
context.call_entity = Mock(return_value=_create_entity_task())
agent = DurableAIAgent(executor, "TestAgent")
thread = agent.get_new_thread()
session = agent.create_session()
result = agent.run("Test message", thread=thread, options={"wait_for_response": True})
result = agent.run("Test message", session=session, options={"wait_for_response": True})
# Verify call_entity was called and signal_entity was not
assert context.call_entity.call_count == 1
@@ -298,15 +298,15 @@ class TestOrchestrationIntegration:
# Create agent directly with executor (not via app.get_agent)
agent = DurableAIAgent(executor, "WriterAgent")
# Create thread
thread = agent.get_new_thread()
# Create session
session = agent.create_session()
# First call - returns AgentTask
task1 = agent.run("Write something", thread=thread)
task1 = agent.run("Write something", session=session)
assert isinstance(task1, AgentTask)
# Second call - returns AgentTask
task2 = agent.run("Improve: something", thread=thread)
task2 = agent.run("Improve: something", session=session)
assert isinstance(task2, AgentTask)
# Verify both calls used the same entity (same session key)
@@ -315,7 +315,7 @@ class TestOrchestrationIntegration:
# EntityId format is @dafx-writeragent@<uuid_hex>
expected_entity_id = f"@dafx-writeragent@{uuid_hexes[0]}"
assert entity_calls[0]["entity_id"] == expected_entity_id
# generate_unique_id called 3 times: thread + 2 correlation IDs
# generate_unique_id called 3 times: session + 2 correlation IDs
assert executor.generate_unique_id.call_count == 3
def test_multiple_agents_in_orchestration(self, executor_with_multiple_uuids: tuple[Any, Mock, list[str]]) -> None:
@@ -334,12 +334,12 @@ class TestOrchestrationIntegration:
writer = DurableAIAgent(executor, "WriterAgent")
editor = DurableAIAgent(executor, "EditorAgent")
writer_thread = writer.get_new_thread()
editor_thread = editor.get_new_thread()
writer_session = writer.create_session()
editor_session = editor.create_session()
# Call both agents - returns AgentTasks
writer_task = writer.run("Write", thread=writer_thread)
editor_task = editor.run("Edit", thread=editor_thread)
writer_task = writer.run("Write", session=writer_session)
editor_task = editor.run("Edit", session=editor_session)
assert isinstance(writer_task, AgentTask)
assert isinstance(editor_task, AgentTask)
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260210"
version = "1.0.0b260212"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0b260210",
"agent-framework-core>=1.0.0b260212",
"boto3>=1.35.0,<2.0.0",
"botocore>=1.35.0,<2.0.0",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260210"
version = "1.0.0b260212"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0b260210",
"agent-framework-core>=1.0.0b260212",
"openai-chatkit>=1.4.0,<2.0.0",
]
@@ -12,12 +12,13 @@ from agent_framework import (
AgentMiddlewareTypes,
AgentResponse,
AgentResponseUpdate,
AgentThread,
AgentSession,
BaseAgent,
BaseContextProvider,
Content,
ContextProvider,
FunctionTool,
Message,
ResponseStream,
get_logger,
normalize_messages,
)
@@ -184,9 +185,9 @@ class ClaudeAgent(BaseAgent, Generic[OptionsT]):
.. code-block:: python
async with ClaudeAgent() as agent:
thread = agent.get_new_thread()
await agent.run("Remember my name is Alice", thread=thread)
response = await agent.run("What's my name?", thread=thread)
session = agent.create_session()
await agent.run("Remember my name is Alice", session=session)
response = await agent.run("What's my name?", session=session)
# Claude will remember "Alice" from the same session
With Agent Framework tools:
@@ -214,7 +215,7 @@ class ClaudeAgent(BaseAgent, Generic[OptionsT]):
id: str | None = None,
name: str | None = None,
description: str | None = None,
context_provider: ContextProvider | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
middleware: Sequence[AgentMiddlewareTypes] | None = None,
tools: FunctionTool
| Callable[..., Any]
@@ -237,7 +238,7 @@ class ClaudeAgent(BaseAgent, Generic[OptionsT]):
id: Unique identifier for the agent.
name: Name of the agent.
description: Description of the agent.
context_provider: Context provider for the agent.
context_providers: Context providers for the agent.
middleware: List of middleware.
tools: Tools for the agent. Can be:
- Strings for built-in tools (e.g., "Read", "Write", "Bash", "Glob")
@@ -250,7 +251,7 @@ class ClaudeAgent(BaseAgent, Generic[OptionsT]):
id=id,
name=name,
description=description,
context_provider=context_provider,
context_providers=context_providers,
middleware=middleware,
)
@@ -559,7 +560,7 @@ class ClaudeAgent(BaseAgent, Generic[OptionsT]):
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: Literal[True],
thread: AgentThread | None = None,
session: AgentSession | None = None,
options: OptionsT | MutableMapping[str, Any] | None = None,
**kwargs: Any,
) -> AsyncIterable[AgentResponseUpdate]: ...
@@ -570,7 +571,7 @@ class ClaudeAgent(BaseAgent, Generic[OptionsT]):
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: Literal[False] = ...,
thread: AgentThread | None = None,
session: AgentSession | None = None,
options: OptionsT | MutableMapping[str, Any] | None = None,
**kwargs: Any,
) -> AgentResponse[Any]: ...
@@ -580,7 +581,7 @@ class ClaudeAgent(BaseAgent, Generic[OptionsT]):
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
options: OptionsT | MutableMapping[str, Any] | None = None,
**kwargs: Any,
) -> AsyncIterable[AgentResponseUpdate] | Awaitable[AgentResponse[Any]]:
@@ -592,46 +593,36 @@ class ClaudeAgent(BaseAgent, Generic[OptionsT]):
Keyword Args:
stream: If True, returns an async iterable of updates. If False (default),
returns an awaitable AgentResponse.
thread: The conversation thread. If thread has service_thread_id set,
session: The conversation session. If session has service_session_id set,
the agent will resume that session.
options: Runtime options (model, permission_mode can be changed per-request).
kwargs: Additional keyword arguments.
Returns:
When stream=True: An AsyncIterable[AgentResponseUpdate] for streaming updates.
When stream=True: An ResponseStream for streaming updates.
When stream=False: An Awaitable[AgentResponse] with the complete response.
"""
if stream:
return self._run_streaming(messages, thread=thread, options=options, **kwargs)
return self._run_non_streaming(messages, thread=thread, options=options, **kwargs)
async def _run_non_streaming(
self,
messages: str | Message | Sequence[str | Message] | None = None,
*,
thread: AgentThread | None = None,
options: OptionsT | MutableMapping[str, Any] | None = None,
**kwargs: Any,
) -> AgentResponse[Any]:
"""Internal non-streaming implementation."""
thread = thread or self.get_new_thread()
return await AgentResponse.from_update_generator(
self._run_streaming(messages, thread=thread, options=options, **kwargs)
response = ResponseStream(
self._get_stream(messages, session=session, options=options, **kwargs),
finalizer=AgentResponse.from_updates,
)
if stream:
return response
return response.get_final_response()
async def _run_streaming(
async def _get_stream(
self,
messages: str | Message | Sequence[str | Message] | None = None,
*,
thread: AgentThread | None = None,
session: AgentSession | None = None,
options: OptionsT | MutableMapping[str, Any] | None = None,
**kwargs: Any,
) -> AsyncIterable[AgentResponseUpdate]:
"""Internal streaming implementation."""
thread = thread or self.get_new_thread()
session = session or self.create_session()
# Ensure we're connected to the right session
await self._ensure_session(thread.service_thread_id)
await self._ensure_session(session.service_session_id)
if not self._client:
raise ServiceException("Claude SDK client not initialized.")
@@ -696,6 +687,6 @@ class ClaudeAgent(BaseAgent, Generic[OptionsT]):
raise ServiceException(f"Claude API error: {error_msg}")
session_id = message.session_id
# Update thread with session ID
# Update session with session ID
if session_id:
thread.service_thread_id = session_id
session.service_session_id = session_id
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Claude Agent SDK integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260210"
version = "1.0.0b260212"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0b260210",
"agent-framework-core>=1.0.0b260212",
"claude-agent-sdk>=0.1.25",
]
@@ -4,7 +4,7 @@ from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent_framework import AgentResponseUpdate, AgentThread, Content, Message, tool
from agent_framework import AgentResponseUpdate, AgentSession, Content, Message, tool
from agent_framework._settings import load_settings
from agent_framework_claude import ClaudeAgent, ClaudeAgentOptions, ClaudeAgentSettings
@@ -267,12 +267,12 @@ class TestClaudeAgentRun:
with patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client):
agent = ClaudeAgent()
thread = agent.get_new_thread()
await agent.run("Hello", thread=thread)
assert thread.service_thread_id == "test-session-id"
session = agent.create_session()
await agent.run("Hello", session=session)
assert session.service_session_id == "test-session-id"
async def test_run_with_thread(self) -> None:
"""Test run with existing thread."""
async def test_run_with_session(self) -> None:
"""Test run with existing session."""
from claude_agent_sdk import AssistantMessage, ResultMessage, TextBlock
from claude_agent_sdk.types import StreamEvent
@@ -302,9 +302,9 @@ class TestClaudeAgentRun:
with patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client):
agent = ClaudeAgent()
thread = agent.get_new_thread()
thread.service_thread_id = "existing-session"
await agent.run("Hello", thread=thread)
session = agent.create_session()
session.service_session_id = "existing-session"
await agent.run("Hello", session=session)
# region Test ClaudeAgent Run Stream
@@ -440,26 +440,18 @@ class TestClaudeAgentRunStream:
class TestClaudeAgentSessionManagement:
"""Tests for ClaudeAgent session management."""
def test_get_new_thread(self) -> None:
"""Test get_new_thread creates a new thread."""
def test_create_session(self) -> None:
"""Test create_session creates a new session."""
agent = ClaudeAgent()
thread = agent.get_new_thread()
assert isinstance(thread, AgentThread)
assert thread.service_thread_id is None
session = agent.create_session()
assert isinstance(session, AgentSession)
assert session.service_session_id is None
def test_get_new_thread_with_service_thread_id(self) -> None:
"""Test get_new_thread with existing service_thread_id."""
def test_create_session_with_service_session_id(self) -> None:
"""Test create_session with existing service_session_id."""
agent = ClaudeAgent()
thread = agent.get_new_thread(service_thread_id="existing-session-123")
assert isinstance(thread, AgentThread)
assert thread.service_thread_id == "existing-session-123"
def test_thread_inherits_context_provider(self) -> None:
"""Test that thread inherits context provider."""
mock_provider = MagicMock()
agent = ClaudeAgent(context_provider=mock_provider)
thread = agent.get_new_thread()
assert thread.context_provider == mock_provider
session = agent.create_session(session_id="existing-session-123")
assert isinstance(session, AgentSession)
async def test_ensure_session_creates_client(self) -> None:
"""Test _ensure_session creates client when not started."""
@@ -9,10 +9,10 @@ from agent_framework import (
AgentMiddlewareTypes,
AgentResponse,
AgentResponseUpdate,
AgentThread,
AgentSession,
BaseAgent,
BaseContextProvider,
Content,
ContextProvider,
Message,
ResponseStream,
normalize_messages,
@@ -59,7 +59,7 @@ class CopilotStudioAgent(BaseAgent):
id: str | None = None,
name: str | None = None,
description: str | None = None,
context_provider: ContextProvider | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
middleware: list[AgentMiddlewareTypes] | None = None,
environment_id: str | None = None,
agent_identifier: str | None = None,
@@ -87,7 +87,7 @@ class CopilotStudioAgent(BaseAgent):
id: id of the CopilotAgent
name: Name of the CopilotAgent
description: Description of the CopilotAgent
context_provider: Context Provider, to be used by the copilot agent.
context_providers: Context Providers, to be used by the copilot agent.
middleware: Agent middleware used by the agent, should be a list of AgentMiddlewareTypes.
environment_id: Environment ID of the Power Platform environment containing
the Copilot Studio app. Can also be set via COPILOTSTUDIOAGENT__ENVIRONMENTID
@@ -118,7 +118,7 @@ class CopilotStudioAgent(BaseAgent):
id=id,
name=name,
description=description,
context_provider=context_provider,
context_providers=context_providers,
middleware=middleware,
)
if not client:
@@ -190,7 +190,7 @@ class CopilotStudioAgent(BaseAgent):
messages: str | Message | list[str] | list[Message] | None = None,
*,
stream: Literal[False] = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse]: ...
@@ -200,7 +200,7 @@ class CopilotStudioAgent(BaseAgent):
messages: str | Message | list[str] | list[Message] | None = None,
*,
stream: Literal[True],
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> ResponseStream[AgentResponseUpdate, AgentResponse]: ...
@@ -209,7 +209,7 @@ class CopilotStudioAgent(BaseAgent):
messages: str | Message | list[str] | list[Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
"""Get a response from the agent.
@@ -223,7 +223,7 @@ class CopilotStudioAgent(BaseAgent):
Keyword Args:
stream: Whether to stream the response. Defaults to False.
thread: The conversation thread associated with the message(s).
session: The conversation session associated with the message(s).
kwargs: Additional keyword arguments.
Returns:
@@ -231,26 +231,26 @@ class CopilotStudioAgent(BaseAgent):
When stream=True: A ResponseStream of AgentResponseUpdate items.
"""
if stream:
return self._run_stream_impl(messages=messages, thread=thread, **kwargs)
return self._run_impl(messages=messages, thread=thread, **kwargs)
return self._run_stream_impl(messages=messages, session=session, **kwargs)
return self._run_impl(messages=messages, session=session, **kwargs)
async def _run_impl(
self,
messages: str | Message | list[str] | list[Message] | None = None,
*,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> AgentResponse:
"""Non-streaming implementation of run."""
if not thread:
thread = self.get_new_thread()
thread.service_thread_id = await self._start_new_conversation()
if not session:
session = self.create_session()
session.service_session_id = await self._start_new_conversation()
input_messages = normalize_messages(messages)
question = "\n".join([message.text for message in input_messages])
activities = self.client.ask_question(question, thread.service_thread_id)
activities = self.client.ask_question(question, session.service_session_id)
response_messages: list[Message] = []
response_id: str | None = None
@@ -263,22 +263,22 @@ class CopilotStudioAgent(BaseAgent):
self,
messages: str | Message | list[str] | list[Message] | None = None,
*,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> ResponseStream[AgentResponseUpdate, AgentResponse]:
"""Streaming implementation of run."""
async def _stream() -> AsyncIterable[AgentResponseUpdate]:
nonlocal thread
if not thread:
thread = self.get_new_thread()
thread.service_thread_id = await self._start_new_conversation()
nonlocal session
if not session:
session = self.create_session()
session.service_session_id = await self._start_new_conversation()
input_messages = normalize_messages(messages)
question = "\n".join([message.text for message in input_messages])
activities = self.client.ask_question(question, thread.service_thread_id)
activities = self.client.ask_question(question, session.service_session_id)
async for message in self._process_activities(activities, streaming=True):
yield AgentResponseUpdate(
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260210"
version = "1.0.0b260212"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0b260210",
"agent-framework-core>=1.0.0b260212",
"microsoft-agents-copilotstudio-client>=0.3.1",
]
@@ -4,7 +4,7 @@ from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from agent_framework import AgentResponse, AgentResponseUpdate, AgentThread, Content, Message
from agent_framework import AgentResponse, AgentResponseUpdate, AgentSession, Content, Message
from agent_framework.exceptions import ServiceException, ServiceInitializationError
from microsoft_agents.copilotstudio.client import CopilotClient
@@ -165,10 +165,10 @@ class TestCopilotStudioAgent:
assert content.text == "Test response"
assert response.messages[0].role == "assistant"
async def test_run_with_thread(self, mock_copilot_client: MagicMock, mock_activity: MagicMock) -> None:
"""Test run method with existing thread."""
async def test_run_with_session(self, mock_copilot_client: MagicMock, mock_activity: MagicMock) -> None:
"""Test run method with existing session."""
agent = CopilotStudioAgent(client=mock_copilot_client)
thread = AgentThread()
session = AgentSession()
conversation_activity = MagicMock()
conversation_activity.conversation.id = "test-conversation-id"
@@ -176,11 +176,11 @@ class TestCopilotStudioAgent:
mock_copilot_client.start_conversation.return_value = create_async_generator([conversation_activity])
mock_copilot_client.ask_question.return_value = create_async_generator([mock_activity])
response = await agent.run("test message", thread=thread)
response = await agent.run("test message", session=session)
assert isinstance(response, AgentResponse)
assert len(response.messages) == 1
assert thread.service_thread_id == "test-conversation-id"
assert session.service_session_id == "test-conversation-id"
async def test_run_start_conversation_failure(self, mock_copilot_client: MagicMock) -> None:
"""Test run method when conversation start fails."""
@@ -217,10 +217,10 @@ class TestCopilotStudioAgent:
assert response_count == 1
async def test_run_streaming_with_thread(self, mock_copilot_client: MagicMock) -> None:
"""Test run(stream=True) method with existing thread."""
async def test_run_streaming_with_session(self, mock_copilot_client: MagicMock) -> None:
"""Test run(stream=True) method with existing session."""
agent = CopilotStudioAgent(client=mock_copilot_client)
thread = AgentThread()
session = AgentSession()
conversation_activity = MagicMock()
conversation_activity.conversation.id = "test-conversation-id"
@@ -235,7 +235,7 @@ class TestCopilotStudioAgent:
mock_copilot_client.ask_question.return_value = create_async_generator([typing_activity])
response_count = 0
async for response in agent.run("test message", thread=thread, stream=True):
async for response in agent.run("test message", session=session, stream=True):
assert isinstance(response, AgentResponseUpdate)
content = response.contents[0]
assert content.type == "text"
@@ -243,7 +243,7 @@ class TestCopilotStudioAgent:
response_count += 1
assert response_count == 1
assert thread.service_thread_id == "test-conversation-id"
assert session.service_session_id == "test-conversation-id"
async def test_run_streaming_no_typing_activity(self, mock_copilot_client: MagicMock) -> None:
"""Test run(stream=True) method with non-typing activity."""
+6 -11
View File
@@ -12,8 +12,7 @@ agent_framework/
├── _types.py # Core types (Message, ChatResponse, Content, etc.)
├── _tools.py # Tool definitions and function invocation
├── _middleware.py # Middleware system for request/response interception
├── _threads.py # AgentThread and message store abstractions
├── _memory.py # Context providers for memory/RAG
├── _sessions.py # AgentSession and context provider abstractions
├── _mcp.py # Model Context Protocol support
├── _workflows/ # Workflow orchestration (sequential, concurrent, handoff, etc.)
├── openai/ # Built-in OpenAI client
@@ -57,16 +56,12 @@ agent_framework/
- **`FunctionMiddleware`** - Intercepts function/tool invocations
- **`AgentContext`** / **`ChatContext`** / **`FunctionInvocationContext`** - Context objects passed through middleware
### Threads (`_threads.py`)
### Sessions (`_sessions.py`)
- **`AgentThread`** - Manages conversation history for an agent
- **`ChatMessageStoreProtocol`** - Protocol for persistent message storage
- **`ChatMessageStore`** - Default in-memory implementation
### Memory (`_memory.py`)
- **`ContextProvider`** - Protocol for providing additional context to agents (RAG, memory systems)
- **`Context`** - Container for context data
- **`AgentSession`** - Manages conversation state and session metadata
- **`SessionContext`** - Context object for session-scoped data during agent runs
- **`BaseContextProvider`** - Base class for context providers (RAG, memory systems)
- **`BaseHistoryProvider`** - Base class for conversation history storage
### Workflows (`_workflows/`)
+1 -1
View File
@@ -213,7 +213,7 @@ if __name__ == "__main__":
asyncio.run(main())
```
**Note**: Sequential, Concurrent, Group Chat, Handoff, and Magentic orchestrations are available. See examples in [orchestration samples](../../samples/02-agents/orchestrations).
**Note**: Sequential, Concurrent, Group Chat, Handoff, and Magentic orchestrations are available. See examples in [orchestration samples](../../samples/03-workflows/orchestrations).
## More Examples & Samples
@@ -13,10 +13,9 @@ from ._agents import * # noqa: F403
from ._clients import * # noqa: F403
from ._logging import * # noqa: F403
from ._mcp import * # noqa: F403
from ._memory import * # noqa: F403
from ._middleware import * # noqa: F403
from ._sessions import * # noqa: F403
from ._telemetry import * # noqa: F403
from ._threads import * # noqa: F403
from ._tools import * # noqa: F403
from ._types import * # noqa: F403
from ._workflows import * # noqa: F403
+239 -290
View File
@@ -31,10 +31,9 @@ from pydantic import BaseModel, Field, create_model
from ._clients import BaseChatClient, SupportsChatGetResponse
from ._logging import get_logger
from ._mcp import LOG_LEVEL_MAPPING, MCPTool
from ._memory import Context, ContextProvider
from ._middleware import AgentMiddlewareLayer, MiddlewareTypes
from ._serialization import SerializationMixin
from ._threads import AgentThread, ChatMessageStoreProtocol
from ._sessions import AgentSession, BaseContextProvider, BaseHistoryProvider, InMemoryHistoryProvider, SessionContext
from ._tools import (
FunctionInvocationLayer,
FunctionTool,
@@ -49,7 +48,7 @@ from ._types import (
map_chat_to_agent_update,
normalize_messages,
)
from .exceptions import AgentExecutionException, AgentInitializationError
from .exceptions import AgentExecutionException
from .observability import AgentTelemetryLayer
if sys.version_info >= (3, 13):
@@ -57,9 +56,9 @@ if sys.version_info >= (3, 13):
else:
from typing_extensions import TypeVar # type: ignore # pragma: no cover
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
pass # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
pass # type: ignore[import] # pragma: no cover
if sys.version_info >= (3, 11):
from typing import Self, TypedDict # pragma: no cover
else:
@@ -68,14 +67,9 @@ else:
if TYPE_CHECKING:
from ._types import ChatOptions
ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None, covariant=True)
ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel)
logger = get_logger("agent_framework")
ThreadTypeT = TypeVar("ThreadTypeT", bound="AgentThread")
ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel)
OptionsCoT = TypeVar(
"OptionsCoT",
bound=TypedDict, # type: ignore[valid-type]
@@ -155,9 +149,10 @@ def _sanitize_agent_name(agent_name: str | None) -> str | None:
class _RunContext(TypedDict):
thread: AgentThread
session: AgentSession | None
session_context: SessionContext
input_messages: list[Message]
thread_messages: list[Message]
session_messages: list[Message]
agent_name: str
chat_options: dict[str, Any]
filtered_kwargs: dict[str, Any]
@@ -197,7 +192,7 @@ class SupportsAgentRun(Protocol):
self.name = "Custom Agent"
self.description = "A fully custom agent implementation"
async def run(self, messages=None, *, stream=False, thread=None, **kwargs):
async def run(self, messages=None, *, stream=False, session=None, **kwargs):
if stream:
# Your custom streaming implementation
async def _stream():
@@ -212,9 +207,15 @@ class SupportsAgentRun(Protocol):
return AgentResponse(messages=[], response_id="custom-response")
def get_new_thread(self, **kwargs):
# Return your own thread implementation
return {"id": "custom-thread", "messages": []}
def create_session(self, **kwargs):
from agent_framework import AgentSession
return AgentSession(**kwargs)
def get_session(self, *, service_session_id, **kwargs):
from agent_framework import AgentSession
return AgentSession(service_session_id=service_session_id, **kwargs)
# Verify the instance satisfies the protocol
@@ -232,7 +233,7 @@ class SupportsAgentRun(Protocol):
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: Literal[False] = ...,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]]:
"""Get a response from the agent (non-streaming)."""
@@ -244,7 +245,7 @@ class SupportsAgentRun(Protocol):
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: Literal[True],
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]:
"""Get a streaming response from the agent."""
@@ -255,7 +256,7 @@ class SupportsAgentRun(Protocol):
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]:
"""Get a response from the agent.
@@ -269,7 +270,7 @@ class SupportsAgentRun(Protocol):
Keyword Args:
stream: Whether to stream the response. Defaults to False.
thread: The conversation thread associated with the message(s).
session: The conversation session associated with the message(s).
kwargs: Additional keyword arguments.
Returns:
@@ -279,8 +280,12 @@ class SupportsAgentRun(Protocol):
"""
...
def get_new_thread(self, **kwargs: Any) -> AgentThread:
"""Creates a new conversation thread for the agent."""
def create_session(self, **kwargs: Any) -> AgentSession:
"""Creates a new conversation session."""
...
def get_session(self, *, service_session_id: str, **kwargs: Any) -> AgentSession:
"""Gets or creates a session for a service-managed session ID."""
...
@@ -294,7 +299,7 @@ class BaseAgent(SerializationMixin):
For most use cases, prefer :class:`Agent` which includes all standard layers.
This class provides core functionality for agent implementations, including
context providers, middleware support, and thread management.
context providers, middleware support, and session management.
Note:
BaseAgent cannot be instantiated directly as it doesn't implement the
@@ -304,12 +309,12 @@ class BaseAgent(SerializationMixin):
Examples:
.. code-block:: python
from agent_framework import BaseAgent, AgentThread, AgentResponse
from agent_framework import BaseAgent, AgentSession, AgentResponse
# Create a concrete subclass that implements the protocol
class SimpleAgent(BaseAgent):
async def run(self, messages=None, *, stream=False, thread=None, **kwargs):
async def run(self, messages=None, *, stream=False, session=None, **kwargs):
if stream:
async def _stream():
@@ -345,7 +350,7 @@ class BaseAgent(SerializationMixin):
id: str | None = None,
name: str | None = None,
description: str | None = None,
context_provider: ContextProvider | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
additional_properties: MutableMapping[str, Any] | None = None,
**kwargs: Any,
@@ -357,7 +362,7 @@ class BaseAgent(SerializationMixin):
a new UUID will be generated.
name: The name of the agent, can be None.
description: The description of the agent.
context_provider: The context provider to include during agent invocation.
context_providers: Context providers to include during agent invocation.
middleware: List of middleware.
additional_properties: Additional properties set on the agent.
kwargs: Additional keyword arguments (merged into additional_properties).
@@ -367,7 +372,7 @@ class BaseAgent(SerializationMixin):
self.id = id
self.name = name
self.description = description
self.context_provider = context_provider
self.context_providers: list[BaseContextProvider] = list(context_providers or [])
self.middleware: list[MiddlewareTypes] | None = (
cast(list[MiddlewareTypes], middleware) if middleware is not None else None
)
@@ -376,56 +381,53 @@ class BaseAgent(SerializationMixin):
self.additional_properties: dict[str, Any] = cast(dict[str, Any], additional_properties or {})
self.additional_properties.update(kwargs)
async def _notify_thread_of_new_messages(
self,
thread: AgentThread,
input_messages: Message | Sequence[Message],
response_messages: Message | Sequence[Message],
**kwargs: Any,
) -> None:
"""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.
**kwargs: Any extra arguments to pass from the agent run.
"""
if isinstance(input_messages, Message) or len(input_messages) > 0:
await thread.on_new_messages(input_messages)
if isinstance(response_messages, Message) or len(response_messages) > 0:
await thread.on_new_messages(response_messages)
if thread.context_provider:
await thread.context_provider.invoked(input_messages, response_messages, **kwargs)
def get_new_thread(self, **kwargs: Any) -> AgentThread:
"""Return a new AgentThread instance that is compatible with the agent.
Keyword 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:
"""Deserialize a thread from its serialized state.
Args:
serialized_thread: The serialized thread data.
def create_session(self, *, session_id: str | None = None, **kwargs: Any) -> AgentSession:
"""Create a new lightweight session.
Keyword Args:
session_id: Optional session ID (generated if not provided).
kwargs: Additional keyword arguments.
Returns:
A new AgentThread instance restored from the serialized state.
A new AgentSession instance.
"""
thread: AgentThread = self.get_new_thread()
await thread.update_from_thread_state(serialized_thread, **kwargs)
return thread
return AgentSession(session_id=session_id)
def get_session(self, *, service_session_id: str, session_id: str | None = None, **kwargs: Any) -> AgentSession:
"""Get or create a session for a service-managed session ID.
Args:
service_session_id: The service-managed session ID.
Keyword Args:
session_id: Optional local session ID (generated if not provided).
kwargs: Additional keyword arguments.
Returns:
A new AgentSession instance with service_session_id set.
"""
return AgentSession(session_id=session_id, service_session_id=service_session_id)
async def _run_after_providers(
self,
*,
session: AgentSession | None,
context: SessionContext,
) -> None:
"""Run after_run on all context providers in reverse order.
Keyword Args:
session: The conversation session.
context: The invocation context with response populated.
"""
state = session.state if session else {}
for provider in reversed(self.context_providers):
await provider.after_run(
agent=self, # type: ignore[arg-type]
session=session, # type: ignore[arg-type]
context=context,
state=state,
)
def as_tool(
self,
@@ -621,8 +623,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
| Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any] | Any]
| None = None,
default_options: OptionsCoT | None = None,
chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None,
context_provider: ContextProvider | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
**kwargs: Any,
) -> None:
"""Initialize a Agent instance.
@@ -636,9 +637,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
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.
If not provided, the default in-memory store will be used.
context_provider: The context providers to include during agent invocation.
context_providers: Context providers to include during agent invocation.
middleware: List of middleware to intercept agent and function invocations.
default_options: A TypedDict containing chat options. When using a typed agent like
``Agent[OpenAIChatOptions]``, this enables IDE autocomplete for
@@ -649,19 +648,8 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
These can be overridden at runtime via the ``options`` parameter of ``run()``.
tools: The tools to use for the request.
kwargs: Any additional keyword arguments. Will be stored as ``additional_properties``.
Raises:
AgentInitializationError: If both conversation_id and chat_message_store_factory are provided.
"""
# Extract conversation_id from options for validation
opts = dict(default_options) if default_options else {}
conversation_id = opts.get("conversation_id")
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 isinstance(client, FunctionInvocationLayer) and isinstance(client, BaseChatClient):
logger.warning(
@@ -672,11 +660,10 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
id=id,
name=name,
description=description,
context_provider=context_provider,
context_providers=context_providers,
**kwargs,
)
self.client = client
self.chat_message_store_factory = chat_message_store_factory
# Get tools from options or named parameter (named param takes precedence)
tools_ = tools if tools is not None else opts.pop("tools", None)
@@ -704,7 +691,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
self.default_options: dict[str, Any] = {
"model_id": opts.pop("model_id", None) or (getattr(self.client, "model_id", None)),
"allow_multiple_tool_calls": opts.pop("allow_multiple_tool_calls", None),
"conversation_id": conversation_id,
"conversation_id": opts.pop("conversation_id", None),
"frequency_penalty": opts.pop("frequency_penalty", None),
"instructions": instructions_,
"logit_bias": opts.pop("logit_bias", None),
@@ -779,7 +766,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: Literal[False] = ...,
thread: AgentThread | None = None,
session: AgentSession | None = None,
tools: FunctionTool
| Callable[..., Any]
| MutableMapping[str, Any]
@@ -796,7 +783,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: Literal[False] = ...,
thread: AgentThread | None = None,
session: AgentSession | None = None,
tools: FunctionTool
| Callable[..., Any]
| MutableMapping[str, Any]
@@ -813,7 +800,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: Literal[True],
thread: AgentThread | None = None,
session: AgentSession | None = None,
tools: FunctionTool
| Callable[..., Any]
| MutableMapping[str, Any]
@@ -829,7 +816,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
tools: FunctionTool
| Callable[..., Any]
| MutableMapping[str, Any]
@@ -852,7 +839,9 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
stream: Whether to stream the response. Defaults to False.
Keyword Args:
thread: The thread to use for the agent.
session: The session to use for the agent.
If None, and no settings for the chat client that indicate otherwise,
the run will be stateless.
tools: The tools to use for this specific run (merged with default tools).
options: A TypedDict containing chat options. When using a typed agent like
``Agent[OpenAIChatOptions]``, this enables IDE autocomplete for
@@ -871,13 +860,13 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
async def _run_non_streaming() -> AgentResponse[Any]:
ctx = await self._prepare_run_context(
messages=messages,
thread=thread,
session=session,
tools=tools,
options=options,
kwargs=kwargs,
)
response = await self.client.get_response( # type: ignore[call-overload]
messages=ctx["thread_messages"],
messages=ctx["session_messages"],
stream=False,
options=ctx["chat_options"],
**ctx["filtered_kwargs"],
@@ -886,12 +875,11 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
if not response:
raise AgentExecutionException("Chat client did not return a response.")
await self._finalize_response_and_update_thread(
await self._finalize_response(
response=response,
agent_name=ctx["agent_name"],
thread=ctx["thread"],
input_messages=ctx["input_messages"],
kwargs=ctx["finalize_kwargs"],
session=ctx["session"],
session_context=ctx["session_context"],
)
response_format = ctx["chat_options"].get("response_format")
if not (
@@ -923,33 +911,41 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
if ctx is None:
return # No context available (shouldn't happen in normal flow)
# Update thread with conversation_id
await self._update_thread_with_type_and_conversation_id(ctx["thread"], response.response_id)
# Update thread with conversation_id derived from streaming raw updates.
# Using response_id here can break function-call continuation for APIs
# where response IDs are not valid conversation handles.
conversation_id = self._extract_conversation_id_from_streaming_response(response)
# Ensure author names are set for all messages
for message in response.messages:
if message.author_name is None:
message.author_name = ctx["agent_name"]
# Notify thread of new messages
await self._notify_thread_of_new_messages(
ctx["thread"],
ctx["input_messages"],
response.messages,
**{k: v for k, v in ctx["finalize_kwargs"].items() if k != "thread"},
# Propagate conversation_id back to session from streaming updates.
# For Responses-style APIs this can rotate every turn (response_id-based continuation),
# so refresh when a newer value is returned.
sess = ctx["session"]
if sess and conversation_id and sess.service_session_id != conversation_id:
sess.service_session_id = conversation_id
# Run after_run providers (reverse order)
session_context = ctx["session_context"]
session_context._response = AgentResponse( # type: ignore[assignment]
messages=response.messages,
response_id=response.response_id,
)
await self._run_after_providers(session=ctx["session"], context=session_context)
async def _get_stream() -> ResponseStream[ChatResponseUpdate, ChatResponse]:
ctx_holder["ctx"] = await self._prepare_run_context(
messages=messages,
thread=thread,
session=session,
tools=tools,
options=options,
kwargs=kwargs,
)
ctx: _RunContext = ctx_holder["ctx"] # type: ignore[assignment] # Safe: we just assigned it
return self.client.get_response( # type: ignore[call-overload, no-any-return]
messages=ctx["thread_messages"],
messages=ctx["session_messages"],
stream=True,
options=ctx["chat_options"],
**ctx["filtered_kwargs"],
@@ -980,11 +976,32 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
output_format_type = response_format if isinstance(response_format, type) else None
return AgentResponse.from_updates(updates, output_format_type=output_format_type)
@staticmethod
def _extract_conversation_id_from_streaming_response(response: AgentResponse[Any]) -> str | None:
"""Extract conversation_id from streaming raw updates, if present."""
raw = response.raw_representation
if raw is None:
return None
raw_items: list[Any] = raw if isinstance(raw, list) else [raw]
for item in reversed(raw_items):
if isinstance(item, Mapping):
value = item.get("conversation_id")
if isinstance(value, str) and value:
return value
continue
value = getattr(item, "conversation_id", None)
if isinstance(value, str) and value:
return value
return None
async def _prepare_run_context(
self,
*,
messages: str | Message | Sequence[str | Message] | None,
thread: AgentThread | None,
session: AgentSession | None,
tools: FunctionTool
| Callable[..., Any]
| MutableMapping[str, Any]
@@ -1000,8 +1017,23 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
tools_ = tools if tools is not None else opts.pop("tools", None)
input_messages = normalize_messages(messages)
thread, run_chat_options, thread_messages = await self._prepare_thread_and_messages(
thread=thread, input_messages=input_messages, **kwargs
# Auto-inject InMemoryHistoryProvider when session is provided, no context providers
# registered, and no service-side storage indicators
if (
session is not None
and not self.context_providers
and not session.service_session_id
and not opts.get("conversation_id")
and not opts.get("store")
and not (getattr(self.client, "STORES_BY_DEFAULT", False) and opts.get("store") is not False)
):
self.context_providers.append(InMemoryHistoryProvider("memory"))
session_context, chat_options = await self._prepare_session_and_messages(
session=session,
input_messages=input_messages,
options=opts,
)
# Normalize tools
@@ -1028,7 +1060,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
# Build options dict from run() options merged with provided options
run_opts: dict[str, Any] = {
"model_id": opts.pop("model_id", None),
"conversation_id": thread.service_thread_id,
"conversation_id": session.service_session_id if session else opts.pop("conversation_id", None),
"allow_multiple_tool_calls": opts.pop("allow_multiple_tool_calls", None),
"additional_function_arguments": opts.pop("additional_function_arguments", None),
"frequency_penalty": opts.pop("frequency_penalty", None),
@@ -1049,103 +1081,131 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
}
# Remove None values and merge with chat_options
run_opts = {k: v for k, v in run_opts.items() if v is not None}
co = _merge_options(run_chat_options, run_opts)
co = _merge_options(chat_options, run_opts)
# Ensure thread is forwarded in kwargs for tool invocation
# Build session_messages from session context: context messages + input messages
session_messages: list[Message] = session_context.get_messages(include_input=True)
# Ensure session is forwarded in kwargs for tool invocation
finalize_kwargs = dict(kwargs)
finalize_kwargs["thread"] = thread
finalize_kwargs["session"] = session
# Filter chat_options from kwargs to prevent duplicate keyword argument
filtered_kwargs = {k: v for k, v in finalize_kwargs.items() if k != "chat_options"}
return {
"thread": thread,
"session": session,
"session_context": session_context,
"input_messages": input_messages,
"thread_messages": thread_messages,
"session_messages": session_messages,
"agent_name": agent_name,
"chat_options": co,
"filtered_kwargs": filtered_kwargs,
"finalize_kwargs": finalize_kwargs,
}
async def _finalize_response_and_update_thread(
async def _finalize_response(
self,
response: ChatResponse,
agent_name: str,
thread: AgentThread,
input_messages: list[Message],
kwargs: dict[str, Any],
session: AgentSession | None,
session_context: SessionContext,
) -> None:
"""Finalize response by updating thread and setting author names.
"""Finalize response by setting author names and running after_run providers.
Args:
response: The chat response to finalize.
agent_name: The name of the agent to set as author.
thread: The conversation thread.
input_messages: The input messages.
kwargs: Additional keyword arguments.
session: The conversation session.
session_context: The invocation context.
"""
await self._update_thread_with_type_and_conversation_id(thread, response.conversation_id)
# Ensure that the author name is set for each message in the response.
for message in response.messages:
if message.author_name is None:
message.author_name = agent_name
# Only notify the thread of new messages if the chatResponse was successful
# to avoid inconsistent messages state in the thread.
await self._notify_thread_of_new_messages(
thread,
input_messages,
response.messages,
**{k: v for k, v in kwargs.items() if k != "thread"},
# Propagate conversation_id back to session (e.g. thread ID from Assistants API).
# For Responses-style APIs this can rotate every turn (response_id-based continuation),
# so refresh when a newer value is returned.
if session and response.conversation_id and session.service_session_id != response.conversation_id:
session.service_session_id = response.conversation_id
# Set the response on the context for after_run providers
session_context._response = AgentResponse( # type: ignore[assignment]
messages=response.messages,
response_id=response.response_id,
)
@override
def get_new_thread(
# Run after_run providers (reverse order)
await self._run_after_providers(session=session, context=session_context)
async def _prepare_session_and_messages(
self,
*,
service_thread_id: str | None = None,
**kwargs: Any,
) -> AgentThread:
"""Get a new conversation thread for the agent.
session: AgentSession | None,
input_messages: list[Message] | None = None,
options: dict[str, Any] | None = None,
) -> tuple[SessionContext, dict[str, Any]]:
"""Prepare the session context and messages for agent execution.
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 include a thread_id and that will be set.
Otherwise a message store is created from the default factory.
Runs the before_run pipeline on all context providers and assembles
the chat options from default options and provider-contributed context.
Keyword Args:
service_thread_id: Optional service managed thread ID.
kwargs: Not used at present.
session: The conversation session (None for stateless invocation).
input_messages: Messages to process.
options: Runtime options dict (already copied, safe to mutate).
Returns:
A new AgentThread instance.
A tuple containing:
- The SessionContext with provider context populated
- The merged chat options dict
"""
if service_thread_id is not None:
return AgentThread(
service_thread_id=service_thread_id,
context_provider=self.context_provider,
# Create a shallow copy of options and deep copy non-tool values
if self.default_options:
chat_options: dict[str, Any] = {}
for key, value in self.default_options.items():
if key == "tools":
chat_options[key] = list(value) if value else []
else:
chat_options[key] = deepcopy(value)
else:
chat_options = {}
session_context = SessionContext(
session_id=session.session_id if session else None,
service_session_id=session.service_session_id if session else None,
input_messages=input_messages or [],
options=options or {},
)
# Run before_run providers (forward order, skip BaseHistoryProvider with load_messages=False)
state = session.state if session else {}
for provider in self.context_providers:
if isinstance(provider, BaseHistoryProvider) and not provider.load_messages:
continue
await provider.before_run(
agent=self, # type: ignore[arg-type]
session=session, # type: ignore[arg-type]
context=session_context,
state=state,
)
if self.default_options.get("conversation_id") is not None:
return AgentThread(
service_thread_id=self.default_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(),
context_provider=self.context_provider,
)
return AgentThread(context_provider=self.context_provider)
# Merge provider-contributed tools into chat_options
if session_context.tools:
if chat_options.get("tools") is not None:
chat_options["tools"].extend(session_context.tools)
else:
chat_options["tools"] = list(session_context.tools)
# Merge provider-contributed instructions into chat_options
if session_context.instructions:
combined_instructions = "\n".join(session_context.instructions)
if "instructions" in chat_options:
chat_options["instructions"] = f"{chat_options['instructions']}\n{combined_instructions}"
else:
chat_options["instructions"] = combined_instructions
return session_context, chat_options
def as_mcp_server(
self,
@@ -1256,115 +1316,6 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
return server
async def _update_thread_with_type_and_conversation_id(
self, thread: AgentThread, response_conversation_id: str | None
) -> None:
"""Update thread with storage type and conversation ID.
Args:
thread: The thread to update.
response_conversation_id: The conversation ID from the response, if any.
Raises:
AgentExecutionException: If conversation ID is missing for service-managed thread.
"""
if response_conversation_id is None and thread.service_thread_id is not None:
# We were passed a thread that is service managed, but we got no conversation id back from the chat client,
# meaning the service doesn't support service managed threads,
# so the thread cannot be used with this service.
raise AgentExecutionException(
"Service did not return a valid conversation id when using a service managed thread."
)
if response_conversation_id is not None:
# If we got a conversation id back from the chat client, it means that the service
# supports server side thread storage so we should update the thread with the new id.
thread.service_thread_id = response_conversation_id
if thread.context_provider:
await thread.context_provider.thread_created(thread.service_thread_id)
elif thread.message_store is None and self.chat_message_store_factory is not None:
# If the service doesn't use service side thread storage (i.e. we got no id back from invocation), and
# the thread has no message_store yet, and we have a custom messages store, we should update the thread
# with the custom message_store so that it has somewhere to store the chat history.
thread.message_store = self.chat_message_store_factory()
async def _prepare_thread_and_messages(
self,
*,
thread: AgentThread | None,
input_messages: list[Message] | None = None,
**kwargs: Any,
) -> tuple[AgentThread, dict[str, Any], list[Message]]:
"""Prepare the thread and messages for agent execution.
This method prepares the conversation thread, merges context provider data,
and assembles the final message list for the chat client.
Keyword Args:
thread: The conversation thread.
input_messages: Messages to process.
**kwargs: Any extra arguments to pass from the agent run.
Returns:
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 conversation IDs on the thread and agent don't match.
"""
# Create a shallow copy of options and deep copy non-tool values
# Tools containing HTTP clients or other non-copyable objects cannot be deep copied
if self.default_options:
chat_options: dict[str, Any] = {}
for key, value in self.default_options.items():
if key == "tools":
# Keep tool references as-is (don't deep copy)
chat_options[key] = list(value) if value else []
else:
# Deep copy other options to prevent mutation
chat_options[key] = deepcopy(value)
else:
chat_options = {}
thread = thread or self.get_new_thread()
if thread.service_thread_id and thread.context_provider:
await thread.context_provider.thread_created(thread.service_thread_id)
thread_messages: list[Message] = []
if thread.message_store:
thread_messages.extend(await thread.message_store.list_messages() or [])
context: Context | None = None
if self.context_provider:
# Note: We don't use 'async with' here because the context provider's lifecycle
# should be managed by the user (via async with) or persist across multiple invocations.
# Using async with here would close resources (like retrieval clients) after each query.
context = await self.context_provider.invoking(input_messages or [], **kwargs)
if context:
if context.messages:
thread_messages.extend(context.messages)
if context.tools:
if chat_options.get("tools") is not None:
chat_options["tools"].extend(context.tools)
else:
chat_options["tools"] = list(context.tools)
if context.instructions:
chat_options["instructions"] = (
context.instructions
if "instructions" not in chat_options
else f"{chat_options['instructions']}\n{context.instructions}"
)
thread_messages.extend(input_messages or [])
if (
thread.service_thread_id
and chat_options.get("conversation_id")
and thread.service_thread_id != chat_options["conversation_id"]
):
raise AgentExecutionException(
"The conversation_id set on the agent is different from the one set on the thread, "
"only one ID can be used for a run."
)
return thread, chat_options, thread_messages
def _get_agent_name(self) -> str:
"""Get the agent name for message attribution.
@@ -1404,8 +1355,7 @@ class Agent(
| Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any] | Any]
| None = None,
default_options: OptionsCoT | None = None,
chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None,
context_provider: ContextProvider | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
**kwargs: Any,
) -> None:
@@ -1418,8 +1368,7 @@ class Agent(
description=description,
tools=tools,
default_options=default_options,
chat_message_store_factory=chat_message_store_factory,
context_provider=context_provider,
context_providers=context_providers,
middleware=middleware,
**kwargs,
)
@@ -28,9 +28,7 @@ from typing import (
from pydantic import BaseModel
from ._logging import get_logger
from ._memory import ContextProvider
from ._serialization import SerializationMixin
from ._threads import ChatMessageStoreProtocol
from ._tools import (
FunctionInvocationConfiguration,
FunctionTool,
@@ -264,7 +262,15 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]):
OTEL_PROVIDER_NAME: ClassVar[str] = "unknown"
DEFAULT_EXCLUDE: ClassVar[set[str]] = {"additional_properties"}
# This is used for OTel setup, should be overridden in subclasses
STORES_BY_DEFAULT: ClassVar[bool] = False
"""Whether this client stores conversation history server-side by default.
Clients that use server-side storage (e.g., OpenAI Responses API with ``store=True``
as default, Azure AI Agent sessions) should override this to ``True``.
When ``True``, the agent skips auto-injecting ``InMemoryHistoryProvider`` unless the
user explicitly sets ``store=False``.
"""
# OTEL_PROVIDER_NAME is used for OTel setup, should be overridden in subclasses
def __init__(
self,
@@ -448,8 +454,7 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]):
| Sequence[FunctionTool | Callable[..., Any] | MutableMapping[str, Any]]
| None = None,
default_options: OptionsCoT | Mapping[str, Any] | None = None,
chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None,
context_provider: ContextProvider | None = None,
context_providers: Sequence[Any] | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
**kwargs: Any,
@@ -471,9 +476,7 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]):
including temperature, max_tokens, model_id, tool_choice, and more.
Note: response_format typing does not flow into run outputs when set via default_options,
and dict literals are accepted without specialized option typing.
chat_message_store_factory: Factory function to create an instance of ChatMessageStoreProtocol.
If not provided, the default in-memory store will be used.
context_provider: Context providers to include during agent invocation.
context_providers: Context providers to include during agent invocation.
middleware: List of middleware to intercept agent and function invocations.
function_invocation_configuration: Optional function invocation configuration override.
kwargs: Any additional keyword arguments. Will be stored as ``additional_properties``.
@@ -509,8 +512,7 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]):
instructions=instructions,
tools=tools,
default_options=cast(Any, default_options),
chat_message_store_factory=chat_message_store_factory,
context_provider=context_provider,
context_providers=context_providers,
middleware=middleware,
function_invocation_configuration=function_invocation_configuration,
**kwargs,
+60 -26
View File
@@ -102,21 +102,31 @@ def _parse_prompt_result_from_mcp(
if isinstance(content, types.TextContent):
parts.append(content.text)
elif isinstance(content, (types.ImageContent, types.AudioContent)):
parts.append(json.dumps({
"type": "image" if isinstance(content, types.ImageContent) else "audio",
"data": content.data,
"mimeType": content.mimeType,
}, default=str))
parts.append(
json.dumps(
{
"type": "image" if isinstance(content, types.ImageContent) else "audio",
"data": content.data,
"mimeType": content.mimeType,
},
default=str,
)
)
elif isinstance(content, types.EmbeddedResource):
match content.resource:
case types.TextResourceContents():
parts.append(content.resource.text)
case types.BlobResourceContents():
parts.append(json.dumps({
"type": "blob",
"data": content.resource.blob,
"mimeType": content.resource.mimeType,
}, default=str))
parts.append(
json.dumps(
{
"type": "blob",
"data": content.resource.blob,
"mimeType": content.resource.mimeType,
},
default=str,
)
)
else:
parts.append(str(content))
if not parts:
@@ -159,27 +169,42 @@ def _parse_tool_result_from_mcp(
case types.TextContent():
parts.append(item.text)
case types.ImageContent() | types.AudioContent():
parts.append(json.dumps({
"type": "image" if isinstance(item, types.ImageContent) else "audio",
"data": item.data,
"mimeType": item.mimeType,
}, default=str))
parts.append(
json.dumps(
{
"type": "image" if isinstance(item, types.ImageContent) else "audio",
"data": item.data,
"mimeType": item.mimeType,
},
default=str,
)
)
case types.ResourceLink():
parts.append(json.dumps({
"type": "resource_link",
"uri": str(item.uri),
"mimeType": item.mimeType,
}, default=str))
parts.append(
json.dumps(
{
"type": "resource_link",
"uri": str(item.uri),
"mimeType": item.mimeType,
},
default=str,
)
)
case types.EmbeddedResource():
match item.resource:
case types.TextResourceContents():
parts.append(item.resource.text)
case types.BlobResourceContents():
parts.append(json.dumps({
"type": "blob",
"data": item.resource.blob,
"mimeType": item.resource.mimeType,
}, default=str))
parts.append(
json.dumps(
{
"type": "blob",
"data": item.resource.blob,
"mimeType": item.resource.mimeType,
},
default=str,
)
)
case _:
parts.append(str(item))
if not parts:
@@ -847,7 +872,16 @@ class MCPTool:
k: v
for k, v in kwargs.items()
if k
not in {"chat_options", "tools", "tool_choice", "thread", "conversation_id", "options", "response_format"}
not in {
"chat_options",
"tools",
"tool_choice",
"session",
"thread",
"conversation_id",
"options",
"response_format",
}
}
parser = self.parse_tool_results or _parse_tool_result_from_mcp
@@ -1,181 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import sys
from abc import ABC, abstractmethod
from collections.abc import MutableSequence, Sequence
from types import TracebackType
from typing import TYPE_CHECKING, Any, Final
from ._types import Message
if TYPE_CHECKING:
from ._tools import FunctionTool
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
else:
from typing_extensions import Self # pragma: no cover
# region Context
__all__ = ["Context", "ContextProvider"]
class Context:
"""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, Message
# Create context with instructions
context = Context(
instructions="Use a professional tone when responding.",
messages=[Message(content="Previous context", role="user")],
tools=[my_tool],
)
# Access context properties
print(context.instructions)
print(len(context.messages))
"""
def __init__(
self,
instructions: str | None = None,
messages: Sequence[Message] | None = None,
tools: Sequence[FunctionTool] | None = None,
):
"""Create a new Context object.
Args:
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[Message] = messages or []
self.tools: Sequence[FunctionTool] = tools or []
# region ContextProvider
class ContextProvider(ABC):
"""Base class for all context providers.
A context provider is a component that can be used to enhance the AI's context management.
It can listen to changes in the conversation and provide additional context to the AI model
just before invocation.
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, Message
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 = Agent(client=client, name="assistant", context_provider=provider)
"""
# Default prompt to be used by all context providers when assembling memories/instructions
DEFAULT_CONTEXT_PROMPT: Final[str] = "## Memories\nConsider the following memories when answering user questions:"
async def thread_created(self, thread_id: str | None) -> None:
"""Called just after a new thread is created.
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.
"""
pass
async def invoked(
self,
request_messages: Message | Sequence[Message],
response_messages: Message | Sequence[Message] | None = None,
invoke_exception: Exception | None = None,
**kwargs: Any,
) -> 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.
Args:
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.
Keyword Args:
kwargs: Additional keyword arguments (not used at present).
"""
pass
@abstractmethod
async def invoking(self, messages: Message | MutableSequence[Message], **kwargs: Any) -> Context:
"""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.
Keyword Args:
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:
"""Enter the async context manager.
Override this method to perform any setup operations when the context provider is entered.
Returns:
The ContextProvider instance for chaining.
"""
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
"""Exit the async context manager.
Override this method to perform any cleanup operations when the context provider is exited.
Args:
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
@@ -36,7 +36,7 @@ if TYPE_CHECKING:
from ._agents import SupportsAgentRun
from ._clients import SupportsChatGetResponse
from ._threads import AgentThread
from ._sessions import AgentSession
from ._tools import FunctionTool
from ._types import ChatOptions, ChatResponse, ChatResponseUpdate
@@ -118,7 +118,7 @@ class AgentContext:
Attributes:
agent: The agent being invoked.
messages: The messages being sent to the agent.
thread: The agent thread for this invocation, if any.
session: The agent session for this invocation, if any.
options: The options for the agent invocation as a dict.
stream: Whether this is a streaming invocation.
metadata: Metadata dictionary for sharing data between agent middleware.
@@ -138,7 +138,7 @@ class AgentContext:
async def process(self, context: AgentContext, call_next):
print(f"Agent: {context.agent.name}")
print(f"Messages: {len(context.messages)}")
print(f"Thread: {context.thread}")
print(f"Session: {context.session}")
print(f"Streaming: {context.stream}")
# Store metadata
@@ -156,7 +156,7 @@ class AgentContext:
*,
agent: SupportsAgentRun,
messages: list[Message],
thread: AgentThread | None = None,
session: AgentSession | None = None,
options: Mapping[str, Any] | None = None,
stream: bool = False,
metadata: Mapping[str, Any] | None = None,
@@ -175,7 +175,7 @@ class AgentContext:
Args:
agent: The agent being invoked.
messages: The messages being sent to the agent.
thread: The agent thread for this invocation, if any.
session: The agent session for this invocation, if any.
options: The options for the agent invocation as a dict.
stream: Whether this is a streaming invocation.
metadata: Metadata dictionary for sharing data between agent middleware.
@@ -187,7 +187,7 @@ class AgentContext:
"""
self.agent = agent
self.messages = messages
self.thread = thread
self.session = session
self.options = options
self.stream = stream
self.metadata = metadata if metadata is not None else {}
@@ -1098,7 +1098,7 @@ class AgentMiddlewareLayer:
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: Literal[False] = ...,
thread: AgentThread | None = None,
session: AgentSession | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
options: ChatOptions[ResponseModelBoundT],
**kwargs: Any,
@@ -1110,7 +1110,7 @@ class AgentMiddlewareLayer:
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: Literal[False] = ...,
thread: AgentThread | None = None,
session: AgentSession | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
options: ChatOptions[None] | None = None,
**kwargs: Any,
@@ -1122,7 +1122,7 @@ class AgentMiddlewareLayer:
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: Literal[True],
thread: AgentThread | None = None,
session: AgentSession | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
options: ChatOptions[Any] | None = None,
**kwargs: Any,
@@ -1133,7 +1133,7 @@ class AgentMiddlewareLayer:
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
options: ChatOptions[Any] | None = None,
**kwargs: Any,
@@ -1157,12 +1157,12 @@ class AgentMiddlewareLayer:
# Execute with middleware if available
if not pipeline.has_middlewares:
return super().run(messages, stream=stream, thread=thread, options=options, **combined_kwargs) # type: ignore[misc, no-any-return]
return super().run(messages, stream=stream, session=session, options=options, **combined_kwargs) # type: ignore[misc, no-any-return]
context = AgentContext(
agent=self, # type: ignore[arg-type]
messages=prepare_messages(messages), # type: ignore[arg-type]
thread=thread,
session=session,
options=options,
stream=stream,
kwargs=combined_kwargs,
@@ -1197,7 +1197,7 @@ class AgentMiddlewareLayer:
return super().run( # type: ignore[misc, no-any-return]
context.messages,
stream=context.stream,
thread=context.thread,
session=context.session,
options=context.options,
**context.kwargs,
)
@@ -166,45 +166,22 @@ class SerializationMixin:
during deserialization via the ``dependencies`` parameter.
Examples:
**Nested object serialization with agent thread management:**
**Nested object serialization:**
.. code-block:: python
from agent_framework import Message
from agent_framework._threads import AgentThreadState, ChatMessageStoreState
from agent_framework._sessions import AgentSession
# ChatMessageStoreState handles nested Message serialization
store_state = ChatMessageStoreState(
messages=[
Message(role="user", text="Hello agent"),
Message(role="assistant", text="Hi! How can I help?"),
]
)
# AgentSession uses SerializationMixin for state serialization
session = AgentSession(session_id="test")
# Nested serialization: messages are automatically converted to dicts
store_dict = store_state.to_dict()
# Result: {
# "type": "chat_message_store_state",
# "messages": [
# {"type": "chat_message", "role": {...}, "contents": [...]},
# {"type": "chat_message", "role": {...}, "contents": [...]}
# ]
# }
# Serialization produces a clean dict representation
session_dict = session.to_dict()
# AgentThreadState contains nested ChatMessageStoreState
thread_state = AgentThreadState(chat_message_store_state=store_state)
# Deep serialization: nested SerializationMixin objects are handled automatically
thread_dict = thread_state.to_dict()
# The chat_message_store_state and its nested messages are all serialized
# Reconstruction from nested dictionaries with automatic type conversion
# The __init__ method handles MutableMapping -> object conversion:
reconstructed = AgentThreadState.from_dict({
"chat_message_store_state": {"messages": [{"role": "user", "text": "Hello again"}]}
})
# chat_message_store_state becomes ChatMessageStoreState instance automatically
# Reconstruction from dictionaries
restored = AgentSession.from_dict(session_dict)
**Framework tools with exclusion patterns:**
@@ -30,6 +30,7 @@ __all__ = [
"BaseHistoryProvider",
"InMemoryHistoryProvider",
"SessionContext",
"register_state_type",
]
@@ -37,16 +38,50 @@ __all__ = [
_STATE_TYPE_REGISTRY: dict[str, type] = {}
def _register_state_type(cls: type) -> None:
"""Register a type for automatic deserialization in session state."""
def register_state_type(cls: type) -> None:
"""Register a type for automatic deserialization in session state.
Call this for any custom type (including Pydantic models) that you store
in ``session.state`` and want to survive ``to_dict()`` / ``from_dict()``
round-trips. Types with ``to_dict``/``from_dict`` methods or Pydantic
``BaseModel`` subclasses are handled automatically.
The type identifier defaults to ``cls.__name__.lower()`` but can be
overridden by defining a ``_get_type_identifier`` classmethod.
Note:
Pydantic models are auto-registered on first serialization, but
pre-registering ensures deserialization works even if the model
hasn't been serialized in this process yet (e.g. cold-start restore).
Args:
cls: The type to register.
"""
type_id: str = getattr(cls, "_get_type_identifier", lambda: cls.__name__.lower())()
_STATE_TYPE_REGISTRY[type_id] = cls
# Keep internal alias for framework use
_register_state_type = register_state_type
def _serialize_value(value: Any) -> Any:
"""Serialize a single value, handling objects with to_dict()."""
"""Serialize a single value, handling objects with to_dict() and Pydantic models."""
if hasattr(value, "to_dict") and callable(value.to_dict):
return value.to_dict() # pyright: ignore[reportUnknownMemberType]
# Pydantic BaseModel support — import lazily to avoid hard dep at module level
try:
from pydantic import BaseModel
if isinstance(value, BaseModel):
data = value.model_dump()
type_id: str = getattr(value.__class__, "_get_type_identifier", lambda: value.__class__.__name__.lower())()
data["type"] = type_id
# Auto-register for round-trip deserialization
_STATE_TYPE_REGISTRY.setdefault(type_id, value.__class__)
return data
except ImportError:
pass
if isinstance(value, list):
return [_serialize_value(item) for item in value] # pyright: ignore[reportUnknownVariableType]
if isinstance(value, dict):
@@ -59,8 +94,18 @@ def _deserialize_value(value: Any) -> Any:
if isinstance(value, dict) and "type" in value:
type_id = str(value["type"]) # pyright: ignore[reportUnknownArgumentType]
cls = _STATE_TYPE_REGISTRY.get(type_id)
if cls is not None and hasattr(cls, "from_dict"):
return cls.from_dict(value) # type: ignore[union-attr]
if cls is not None:
if hasattr(cls, "from_dict"):
return cls.from_dict(value) # type: ignore[union-attr]
# Pydantic BaseModel support
try:
from pydantic import BaseModel
if issubclass(cls, BaseModel):
data = {k: v for k, v in value.items() if k != "type"}
return cls.model_validate(data)
except ImportError:
pass
if isinstance(value, list):
return [_deserialize_value(item) for item in value] # pyright: ignore[reportUnknownVariableType]
if isinstance(value, dict):
@@ -12,14 +12,17 @@ Usage::
class MySettings(TypedDict, total=False):
api_key: str | None # optional — resolves to None if not set
model_id: str | None # optional by default
source_a: str | None
source_b: str | None
# Make model_id required at call time:
# Make model_id required; require exactly one of source_a / source_b:
settings = load_settings(
MySettings,
env_prefix="MY_APP_",
required_fields=["model_id"],
required_fields=["model_id", ("source_a", "source_b")],
model_id="gpt-4",
source_a="value",
)
settings["api_key"] # type-checked dict access
settings["model_id"] # str | None per type, but guaranteed not None at runtime
@@ -167,7 +170,7 @@ def load_settings(
env_prefix: str = "",
env_file_path: str | None = None,
env_file_encoding: str | None = None,
required_fields: Sequence[str] | None = None,
required_fields: Sequence[str | tuple[str, ...]] | None = None,
**overrides: Any,
) -> SettingsT:
"""Load settings from environment variables, a ``.env`` file, and explicit overrides.
@@ -181,18 +184,19 @@ def load_settings(
4. Default values fields with class-level defaults on the TypedDict, or
``None`` for optional fields.
Fields listed in *required_fields* are validated after resolution. If any
required field resolves to ``None``, a ``SettingNotFoundError`` is raised.
This allows callers to decide which fields are required based on runtime
context (e.g. ``endpoint`` is only required when no pre-built client is
provided).
Entries in *required_fields* are validated after resolution:
- A **string** entry means the field must resolve to a non-``None`` value.
- A **tuple** entry means exactly one field in the group must be non-``None``
(mutually exclusive).
Args:
settings_type: A ``TypedDict`` class describing the settings schema.
env_prefix: Prefix for environment variable lookup (e.g. ``"OPENAI_"``).
env_file_path: Path to ``.env`` file. Defaults to ``".env"`` when omitted.
env_file_encoding: Encoding for reading the ``.env`` file. Defaults to ``"utf-8"``.
required_fields: Field names that must resolve to a non-``None`` value.
required_fields: Field names (``str``) that must resolve to a non-``None``
value, or tuples of field names where exactly one must be set.
**overrides: Field values. ``None`` values are ignored so that callers can
forward optional parameters without masking env-var / default resolution.
@@ -200,7 +204,8 @@ def load_settings(
A populated dict matching *settings_type*.
Raises:
SettingNotFoundError: If a required field could not be resolved from any source.
SettingNotFoundError: If a required field could not be resolved from any
source, or if a mutually exclusive constraint is violated.
ServiceInitializationError: If an override value has an incompatible type.
"""
encoding = env_file_encoding or "utf-8"
@@ -215,7 +220,6 @@ def load_settings(
# Get field type hints from the TypedDict
hints = get_type_hints(settings_type)
required: set[str] = set(required_fields) if required_fields else set()
result: dict[str, Any] = {}
for field_name, field_type in hints.items():
@@ -249,14 +253,28 @@ def load_settings(
result[field_name] = None
# Validate required fields after all resolution
if required:
for field_name in required:
if result.get(field_name) is None:
env_var_name = f"{env_prefix}{field_name.upper()}"
raise SettingNotFoundError(
f"Required setting '{field_name}' was not provided. "
f"Set it via the '{field_name}' parameter or the "
f"'{env_var_name}' environment variable."
)
if required_fields:
for entry in required_fields:
if isinstance(entry, str):
# Single required field
if result.get(entry) is None:
env_var_name = f"{env_prefix}{entry.upper()}"
raise SettingNotFoundError(
f"Required setting '{entry}' was not provided. "
f"Set it via the '{entry}' parameter or the "
f"'{env_var_name}' environment variable."
)
else:
# Mutually exclusive group — exactly one must be set
set_fields = [f for f in entry if result.get(f) is not None]
if len(set_fields) == 0:
names = ", ".join(f"'{f}'" for f in entry)
raise SettingNotFoundError(f"Exactly one of {names} must be provided, but none was set.")
if len(set_fields) > 1:
all_names = ", ".join(f"'{f}'" for f in entry)
set_names = ", ".join(f"'{f}'" for f in set_fields)
raise SettingNotFoundError(
f"Only one of {all_names} may be provided, but multiple were set: {set_names}."
)
return result # type: ignore[return-value]
@@ -1,507 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
from collections.abc import MutableMapping, Sequence
from typing import Any, Protocol, TypeVar
from ._memory import ContextProvider
from ._serialization import SerializationMixin
from ._types import Message
from .exceptions import AgentThreadException
__all__ = ["AgentThread", "ChatMessageStore", "ChatMessageStoreProtocol"]
class ChatMessageStoreProtocol(Protocol):
"""Defines methods for storing and retrieving chat messages associated with a specific thread.
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 Message
class MyMessageStore:
def __init__(self):
self._messages = []
async def list_messages(self) -> list[Message]:
return self._messages
async def add_messages(self, messages: Sequence[Message]) -> 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[Message]:
"""Gets all the messages from the store that should be used for the next agent invocation.
Messages are returned in ascending chronological order, with the oldest message first.
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
since they may contain state that is specific to a thread.
"""
...
async def add_messages(self, messages: Sequence[Message]) -> None:
"""Adds messages to the store.
Args:
messages: The sequence of Message objects to add to the store.
"""
...
@classmethod
async def deserialize(
cls, serialized_store_state: MutableMapping[str, Any], **kwargs: Any
) -> ChatMessageStoreProtocol:
"""Creates a new instance of the store from previously serialized state.
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.
Keyword Args:
**kwargs: Additional arguments for deserialization.
Returns:
A new instance of the store populated with messages from the serialized state.
"""
...
async def update_from_state(self, serialized_store_state: MutableMapping[str, Any], **kwargs: Any) -> None:
"""Update the current ChatMessageStore instance from serialized state data.
Args:
serialized_store_state: Previously serialized state data containing messages.
Keyword Args:
kwargs: Additional arguments for deserialization.
"""
...
async def serialize(self, **kwargs: Any) -> dict[str, Any]:
"""Serializes the current object's state.
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.
Keyword Args:
kwargs: Additional arguments for serialization.
Returns:
The serialized state data that can be used with ``deserialize()``.
"""
...
class ChatMessageStoreState(SerializationMixin):
"""State model for serializing and deserializing chat message store data.
Attributes:
messages: List of chat messages stored in the message store.
"""
def __init__(
self,
messages: Sequence[Message] | Sequence[MutableMapping[str, Any]] | None = None,
**kwargs: Any,
) -> None:
"""Create the store state.
Args:
messages: a list of messages or a list of the dict representation of messages.
Keyword Args:
**kwargs: not used for this, but might be used by subclasses.
"""
if not messages:
self.messages: list[Message] = []
return
if not isinstance(messages, list):
raise TypeError("Messages should be a list")
new_messages: list[Message] = []
for msg in messages:
if isinstance(msg, Message):
new_messages.append(msg)
else:
new_messages.append(Message.from_dict(msg))
self.messages = new_messages
class AgentThreadState(SerializationMixin):
"""State model for serializing and deserializing thread information."""
def __init__(
self,
*,
service_thread_id: str | None = None,
chat_message_store_state: ChatMessageStoreState | MutableMapping[str, Any] | None = None,
) -> None:
"""Create a AgentThread state.
Keyword Args:
service_thread_id: Optional ID of the thread managed by the agent service.
chat_message_store_state: Optional serialized state of the chat message store.
"""
if service_thread_id is not None and chat_message_store_state is not None:
raise AgentThreadException("A thread cannot have both a service_thread_id and a chat_message_store.")
self.service_thread_id = service_thread_id
self.chat_message_store_state: ChatMessageStoreState | None = None
if chat_message_store_state is not None:
if isinstance(chat_message_store_state, dict):
self.chat_message_store_state = ChatMessageStoreState.from_dict(chat_message_store_state)
elif isinstance(chat_message_store_state, ChatMessageStoreState):
self.chat_message_store_state = chat_message_store_state
else:
raise TypeError("Could not parse ChatMessageStoreState.")
ChatMessageStoreT = TypeVar("ChatMessageStoreT", bound="ChatMessageStore")
class ChatMessageStore:
"""An in-memory implementation of ChatMessageStoreProtocol that stores messages in a list.
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.
The store maintains messages in memory and provides methods to serialize
and deserialize the state for persistence purposes.
Examples:
.. code-block:: python
from agent_framework import ChatMessageStore, Message
# Create an empty store
store = ChatMessageStore()
# Add messages
message = Message(role="user", text="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[Message] | None = None):
"""Create a ChatMessageStore for use in a thread.
Args:
messages: The messages to store.
"""
self.messages = list(messages) if messages else []
async def add_messages(self, messages: Sequence[Message]) -> None:
"""Add messages to the store.
Args:
messages: Sequence of Message objects to add to the store.
"""
self.messages.extend(messages)
async def list_messages(self) -> list[Message]:
"""Get all messages from the store in chronological order.
Returns:
List of Message objects, ordered from oldest to newest.
"""
return self.messages
@classmethod
async def deserialize(
cls: type[ChatMessageStoreT], serialized_store_state: MutableMapping[str, Any], **kwargs: Any
) -> ChatMessageStoreT:
"""Create a new ChatMessageStore instance from serialized state data.
Args:
serialized_store_state: Previously serialized state data containing messages.
Keyword Args:
**kwargs: Additional arguments for deserialization.
Returns:
A new ChatMessageStore instance populated with messages from the serialized state.
"""
state = ChatMessageStoreState.from_dict(serialized_store_state, **kwargs)
if state.messages:
return cls(messages=state.messages)
return cls()
async def update_from_state(self, serialized_store_state: MutableMapping[str, Any], **kwargs: Any) -> None:
"""Update the current ChatMessageStore instance from serialized state data.
Args:
serialized_store_state: Previously serialized state data containing messages.
Keyword Args:
**kwargs: Additional arguments for deserialization.
"""
if not serialized_store_state:
return
state = ChatMessageStoreState.from_dict(serialized_store_state, **kwargs)
if state.messages:
self.messages = state.messages
async def serialize(self, **kwargs: Any) -> dict[str, Any]:
"""Serialize the current store state for persistence.
Keyword Args:
**kwargs: Additional arguments for serialization.
Returns:
Serialized state data that can be used with deserialize_state.
"""
state = ChatMessageStoreState(messages=self.messages)
return state.to_dict()
AgentThreadT = TypeVar("AgentThreadT", bound="AgentThread")
class AgentThread:
"""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 Agent, 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 = Agent(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 = Agent(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 = Agent(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,
*,
service_thread_id: str | None = None,
message_store: ChatMessageStoreProtocol | None = None,
context_provider: ContextProvider | None = None,
) -> None:
"""Initialize an AgentThread, do not use this method manually, always use: ``agent.get_new_thread()``.
Args:
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.
"""
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.")
self._service_thread_id = service_thread_id
self._message_store = message_store
self.context_provider = context_provider
@property
def is_initialized(self) -> bool:
"""Indicates if the thread is initialized.
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
@property
def service_thread_id(self) -> str | None:
"""Gets the ID of the current thread to support cases where the thread is owned by the agent service."""
return self._service_thread_id
@service_thread_id.setter
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:
Either ``service_thread_id`` or ``message_store`` may be set, but not both.
"""
if service_thread_id is None:
return
if self._message_store is not None:
raise AgentThreadException(
"Only the service_thread_id or message_store may be set, "
"but not both and switching from one to another is not supported."
)
self._service_thread_id = service_thread_id
@property
def message_store(self) -> ChatMessageStoreProtocol | None:
"""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.
Note:
Either ``service_thread_id`` or ``message_store`` may be set, but not both.
"""
if message_store is None:
return
if self._service_thread_id is not None:
raise AgentThreadException(
"Only the service_thread_id or message_store may be set, "
"but not both and switching from one to another is not supported."
)
self._message_store = message_store
async def on_new_messages(self, new_messages: Message | Sequence[Message]) -> None:
"""Invoked when a new message has been contributed to the chat by any participant.
Args:
new_messages: The new Message or sequence of Message 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.
return
if self._message_store is None:
# If there is no conversation id, and no store we can
# create a default in memory store.
self._message_store = ChatMessageStore()
# If a store has been provided, we need to add the messages to the store.
if isinstance(new_messages, Message):
new_messages = [new_messages]
await self._message_store.add_messages(new_messages)
async def serialize(self, **kwargs: Any) -> dict[str, Any]:
"""Serializes the current object's state.
Keyword Args:
**kwargs: Arguments for serialization.
"""
chat_message_store_state = None
if self._message_store is not None:
chat_message_store_state = await self._message_store.serialize(**kwargs)
state = AgentThreadState(
service_thread_id=self._service_thread_id, chat_message_store_state=chat_message_store_state
)
return state.to_dict(exclude_none=False)
@classmethod
async def deserialize(
cls: type[AgentThreadT],
serialized_thread_state: MutableMapping[str, Any],
*,
message_store: ChatMessageStoreProtocol | None = None,
**kwargs: Any,
) -> AgentThreadT:
"""Deserializes the state from a dictionary into a new AgentThread instance.
Args:
serialized_thread_state: The serialized thread state as a dictionary.
Keyword Args:
message_store: Optional ChatMessageStoreProtocol to use for managing messages.
If not provided, a new ChatMessageStore will be created if needed.
**kwargs: Additional arguments for deserialization.
Returns:
A new AgentThread instance with properties set from the serialized state.
"""
state = AgentThreadState.from_dict(serialized_thread_state)
if state.service_thread_id is not None:
return cls(service_thread_id=state.service_thread_id)
# If we don't have any ChatMessageStoreProtocol state return here.
if state.chat_message_store_state is None:
return cls()
if message_store is not None:
try:
await message_store.add_messages(state.chat_message_store_state.messages, **kwargs)
except Exception as ex:
raise AgentThreadException("Failed to deserialize the provided message store.") from ex
return cls(message_store=message_store)
try:
message_store = ChatMessageStore(messages=state.chat_message_store_state.messages, **kwargs)
except Exception as ex:
raise AgentThreadException("Failed to deserialize the message store.") from ex
return cls(message_store=message_store)
async def update_from_thread_state(
self,
serialized_thread_state: MutableMapping[str, Any],
**kwargs: Any,
) -> None:
"""Deserializes the state from a dictionary into the thread properties.
Args:
serialized_thread_state: The serialized thread state as a dictionary.
Keyword Args:
**kwargs: Additional arguments for deserialization.
"""
state = AgentThreadState.from_dict(serialized_thread_state)
if state.service_thread_id is not None:
self.service_thread_id = state.service_thread_id
# Since we have an ID, we should not have a chat message store and we can return here.
return
# If we don't have any ChatMessageStoreProtocol state return here.
if state.chat_message_store_state is None:
return
if self.message_store is not None:
await self.message_store.add_messages(state.chat_message_store_state.messages, **kwargs)
# If we don't have a chat message store yet, create an in-memory one.
return
# Create the message store from the default.
self.message_store = ChatMessageStore(messages=state.chat_message_store_state.messages, **kwargs)
@@ -468,16 +468,16 @@ class FunctionTool(SerializationMixin, Generic[ArgsT]):
"chat_options",
"tools",
"tool_choice",
"thread",
"session",
"conversation_id",
"options",
"response_format",
}
}
attributes.update({
OtelAttr.TOOL_ARGUMENTS: arguments.model_dump_json()
OtelAttr.TOOL_ARGUMENTS: arguments.model_dump_json(ensure_ascii=False)
if arguments
else json.dumps(serializable_kwargs, default=str)
else json.dumps(serializable_kwargs, default=str, ensure_ascii=False)
if serializable_kwargs
else "None"
})
@@ -1897,7 +1897,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
config=self.function_invocation_configuration,
middleware_pipeline=function_middleware_pipeline,
)
filtered_kwargs = {k: v for k, v in kwargs.items() if k != "thread"}
filtered_kwargs = {k: v for k, v in kwargs.items() if k != "session"}
# Make options mutable so we can update conversation_id during function invocation loop
mutable_options: dict[str, Any] = dict(options) if options else {}
# Remove additional_function_arguments from options passed to underlying chat client
@@ -1791,7 +1791,7 @@ class ContinuationToken(TypedDict):
# Restore and resume
token = json.loads(token_json)
response = await agent.run(
thread=thread,
session=session,
options={"continuation_token": token},
)
"""
@@ -6,22 +6,22 @@ import json
import logging
import sys
import uuid
from collections.abc import AsyncIterable, Awaitable
from collections.abc import AsyncIterable, Awaitable, Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast, overload
from agent_framework import (
from .._agents import BaseAgent
from .._sessions import AgentSession, BaseContextProvider, BaseHistoryProvider, SessionContext
from .._types import (
AgentResponse,
AgentResponseUpdate,
AgentThread,
BaseAgent,
Content,
Message,
ResponseStream,
UsageDetails,
add_usage_details,
)
from .._types import add_usage_details
from ..exceptions import AgentExecutionException
from ._checkpoint import CheckpointStorage
from ._events import (
@@ -79,6 +79,7 @@ class WorkflowAgent(BaseAgent):
id: str | None = None,
name: str | None = None,
description: str | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
**kwargs: Any,
) -> None:
"""Initialize the WorkflowAgent.
@@ -90,6 +91,7 @@ class WorkflowAgent(BaseAgent):
id: Unique identifier for the agent. If None, will be generated.
name: Optional name for the agent.
description: Optional description of the agent.
context_providers: Optional sequence of context providers for the agent.
**kwargs: Additional keyword arguments passed to BaseAgent.
Note:
@@ -110,7 +112,7 @@ class WorkflowAgent(BaseAgent):
if not any(is_type_compatible(list[Message], input_type) for input_type in start_executor.input_types):
raise ValueError("Workflow's start executor cannot handle list[Message]")
super().__init__(id=id, name=name, description=description, **kwargs)
super().__init__(id=id, name=name, description=description, context_providers=context_providers, **kwargs)
self._workflow: Workflow = workflow
self._pending_requests: dict[str, WorkflowEvent[Any]] = {}
@@ -127,22 +129,22 @@ class WorkflowAgent(BaseAgent):
@overload
def run(
self,
messages: str | Message | list[str] | list[Message] | None = None,
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: Literal[True],
thread: AgentThread | None = None,
session: AgentSession | None = None,
checkpoint_id: str | None = None,
checkpoint_storage: CheckpointStorage | None = None,
**kwargs: Any,
) -> AsyncIterable[AgentResponseUpdate]: ...
) -> ResponseStream[AgentResponseUpdate, AgentResponse]: ...
@overload
async def run(
self,
messages: str | Message | list[str] | list[Message] | None = None,
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: Literal[False] = ...,
thread: AgentThread | None = None,
session: AgentSession | None = None,
checkpoint_id: str | None = None,
checkpoint_storage: CheckpointStorage | None = None,
**kwargs: Any,
@@ -150,14 +152,14 @@ class WorkflowAgent(BaseAgent):
def run(
self,
messages: str | Message | list[str] | list[Message] | None = None,
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
checkpoint_id: str | None = None,
checkpoint_storage: CheckpointStorage | None = None,
**kwargs: Any,
) -> AsyncIterable[AgentResponseUpdate] | Awaitable[AgentResponse]:
) -> ResponseStream[AgentResponseUpdate, AgentResponse] | Awaitable[AgentResponse]:
"""Get a response from the workflow agent.
Args:
@@ -167,7 +169,7 @@ class WorkflowAgent(BaseAgent):
Keyword Args:
stream: If True, returns an async iterable of updates. If False (default),
returns an awaitable AgentResponse.
thread: The conversation thread. If None, a new thread will be created.
session: The agent session for conversation context.
checkpoint_id: ID of checkpoint to restore from. If provided, the workflow
resumes from this checkpoint instead of starting fresh.
checkpoint_storage: Runtime checkpoint storage. When provided with checkpoint_id,
@@ -184,82 +186,21 @@ class WorkflowAgent(BaseAgent):
or AgentResponseUpdate objects. Request info events (type='request_info') will be
converted to function call and approval request contents.
"""
if messages is None:
messages = []
response_id = str(uuid.uuid4())
if stream:
return self._run_streaming(
messages=messages,
thread=thread,
checkpoint_id=checkpoint_id,
checkpoint_storage=checkpoint_storage,
**kwargs,
return ResponseStream(
self._run_stream_impl(messages, response_id, session, checkpoint_id, checkpoint_storage, **kwargs),
finalizer=AgentResponse.from_updates,
)
return self._run_non_streaming(
messages=messages,
thread=thread,
checkpoint_id=checkpoint_id,
checkpoint_storage=checkpoint_storage,
**kwargs,
)
async def _run_non_streaming(
self,
messages: str | Message | list[str] | list[Message] | None = None,
*,
thread: AgentThread | None = None,
checkpoint_id: str | None = None,
checkpoint_storage: CheckpointStorage | None = None,
**kwargs: Any,
) -> AgentResponse:
"""Internal non-streaming implementation."""
input_messages = normalize_messages_input(messages)
thread = thread or self.get_new_thread()
response_id = str(uuid.uuid4())
response = await self._run_impl(
input_messages, response_id, thread, checkpoint_id, checkpoint_storage, **kwargs
)
# Notify thread of new messages (both input and response messages)
await self._notify_thread_of_new_messages(thread, input_messages, response.messages)
return response
async def _run_streaming(
self,
messages: str | Message | list[str] | list[Message] | None = None,
*,
thread: AgentThread | None = None,
checkpoint_id: str | None = None,
checkpoint_storage: CheckpointStorage | None = None,
**kwargs: Any,
) -> AsyncIterable[AgentResponseUpdate]:
"""Internal streaming implementation.
Yields AgentResponseUpdate objects. Output events (type='output') from the workflow
are converted to updates. Request info events (type='request_info') are converted
to function call and approval request contents.
"""
input_messages = normalize_messages_input(messages)
thread = thread or self.get_new_thread()
response_updates: list[AgentResponseUpdate] = []
response_id = str(uuid.uuid4())
async for update in self._run_stream_impl(
input_messages, response_id, thread, checkpoint_id, checkpoint_storage, **kwargs
):
response_updates.append(update)
yield update
# Convert updates to final response.
response = self.merge_updates(response_updates, response_id)
# Notify thread of new messages (both input and response messages)
await self._notify_thread_of_new_messages(thread, input_messages, response.messages)
return self._run_impl(messages, response_id, session, checkpoint_id, checkpoint_storage, **kwargs)
async def _run_impl(
self,
input_messages: list[Message],
messages: str | Message | Sequence[str | Message],
response_id: str,
thread: AgentThread,
session: AgentSession | None,
checkpoint_id: str | None = None,
checkpoint_storage: CheckpointStorage | None = None,
**kwargs: Any,
@@ -267,9 +208,9 @@ class WorkflowAgent(BaseAgent):
"""Internal implementation of non-streaming execution.
Args:
input_messages: Normalized input messages to process.
messages: Normalized input messages to process.
response_id: The unique response ID for this workflow execution.
thread: The conversation thread containing message history.
session: The agent session for conversation context.
checkpoint_id: ID of checkpoint to restore from.
checkpoint_storage: Runtime checkpoint storage.
**kwargs: Additional keyword arguments passed through to the underlying
@@ -278,20 +219,44 @@ class WorkflowAgent(BaseAgent):
Returns:
An AgentResponse representing the workflow execution results.
"""
input_messages = normalize_messages_input(messages)
# run the context providers with the session
session_context = SessionContext(
session_id=session.session_id if session else None,
service_session_id=session.service_session_id if session else None,
input_messages=input_messages or [],
options={},
)
state = session.state if session else {}
for provider in self.context_providers:
if isinstance(provider, BaseHistoryProvider) and not provider.load_messages:
continue
await provider.before_run(
agent=self, # type: ignore[arg-type]
session=session, # type: ignore[arg-type]
context=session_context,
state=state,
)
# combine the messages
session_messages: list[Message] = session_context.get_messages(include_input=True)
output_events: list[WorkflowEvent[Any]] = []
async for event in self._run_core(
input_messages, thread, checkpoint_id, checkpoint_storage, streaming=False, **kwargs
session_messages, checkpoint_id, checkpoint_storage, streaming=False, **kwargs
):
if event.type == "output" or event.type == "request_info":
output_events.append(event)
return self._convert_workflow_events_to_agent_response(response_id, output_events)
result = self._convert_workflow_events_to_agent_response(response_id, output_events)
await self._run_after_providers(session=session, context=session_context)
return result
async def _run_stream_impl(
self,
input_messages: list[Message],
messages: str | Message | Sequence[str | Message],
response_id: str,
thread: AgentThread,
session: AgentSession | None,
checkpoint_id: str | None = None,
checkpoint_storage: CheckpointStorage | None = None,
**kwargs: Any,
@@ -299,9 +264,9 @@ class WorkflowAgent(BaseAgent):
"""Internal implementation of streaming execution.
Args:
input_messages: Normalized input messages to process.
messages: Input messages to process.
response_id: The unique response ID for this workflow execution.
thread: The conversation thread containing message history.
session: The agent session for conversation context.
checkpoint_id: ID of checkpoint to restore from.
checkpoint_storage: Runtime checkpoint storage.
**kwargs: Additional keyword arguments passed through to the underlying
@@ -310,17 +275,39 @@ class WorkflowAgent(BaseAgent):
Yields:
AgentResponseUpdate objects representing the workflow execution progress.
"""
input_messages = normalize_messages_input(messages)
# run the context providers with the session
session_context = SessionContext(
session_id=session.session_id if session else None,
service_session_id=session.service_session_id if session else None,
input_messages=input_messages or [],
options={},
)
state = session.state if session else {}
for provider in self.context_providers:
if isinstance(provider, BaseHistoryProvider) and not provider.load_messages:
continue
await provider.before_run(
agent=self, # type: ignore[arg-type]
session=session, # type: ignore[arg-type]
context=session_context,
state=state,
)
# combine the messages
session_messages: list[Message] = session_context.get_messages(include_input=True)
async for event in self._run_core(
input_messages, thread, checkpoint_id, checkpoint_storage, streaming=True, **kwargs
session_messages, checkpoint_id, checkpoint_storage, streaming=True, **kwargs
):
updates = self._convert_workflow_event_to_agent_response_updates(response_id, event)
for update in updates:
yield update
await self._run_after_providers(session=session, context=session_context)
async def _run_core(
self,
input_messages: list[Message],
thread: AgentThread,
input_messages: Sequence[Message],
checkpoint_id: str | None,
checkpoint_storage: CheckpointStorage | None,
streaming: bool,
@@ -330,7 +317,6 @@ class WorkflowAgent(BaseAgent):
Args:
input_messages: Normalized input messages to process.
thread: The conversation thread containing message history.
checkpoint_id: ID of checkpoint to restore from.
checkpoint_storage: Runtime checkpoint storage.
streaming: Whether to use streaming workflow methods.
@@ -371,10 +357,9 @@ class WorkflowAgent(BaseAgent):
yield event
else:
conversation_messages = await self._build_conversation_messages(thread, input_messages)
if streaming:
async for event in self.workflow.run(
message=conversation_messages,
message=input_messages,
stream=True,
checkpoint_storage=checkpoint_storage,
**kwargs,
@@ -382,7 +367,7 @@ class WorkflowAgent(BaseAgent):
yield event
else:
for event in await self.workflow.run(
message=conversation_messages,
message=input_messages,
checkpoint_storage=checkpoint_storage,
**kwargs,
):
@@ -390,29 +375,7 @@ class WorkflowAgent(BaseAgent):
# endregion Run Methods
async def _build_conversation_messages(
self,
thread: AgentThread,
input_messages: list[Message],
) -> list[Message]:
"""Build the complete conversation by prepending thread history to input messages.
Args:
thread: The conversation thread containing message history.
input_messages: The new input messages to append.
Returns:
A list of Message objects representing the full conversation.
"""
conversation_messages: list[Message] = []
if thread.message_store:
history = await thread.message_store.list_messages()
if history:
conversation_messages.extend(history)
conversation_messages.extend(input_messages)
return conversation_messages
def _process_pending_requests(self, input_messages: list[Message]) -> dict[str, Any]:
def _process_pending_requests(self, input_messages: Sequence[Message]) -> dict[str, Any]:
"""Process pending requests by extracting function responses and updating state.
Args:
@@ -669,7 +632,7 @@ class WorkflowAgent(BaseAgent):
# Ignore workflow-internal events
return []
def _extract_function_responses(self, input_messages: list[Message]) -> dict[str, Any]:
def _extract_function_responses(self, input_messages: Sequence[Message]) -> dict[str, Any]:
"""Extract function responses from input messages."""
function_responses: dict[str, Any] = {}
for message in input_messages:
@@ -2,7 +2,7 @@
import logging
import sys
from collections.abc import Mapping
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass
from typing import Any, cast
@@ -11,7 +11,7 @@ from typing_extensions import Never
from agent_framework import Content
from .._agents import SupportsAgentRun
from .._threads import AgentThread
from .._sessions import AgentSession
from .._types import AgentResponse, AgentResponseUpdate, Message
from ._agent_utils import resolve_agent_id
from ._const import WORKFLOW_RUN_KWARGS_KEY
@@ -81,14 +81,14 @@ class AgentExecutor(Executor):
self,
agent: SupportsAgentRun,
*,
agent_thread: AgentThread | None = None,
session: AgentSession | None = None,
id: str | None = None,
):
"""Initialize the executor with a unique identifier.
Args:
agent: The agent to be wrapped by this executor.
agent_thread: The thread to use for running the agent. If None, a new thread will be created.
session: The session to use for running the agent. If None, a new session will be created.
id: A unique identifier for the executor. If None, the agent's name will be used if available.
"""
# Prefer provided id; else use agent.name if present; else generate deterministic prefix
@@ -97,7 +97,7 @@ class AgentExecutor(Executor):
raise ValueError("Agent must have a non-empty name or id or an explicit id must be provided.")
super().__init__(exec_id)
self._agent = agent
self._agent_thread = agent_thread or self._agent.get_new_thread()
self._session = session or self._agent.create_session()
self._pending_agent_requests: dict[str, Content] = {}
self._pending_responses_to_agent: list[Content] = []
@@ -205,35 +205,33 @@ class AgentExecutor(Executor):
async def on_checkpoint_save(self) -> dict[str, Any]:
"""Capture current executor state for checkpointing.
NOTE: if the thread storage is on the server side, the full thread state
may not be serialized locally. Therefore, we are relying on the server-side
to ensure the thread state is preserved and immutable across checkpoints.
This is not the case for AzureAI Agents, but works for the Responses API.
NOTE: if the session uses service-side storage, the full session state
may not be serialized locally.
Returns:
Dict containing serialized cache and thread state
Dict containing serialized cache and session state
"""
# Check if using AzureAIAgentClient with server-side thread and warn about checkpointing limitations
if is_chat_agent(self._agent) and self._agent_thread.service_thread_id is not None:
# Check if using AzureAIAgentClient with server-side session and warn about checkpointing limitations
if is_chat_agent(self._agent) and self._session.service_session_id is not None:
client_class_name = self._agent.client.__class__.__name__
client_module = self._agent.client.__class__.__module__
if client_class_name == "AzureAIAgentClient" and "azure_ai" in client_module:
logger.warning(
"Checkpointing an AgentExecutor with AzureAIAgentClient that uses server-side threads. "
"Currently, checkpointing does not capture messages from server-side threads "
"(service_thread_id: %s). The thread state in checkpoints is not immutable and can be "
"Checkpointing an AgentExecutor with AzureAIAgentClient that uses server-side sessions. "
"Currently, checkpointing does not capture messages from server-side sessions "
"(service_session_id: %s). The session state in checkpoints is not immutable and can be "
"modified by subsequent runs. If you need reliable checkpointing with Azure AI agents, "
"consider implementing a custom executor and managing the thread state yourself.",
self._agent_thread.service_thread_id,
"consider implementing a custom executor and managing the session state yourself.",
self._session.service_session_id,
)
serialized_thread = await self._agent_thread.serialize()
serialized_session = self._session.to_dict()
return {
"cache": self._cache,
"full_conversation": self._full_conversation,
"agent_thread": serialized_thread,
"agent_session": serialized_session,
"pending_agent_requests": self._pending_agent_requests,
"pending_responses_to_agent": self._pending_responses_to_agent,
}
@@ -246,22 +244,34 @@ class AgentExecutor(Executor):
state: Checkpoint data dict
"""
cache_payload = state.get("cache")
self._cache = cache_payload or []
if cache_payload:
try:
self._cache = cache_payload
except Exception as exc:
logger.warning("Failed to restore cache: %s", exc)
self._cache = []
else:
self._cache = []
full_conversation_payload = state.get("full_conversation")
self._full_conversation = full_conversation_payload or []
thread_payload = state.get("agent_thread")
if thread_payload:
if full_conversation_payload:
try:
# Deserialize the thread state directly
self._agent_thread = await AgentThread.deserialize(thread_payload)
self._full_conversation = full_conversation_payload
except Exception as exc:
logger.warning("Failed to restore agent thread: %s", exc)
self._agent_thread = self._agent.get_new_thread()
logger.warning("Failed to restore full conversation: %s", exc)
self._full_conversation = []
else:
self._agent_thread = self._agent.get_new_thread()
self._full_conversation = []
session_payload = state.get("agent_session")
if session_payload:
try:
self._session = AgentSession.from_dict(session_payload)
except Exception as exc:
logger.warning("Failed to restore agent session: %s", exc)
self._session = self._agent.create_session()
else:
self._session = self._agent.create_session()
pending_requests_payload = state.get("pending_agent_requests")
if pending_requests_payload:
@@ -321,7 +331,7 @@ class AgentExecutor(Executor):
response = await self._agent.run(
self._cache,
stream=False,
thread=self._agent_thread,
session=self._session,
options=options,
**run_kwargs,
)
@@ -348,22 +358,31 @@ class AgentExecutor(Executor):
run_kwargs, options = self._prepare_agent_run_args(ctx.get_state(WORKFLOW_RUN_KWARGS_KEY) or {})
updates: list[AgentResponseUpdate] = []
user_input_requests: list[Content] = []
async for update in self._agent.run(
streamed_user_input_requests: list[Content] = []
stream = self._agent.run(
self._cache,
stream=True,
thread=self._agent_thread,
session=self._session,
options=options,
**run_kwargs,
):
)
async for update in stream:
updates.append(update)
await ctx.yield_output(update)
if update.user_input_requests:
user_input_requests.extend(update.user_input_requests)
streamed_user_input_requests.extend(update.user_input_requests)
# Build the final AgentResponse from the collected updates
if is_chat_agent(self._agent):
# Prefer stream finalization when available so result hooks run
# (e.g., thread conversation updates). Fall back to reconstructing from updates
# for legacy/custom agents that return a plain async iterable.
# TODO(evmattso): Integrate workflow agent run handling around ResponseStream so
# AgentExecutor does not need this conditional stream-finalization branch.
maybe_get_final_response = getattr(stream, "get_final_response", None)
get_final_response = maybe_get_final_response if callable(maybe_get_final_response) else None
response: AgentResponse[Any]
if get_final_response is not None:
response = await cast(Callable[[], Awaitable[AgentResponse[Any]]], get_final_response)()
elif is_chat_agent(self._agent):
response_format = self._agent.default_options.get("response_format")
response = AgentResponse.from_updates(
updates,
@@ -373,6 +392,16 @@ class AgentExecutor(Executor):
response = AgentResponse.from_updates(updates)
# Handle any user input requests after the streaming completes
user_input_requests: list[Content] = []
seen_request_ids: set[str] = set()
for user_input_request in [*streamed_user_input_requests, *response.user_input_requests]:
request_id = getattr(user_input_request, "id", None)
if isinstance(request_id, str) and request_id:
if request_id in seen_request_ids:
continue
seen_request_ids.add(request_id)
user_input_requests.append(user_input_request)
if user_input_requests:
for user_input_request in user_input_requests:
self._pending_agent_requests[user_input_request.id] = user_input_request # type: ignore[index]
@@ -8,7 +8,6 @@ PACKAGE_NAME = "agent-framework-ag-ui"
_IMPORTS = [
"__version__",
"AgentFrameworkAgent",
"AGUIThread",
"add_agent_framework_fastapi_endpoint",
"AGUIChatClient",
"AGUIEventConverter",
@@ -83,8 +83,7 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
env_file_encoding: str | None = None,
instruction_role: str | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
function_invocation_configuration: FunctionInvocationConfiguration
| None = None,
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
**kwargs: Any,
) -> None:
"""Initialize an Azure OpenAI Responses client.
@@ -190,9 +189,7 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
deployment_name = str(model_id)
# Project client path: create OpenAI client from an Azure AI Foundry project
if async_client is None and (
project_client is not None or project_endpoint is not None
):
if async_client is None and (project_client is not None or project_endpoint is not None):
async_client = self._create_client_from_project(
project_client=project_client,
project_endpoint=project_endpoint,
@@ -221,9 +218,7 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
and (hostname := urlparse(str(azure_openai_settings["endpoint"])).hostname)
and hostname.endswith(".openai.azure.com")
):
azure_openai_settings["base_url"] = urljoin(
str(azure_openai_settings["endpoint"]), "/openai/v1/"
)
azure_openai_settings["base_url"] = urljoin(str(azure_openai_settings["endpoint"]), "/openai/v1/")
if not azure_openai_settings["responses_deployment_name"]:
raise ServiceInitializationError(
@@ -236,9 +231,7 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
endpoint=azure_openai_settings["endpoint"],
base_url=azure_openai_settings["base_url"],
api_version=azure_openai_settings["api_version"], # type: ignore
api_key=azure_openai_settings["api_key"].get_secret_value()
if azure_openai_settings["api_key"]
else None,
api_key=azure_openai_settings["api_key"].get_secret_value() if azure_openai_settings["api_key"] else None,
ad_token=ad_token,
ad_token_provider=ad_token_provider,
token_endpoint=azure_openai_settings["token_endpoint"],
@@ -49,8 +49,8 @@ class AgentInitializationError(AgentException):
pass
class AgentThreadException(AgentException):
"""An error occurred while managing the agent thread."""
class AgentSessionException(AgentException):
"""An error occurred while managing the agent session."""
pass
@@ -5,7 +5,7 @@ from typing import Any
IMPORT_PATH = "agent_framework_mem0"
PACKAGE_NAME = "agent-framework-mem0"
_IMPORTS = ["__version__", "Mem0Provider"]
_IMPORTS = ["__version__", "Mem0ContextProvider"]
def __getattr__(name: str) -> Any:
@@ -1,11 +1,11 @@
# Copyright (c) Microsoft. All rights reserved.
from agent_framework_mem0 import (
Mem0Provider,
Mem0ContextProvider,
__version__,
)
__all__ = [
"Mem0Provider",
"Mem0ContextProvider",
"__version__",
]
@@ -39,7 +39,7 @@ if TYPE_CHECKING: # pragma: no cover
from ._agents import SupportsAgentRun
from ._clients import SupportsChatGetResponse
from ._threads import AgentThread
from ._sessions import AgentSession
from ._tools import FunctionTool
from ._types import (
AgentResponse,
@@ -1280,7 +1280,7 @@ class AgentTelemetryLayer:
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: Literal[False] = ...,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]]: ...
@@ -1290,7 +1290,7 @@ class AgentTelemetryLayer:
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: Literal[True],
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
@@ -1299,7 +1299,7 @@ class AgentTelemetryLayer:
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]:
"""Trace agent runs with OpenTelemetry spans and metrics."""
@@ -1312,7 +1312,7 @@ class AgentTelemetryLayer:
return super_run( # type: ignore[no-any-return]
messages=messages,
stream=stream,
thread=thread,
session=session,
**kwargs,
)
@@ -1327,7 +1327,7 @@ class AgentTelemetryLayer:
agent_id=getattr(self, "id", "unknown"),
agent_name=getattr(self, "name", None) or getattr(self, "id", "unknown"),
agent_description=getattr(self, "description", None),
thread_id=thread.service_thread_id if thread else None,
thread_id=session.service_session_id if session else None,
all_options=merged_options,
**kwargs,
)
@@ -1336,7 +1336,7 @@ class AgentTelemetryLayer:
run_result = super_run(
messages=messages,
stream=True,
thread=thread,
session=session,
**kwargs,
)
if isinstance(run_result, ResponseStream):
@@ -1423,7 +1423,7 @@ class AgentTelemetryLayer:
response = await super_run(
messages=messages,
stream=False,
thread=thread,
session=session,
**kwargs,
)
except Exception as exception:
@@ -1557,7 +1557,7 @@ OTEL_ATTR_MAP: dict[str | tuple[str, ...], tuple[str, Callable[[Any], Any] | Non
"tools": (
OtelAttr.TOOL_DEFINITIONS,
lambda tools: (
json.dumps(tools_dict)
json.dumps(tools_dict, ensure_ascii=False)
if (tools_dict := __import__("agent_framework._tools", fromlist=["_tools_to_dict"])._tools_to_dict(tools))
else None
),
@@ -1639,12 +1639,14 @@ def _capture_messages(
)
if finish_reason:
otel_messages[-1]["finish_reason"] = FINISH_REASON_MAP[finish_reason]
span.set_attribute(OtelAttr.OUTPUT_MESSAGES if output else OtelAttr.INPUT_MESSAGES, json.dumps(otel_messages))
span.set_attribute(
OtelAttr.OUTPUT_MESSAGES if output else OtelAttr.INPUT_MESSAGES, json.dumps(otel_messages, ensure_ascii=False)
)
if system_instructions:
if not isinstance(system_instructions, list):
system_instructions = [system_instructions]
otel_sys_instructions = [{"type": "text", "content": instruction} for instruction in system_instructions]
span.set_attribute(OtelAttr.SYSTEM_INSTRUCTIONS, json.dumps(otel_sys_instructions))
span.set_attribute(OtelAttr.SYSTEM_INSTRUCTIONS, json.dumps(otel_sys_instructions, ensure_ascii=False))
def _to_otel_message(message: Message) -> dict[str, Any]:
@@ -13,8 +13,8 @@ from pydantic import BaseModel
from agent_framework._settings import SecretString, load_settings
from .._agents import Agent
from .._memory import ContextProvider
from .._middleware import MiddlewareTypes
from .._sessions import BaseContextProvider
from .._tools import FunctionTool
from .._types import normalize_tools
from ..exceptions import ServiceInitializationError
@@ -208,7 +208,7 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]):
metadata: dict[str, str] | None = None,
default_options: OptionsCoT | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
context_provider: ContextProvider | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
) -> Agent[OptionsCoT]:
"""Create a new assistant on OpenAI and return a Agent.
@@ -230,7 +230,7 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]):
These options are applied to every run unless overridden.
Include ``response_format`` here for structured output responses.
middleware: MiddlewareTypes for the Agent.
context_provider: Context provider for the Agent.
context_providers: Context providers for the Agent.
Returns:
A Agent instance wrapping the created assistant.
@@ -304,7 +304,7 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]):
tools=normalized_tools,
instructions=instructions,
middleware=middleware,
context_provider=context_provider,
context_providers=context_providers,
default_options=default_options,
)
@@ -316,7 +316,7 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]):
instructions: str | None = None,
default_options: OptionsCoT | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
context_provider: ContextProvider | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
) -> Agent[OptionsCoT]:
"""Retrieve an existing assistant by ID and return a Agent.
@@ -335,7 +335,7 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]):
default_options: A TypedDict containing default chat options for the agent.
These options are applied to every run unless overridden.
middleware: MiddlewareTypes for the Agent.
context_provider: Context provider for the Agent.
context_providers: Context providers for the Agent.
Returns:
A Agent instance wrapping the retrieved assistant.
@@ -371,7 +371,7 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]):
instructions=instructions,
default_options=default_options,
middleware=middleware,
context_provider=context_provider,
context_providers=context_providers,
)
def as_agent(
@@ -382,7 +382,7 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]):
instructions: str | None = None,
default_options: OptionsCoT | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
context_provider: ContextProvider | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
) -> Agent[OptionsCoT]:
"""Wrap an existing SDK Assistant object as a Agent.
@@ -400,7 +400,7 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]):
default_options: A TypedDict containing default chat options for the agent.
These options are applied to every run unless overridden.
middleware: MiddlewareTypes for the Agent.
context_provider: Context provider for the Agent.
context_providers: Context providers for the Agent.
Returns:
A Agent instance wrapping the assistant.
@@ -437,7 +437,7 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]):
instructions=instructions,
default_options=default_options,
middleware=middleware,
context_provider=context_provider,
context_providers=context_providers,
)
def _validate_function_tools(
@@ -524,7 +524,7 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]):
tools: list[FunctionTool | MutableMapping[str, Any]] | None,
instructions: str | None,
middleware: Sequence[MiddlewareTypes] | None,
context_provider: ContextProvider | None,
context_providers: Sequence[BaseContextProvider] | None,
default_options: OptionsCoT | None = None,
**kwargs: Any,
) -> Agent[OptionsCoT]:
@@ -535,7 +535,7 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]):
tools: Tools for the agent.
instructions: Instructions override.
middleware: MiddlewareTypes for the agent.
context_provider: Context provider for the agent.
context_providers: Context providers for the agent.
default_options: Default chat options for the agent (may include response_format).
**kwargs: Additional arguments passed to Agent.
@@ -563,7 +563,7 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]):
instructions=final_instructions,
tools=tools if tools else None,
middleware=middleware,
context_provider=context_provider,
context_providers=context_providers,
default_options=default_options, # type: ignore[arg-type]
**kwargs,
)
@@ -13,7 +13,7 @@ from collections.abc import (
)
from datetime import datetime, timezone
from itertools import chain
from typing import TYPE_CHECKING, Any, Generic, Literal, NoReturn, TypedDict, cast
from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, NoReturn, TypedDict, cast
from openai import AsyncOpenAI, BadRequestError
from openai.types.responses.file_search_tool_param import FileSearchToolParam
@@ -238,6 +238,8 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
Use ``OpenAIResponsesClient`` instead for a fully-featured client with all layers applied.
"""
STORES_BY_DEFAULT: ClassVar[bool] = True # type: ignore[reportIncompatibleVariableOverride, misc]
FILE_SEARCH_MAX_RESULTS: int = 50
# region Inner Methods
@@ -5,7 +5,7 @@ from typing import Any
IMPORT_PATH = "agent_framework_redis"
PACKAGE_NAME = "agent-framework-redis"
_IMPORTS = ["__version__", "RedisProvider", "RedisChatMessageStore"]
_IMPORTS = ["__version__", "RedisContextProvider", "RedisHistoryProvider"]
def __getattr__(name: str) -> Any:
@@ -1,13 +1,13 @@
# Copyright (c) Microsoft. All rights reserved.
from agent_framework_redis import (
RedisChatMessageStore,
RedisProvider,
RedisContextProvider,
RedisHistoryProvider,
__version__,
)
__all__ = [
"RedisChatMessageStore",
"RedisProvider",
"RedisContextProvider",
"RedisHistoryProvider",
"__version__",
]
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260210"
version = "1.0.0b260212"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -12,7 +12,7 @@ from agent_framework import (
Agent,
AgentResponse,
AgentResponseUpdate,
AgentThread,
AgentSession,
ChatResponse,
ChatResponseUpdate,
Message,
@@ -433,70 +433,70 @@ async def test_azure_assistants_agent_basic_run_streaming():
@pytest.mark.flaky
@skip_if_azure_integration_tests_disabled
async def test_azure_assistants_agent_thread_persistence():
"""Test Agent thread persistence across runs with AzureOpenAIAssistantsClient."""
async def test_azure_assistants_agent_session_persistence():
"""Test Agent session persistence across runs with AzureOpenAIAssistantsClient."""
async with Agent(
client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant with good memory.",
) as agent:
# Create a new thread that will be reused
thread = agent.get_new_thread()
# Create a new session that will be reused
session = agent.create_session()
# First message - establish context
first_response = await agent.run(
"Remember this number: 42. What number did I just tell you to remember?", thread=thread
"Remember this number: 42. What number did I just tell you to remember?", session=session
)
assert isinstance(first_response, AgentResponse)
assert "42" in first_response.text
# Second message - test conversation memory
second_response = await agent.run(
"What number did I tell you to remember in my previous message?", thread=thread
"What number did I tell you to remember in my previous message?", session=session
)
assert isinstance(second_response, AgentResponse)
assert "42" in second_response.text
# Verify thread has been populated with conversation ID
assert thread.service_thread_id is not None
# Verify session has been populated with conversation ID
assert session.service_session_id is not None
@pytest.mark.flaky
@skip_if_azure_integration_tests_disabled
async def test_azure_assistants_agent_existing_thread_id():
"""Test Agent with existing thread ID to continue conversations across agent instances."""
# First, create a conversation and capture the thread ID
existing_thread_id = None
async def test_azure_assistants_agent_existing_session_id():
"""Test Agent with existing session ID to continue conversations across agent instances."""
# First, create a conversation and capture the session ID
existing_session_id = None
async with Agent(
client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()),
instructions="You are a helpful weather agent.",
tools=[get_weather],
) as agent:
# Start a conversation and get the thread ID
thread = agent.get_new_thread()
response1 = await agent.run("What's the weather in Paris?", thread=thread)
# Start a conversation and get the session ID
session = agent.create_session()
response1 = await agent.run("What's the weather in Paris?", session=session)
# Validate first response
assert isinstance(response1, AgentResponse)
assert response1.text is not None
assert any(word in response1.text.lower() for word in ["weather", "paris"])
# The thread ID is set after the first response
existing_thread_id = thread.service_thread_id
assert existing_thread_id is not None
# The session ID is set after the first response
existing_session_id = session.service_session_id
assert existing_session_id is not None
# Now continue with the same thread ID in a new agent instance
# Now continue with the same session ID in a new agent instance
async with Agent(
client=AzureOpenAIAssistantsClient(thread_id=existing_thread_id, credential=AzureCliCredential()),
client=AzureOpenAIAssistantsClient(thread_id=existing_session_id, credential=AzureCliCredential()),
instructions="You are a helpful weather agent.",
tools=[get_weather],
) as agent:
# Create a thread with the existing ID
thread = AgentThread(service_thread_id=existing_thread_id)
# Create a session with the existing ID
session = AgentSession(service_session_id=existing_session_id)
# Ask about the previous conversation
response2 = await agent.run("What was the last city I asked about?", thread=thread)
response2 = await agent.run("What was the last city I asked about?", session=session)
# Validate that the agent remembers the previous conversation
assert isinstance(response2, AgentResponse)
@@ -800,23 +800,23 @@ async def test_azure_openai_chat_client_agent_basic_run_streaming():
@pytest.mark.flaky
@skip_if_azure_integration_tests_disabled
async def test_azure_openai_chat_client_agent_thread_persistence():
"""Test Azure OpenAI chat client agent thread persistence across runs with AzureOpenAIChatClient."""
async def test_azure_openai_chat_client_agent_session_persistence():
"""Test Azure OpenAI chat client agent session persistence across runs with AzureOpenAIChatClient."""
async with Agent(
client=AzureOpenAIChatClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant with good memory.",
) as agent:
# Create a new thread that will be reused
thread = agent.get_new_thread()
# Create a new session that will be reused
session = agent.create_session()
# First interaction
response1 = await agent.run("My name is Alice. Remember this.", thread=thread)
response1 = await agent.run("My name is Alice. Remember this.", session=session)
assert isinstance(response1, AgentResponse)
assert response1.text is not None
# Second interaction - test memory
response2 = await agent.run("What is my name?", thread=thread)
response2 = await agent.run("What is my name?", session=session)
assert isinstance(response2, AgentResponse)
assert response2.text is not None
@@ -825,33 +825,33 @@ async def test_azure_openai_chat_client_agent_thread_persistence():
@pytest.mark.flaky
@skip_if_azure_integration_tests_disabled
async def test_azure_openai_chat_client_agent_existing_thread():
"""Test Azure OpenAI chat client agent with existing thread to continue conversations across agent instances."""
# First conversation - capture the thread
preserved_thread = None
async def test_azure_openai_chat_client_agent_existing_session():
"""Test Azure OpenAI chat client agent with existing session to continue conversations across agent instances."""
# First conversation - capture the session
preserved_session = None
async with Agent(
client=AzureOpenAIChatClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant with good memory.",
) as first_agent:
# Start a conversation and capture the thread
thread = first_agent.get_new_thread()
first_response = await first_agent.run("My name is Alice. Remember this.", thread=thread)
# Start a conversation and capture the session
session = first_agent.create_session()
first_response = await first_agent.run("My name is Alice. Remember this.", session=session)
assert isinstance(first_response, AgentResponse)
assert first_response.text is not None
# Preserve the thread for reuse
preserved_thread = thread
# Preserve the session for reuse
preserved_session = session
# Second conversation - reuse the thread in a new agent instance
if preserved_thread:
# Second conversation - reuse the session in a new agent instance
if preserved_session:
async with Agent(
client=AzureOpenAIChatClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant with good memory.",
) as second_agent:
# Reuse the preserved thread
second_response = await second_agent.run("What is my name?", thread=preserved_thread)
# Reuse the preserved session
second_response = await second_agent.run("What is my name?", session=preserved_session)
assert isinstance(second_response, AgentResponse)
assert second_response.text is not None
@@ -537,33 +537,33 @@ async def test_integration_client_agent_hosted_code_interpreter_tool():
@pytest.mark.flaky
@skip_if_azure_integration_tests_disabled
async def test_integration_client_agent_existing_thread():
"""Test Azure Responses Client agent with existing thread to continue conversations across agent instances."""
# First conversation - capture the thread
preserved_thread = None
async def test_integration_client_agent_existing_session():
"""Test Azure Responses Client agent with existing session to continue conversations across agent instances."""
# First conversation - capture the session
preserved_session = None
async with Agent(
client=AzureOpenAIResponsesClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant with good memory.",
) as first_agent:
# Start a conversation and capture the thread
thread = first_agent.get_new_thread()
first_response = await first_agent.run("My hobby is photography. Remember this.", thread=thread, store=True)
# Start a conversation and capture the session
session = first_agent.create_session()
first_response = await first_agent.run("My hobby is photography. Remember this.", session=session, store=True)
assert isinstance(first_response, AgentResponse)
assert first_response.text is not None
# Preserve the thread for reuse
preserved_thread = thread
# Preserve the session for reuse
preserved_session = session
# Second conversation - reuse the thread in a new agent instance
if preserved_thread:
# Second conversation - reuse the session in a new agent instance
if preserved_session:
async with Agent(
client=AzureOpenAIResponsesClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant with good memory.",
) as second_agent:
# Reuse the preserved thread
second_response = await second_agent.run("What is my hobby?", thread=preserved_thread)
# Reuse the preserved session
second_response = await second_agent.run("What is my hobby?", session=preserved_session)
assert isinstance(second_response, AgentResponse)
assert second_response.text is not None
+13 -13
View File
@@ -13,7 +13,7 @@ from pytest import fixture
from agent_framework import (
AgentResponse,
AgentResponseUpdate,
AgentThread,
AgentSession,
BaseChatClient,
ChatMiddlewareLayer,
ChatResponse,
@@ -261,7 +261,7 @@ def chat_client_base(enable_function_calling: bool, max_iterations: int) -> Mock
# region Agents
class MockAgentThread(AgentThread):
class MockAgentSession(AgentSession):
pass
@@ -284,41 +284,41 @@ class MockAgent(SupportsAgentRun):
self,
messages: str | Message | list[str] | list[Message] | None = None,
*,
thread: AgentThread | None = None,
session: AgentSession | None = None,
stream: bool = False,
**kwargs: Any,
) -> Awaitable[AgentResponse] | AsyncIterable[AgentResponseUpdate]:
if stream:
return self._run_stream_impl(messages=messages, thread=thread, **kwargs)
return self._run_impl(messages=messages, thread=thread, **kwargs)
return self._run_stream_impl(messages=messages, session=session, **kwargs)
return self._run_impl(messages=messages, session=session, **kwargs)
async def _run_impl(
self,
messages: str | Message | list[str] | list[Message] | None = None,
*,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> AgentResponse:
logger.debug(f"Running mock agent, with: {messages=}, {thread=}, {kwargs=}")
logger.debug(f"Running mock agent, with: {messages=}, {session=}, {kwargs=}")
return AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Response")])])
async def _run_stream_impl(
self,
messages: str | Message | list[str] | list[Message] | None = None,
*,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> AsyncIterable[AgentResponseUpdate]:
logger.debug(f"Running mock agent stream, with: {messages=}, {thread=}, {kwargs=}")
logger.debug(f"Running mock agent stream, with: {messages=}, {session=}, {kwargs=}")
yield AgentResponseUpdate(contents=[Content.from_text("Response")])
def get_new_thread(self) -> AgentThread:
return MockAgentThread()
def create_session(self) -> AgentSession:
return MockAgentSession()
@fixture
def agent_thread() -> AgentThread:
return MockAgentThread()
def agent_session() -> AgentSession:
return MockAgentSession()
@fixture
+299 -186
View File
@@ -1,7 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
import contextlib
from collections.abc import AsyncIterable, MutableSequence, Sequence
from collections.abc import AsyncIterable, MutableSequence
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
@@ -13,13 +13,12 @@ from agent_framework import (
Agent,
AgentResponse,
AgentResponseUpdate,
AgentThread,
ChatMessageStore,
AgentSession,
BaseContextProvider,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
Content,
Context,
ContextProvider,
FunctionTool,
Message,
SupportsAgentRun,
@@ -28,11 +27,10 @@ from agent_framework import (
)
from agent_framework._agents import _merge_options, _sanitize_agent_name
from agent_framework._mcp import MCPTool
from agent_framework.exceptions import AgentExecutionException, AgentInitializationError
def test_agent_thread_type(agent_thread: AgentThread) -> None:
assert isinstance(agent_thread, AgentThread)
def test_agent_session_type(agent_session: AgentSession) -> None:
assert isinstance(agent_session, AgentSession)
def test_agent_type(agent: SupportsAgentRun) -> None:
@@ -93,38 +91,42 @@ async def test_chat_client_agent_run_streaming(client: SupportsChatGetResponse)
assert result.text == "test streaming response another update"
async def test_chat_client_agent_get_new_thread(client: SupportsChatGetResponse) -> None:
async def test_chat_client_agent_create_session(client: SupportsChatGetResponse) -> None:
agent = Agent(client=client)
thread = agent.get_new_thread()
session = agent.create_session()
assert isinstance(thread, AgentThread)
assert isinstance(session, AgentSession)
async def test_chat_client_agent_prepare_thread_and_messages(client: SupportsChatGetResponse) -> None:
agent = Agent(client=client)
async def test_chat_client_agent_prepare_session_and_messages(client: SupportsChatGetResponse) -> None:
from agent_framework._sessions import InMemoryHistoryProvider
agent = Agent(client=client, context_providers=[InMemoryHistoryProvider("memory")])
message = Message(role="user", text="Hello")
thread = AgentThread(message_store=ChatMessageStore(messages=[message]))
session = AgentSession()
session.state["memory"] = {"messages": [message]}
_, _, result_messages = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
thread=thread,
session_context, _ = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
session=session,
input_messages=[Message(role="user", text="Test")],
)
result_messages = session_context.get_messages(include_input=True)
assert len(result_messages) == 2
assert result_messages[0] == message
assert result_messages[0].text == "Hello"
assert result_messages[1].text == "Test"
async def test_prepare_thread_does_not_mutate_agent_chat_options(client: SupportsChatGetResponse) -> None:
async def test_prepare_session_does_not_mutate_agent_chat_options(client: SupportsChatGetResponse) -> None:
tool = {"type": "code_interpreter"}
agent = Agent(client=client, tools=[tool])
assert agent.default_options.get("tools") is not None
base_tools = agent.default_options["tools"]
thread = agent.get_new_thread()
session = agent.create_session()
_, prepared_chat_options, _ = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
thread=thread,
_, prepared_chat_options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
session=session,
input_messages=[Message(role="user", text="Test")],
)
@@ -135,7 +137,7 @@ async def test_prepare_thread_does_not_mutate_agent_chat_options(client: Support
assert len(agent.default_options["tools"]) == 1
async def test_chat_client_agent_update_thread_id(chat_client_base: SupportsChatGetResponse) -> None:
async def test_chat_client_agent_run_with_session(chat_client_base: SupportsChatGetResponse) -> None:
mock_response = ChatResponse(
messages=[Message(role="assistant", contents=[Content.from_text("test response")])],
conversation_id="123",
@@ -145,25 +147,129 @@ async def test_chat_client_agent_update_thread_id(chat_client_base: SupportsChat
client=chat_client_base,
tools={"type": "code_interpreter"},
)
thread = agent.get_new_thread()
session = agent.get_session(service_session_id="123")
result = await agent.run("Hello", thread=thread)
result = await agent.run("Hello", session=session)
assert result.text == "test response"
assert thread.service_thread_id == "123"
assert session.service_session_id == "123"
async def test_chat_client_agent_update_thread_messages(client: SupportsChatGetResponse) -> None:
async def test_chat_client_agent_updates_existing_session_id_non_streaming(
chat_client_base: SupportsChatGetResponse,
) -> None:
chat_client_base.run_responses = [
ChatResponse(
messages=[Message(role="assistant", contents=[Content.from_text("test response")])],
conversation_id="resp_new_123",
)
]
agent = Agent(client=chat_client_base)
session = agent.get_session(service_session_id="resp_old_123")
await agent.run("Hello", session=session)
assert session.service_session_id == "resp_new_123"
async def test_chat_client_agent_update_session_id_streaming_uses_conversation_id(
chat_client_base: SupportsChatGetResponse,
) -> None:
chat_client_base.streaming_responses = [
[
ChatResponseUpdate(
contents=[Content.from_text("stream part 1")],
role="assistant",
response_id="resp_stream_123",
conversation_id="conv_stream_456",
),
ChatResponseUpdate(
contents=[Content.from_text(" stream part 2")],
role="assistant",
response_id="resp_stream_123",
conversation_id="conv_stream_456",
finish_reason="stop",
),
]
]
agent = Agent(client=chat_client_base)
session = agent.create_session()
stream = agent.run("Hello", session=session, stream=True)
async for _ in stream:
pass
result = await stream.get_final_response()
assert result.text == "stream part 1 stream part 2"
assert session.service_session_id == "conv_stream_456"
async def test_chat_client_agent_updates_existing_session_id_streaming(
chat_client_base: SupportsChatGetResponse,
) -> None:
chat_client_base.streaming_responses = [
[
ChatResponseUpdate(
contents=[Content.from_text("stream part 1")],
role="assistant",
response_id="resp_stream_123",
conversation_id="resp_new_456",
),
ChatResponseUpdate(
contents=[Content.from_text(" stream part 2")],
role="assistant",
response_id="resp_stream_123",
conversation_id="resp_new_456",
finish_reason="stop",
),
]
]
agent = Agent(client=chat_client_base)
session = agent.get_session(service_session_id="resp_old_456")
stream = agent.run("Hello", session=session, stream=True)
async for _ in stream:
pass
await stream.get_final_response()
assert session.service_session_id == "resp_new_456"
async def test_chat_client_agent_update_session_id_streaming_does_not_use_response_id(
chat_client_base: SupportsChatGetResponse,
) -> None:
chat_client_base.streaming_responses = [
[
ChatResponseUpdate(
contents=[Content.from_text("stream response without conversation id")],
role="assistant",
response_id="resp_only_123",
finish_reason="stop",
),
]
]
agent = Agent(client=chat_client_base)
session = agent.create_session()
stream = agent.run("Hello", session=session, stream=True)
async for _ in stream:
pass
result = await stream.get_final_response()
assert result.text == "stream response without conversation id"
assert session.service_session_id is None
async def test_chat_client_agent_update_session_messages(client: SupportsChatGetResponse) -> None:
agent = Agent(client=client)
thread = agent.get_new_thread()
session = agent.create_session()
result = await agent.run("Hello", thread=thread)
result = await agent.run("Hello", session=session)
assert result.text == "test response"
assert thread.service_thread_id is None
assert thread.message_store is not None
assert session.service_session_id is None
chat_messages: list[Message] = await thread.message_store.list_messages()
chat_messages: list[Message] = session.state.get("memory", {}).get("messages", [])
assert chat_messages is not None
assert len(chat_messages) == 2
@@ -171,12 +277,12 @@ async def test_chat_client_agent_update_thread_messages(client: SupportsChatGetR
assert chat_messages[1].text == "test response"
async def test_chat_client_agent_update_thread_conversation_id_missing(client: SupportsChatGetResponse) -> None:
async def test_chat_client_agent_update_session_conversation_id_missing(client: SupportsChatGetResponse) -> None:
agent = Agent(client=client)
thread = AgentThread(service_thread_id="123")
session = agent.get_session(service_session_id="123")
with raises(AgentExecutionException, match="Service did not return a valid conversation id"):
await agent._update_thread_with_type_and_conversation_id(thread, None) # type: ignore[reportPrivateUsage]
# With the session-based API, service_session_id is managed directly on the session
assert session.service_session_id == "123"
async def test_chat_client_agent_default_author_name(client: SupportsChatGetResponse) -> None:
@@ -214,54 +320,41 @@ async def test_chat_client_agent_author_name_is_used_from_response(chat_client_b
# Mock context provider for testing
class MockContextProvider(ContextProvider):
class MockContextProvider(BaseContextProvider):
def __init__(self, messages: list[Message] | None = None) -> None:
super().__init__(source_id="mock")
self.context_messages = messages
self.thread_created_called = False
self.invoked_called = False
self.invoking_called = False
self.thread_created_thread_id = None
self.invoked_thread_id = None
self.before_run_called = False
self.after_run_called = False
self.new_messages: list[Message] = []
self.last_service_session_id: str | None = None
async def thread_created(self, thread_id: str | None) -> None:
self.thread_created_called = True
self.thread_created_thread_id = thread_id
async def before_run(self, *, agent: Any, session: Any, context: Any, state: Any) -> None:
self.before_run_called = True
if self.context_messages:
context.extend_messages(self, self.context_messages)
async def invoked(
self,
request_messages: Message | Sequence[Message],
response_messages: Message | Sequence[Message] | None = None,
invoke_exception: Any = None,
**kwargs: Any,
) -> None:
self.invoked_called = True
if isinstance(request_messages, Message):
self.new_messages.append(request_messages)
else:
self.new_messages.extend(request_messages)
if isinstance(response_messages, Message):
self.new_messages.append(response_messages)
else:
self.new_messages.extend(response_messages)
async def invoking(self, messages: Message | MutableSequence[Message], **kwargs: Any) -> Context:
self.invoking_called = True
return Context(messages=self.context_messages)
async def after_run(self, *, agent: Any, session: Any, context: Any, state: Any) -> None:
self.after_run_called = True
if session:
self.last_service_session_id = session.service_session_id
if context.response:
self.new_messages.extend(context.input_messages)
self.new_messages.extend(context.response.messages)
async def test_chat_agent_context_providers_model_invoking(client: SupportsChatGetResponse) -> None:
"""Test that context providers' invoking is called during agent run."""
async def test_chat_agent_context_providers_model_before_run(client: SupportsChatGetResponse) -> None:
"""Test that context providers' before_run is called during agent run."""
mock_provider = MockContextProvider(messages=[Message(role="system", text="Test context instructions")])
agent = Agent(client=client, context_provider=mock_provider)
agent = Agent(client=client, context_providers=[mock_provider])
await agent.run("Hello")
assert mock_provider.invoking_called
assert mock_provider.before_run_called
async def test_chat_agent_context_providers_thread_created(chat_client_base: SupportsChatGetResponse) -> None:
"""Test that context providers' thread_created is called during agent run."""
async def test_chat_agent_context_providers_after_run(chat_client_base: SupportsChatGetResponse) -> None:
"""Test that context providers' after_run is called during agent run."""
mock_provider = MockContextProvider()
chat_client_base.run_responses = [
ChatResponse(
@@ -270,22 +363,23 @@ async def test_chat_agent_context_providers_thread_created(chat_client_base: Sup
)
]
agent = Agent(client=chat_client_base, context_provider=mock_provider)
agent = Agent(client=chat_client_base, context_providers=[mock_provider])
await agent.run("Hello")
session = agent.get_session(service_session_id="test-thread-id")
await agent.run("Hello", session=session)
assert mock_provider.thread_created_called
assert mock_provider.thread_created_thread_id == "test-thread-id"
assert mock_provider.after_run_called
assert mock_provider.last_service_session_id == "test-thread-id"
async def test_chat_agent_context_providers_messages_adding(client: SupportsChatGetResponse) -> None:
"""Test that context providers' invoked is called during agent run."""
"""Test that context providers' after_run is called during agent run."""
mock_provider = MockContextProvider()
agent = Agent(client=client, context_provider=mock_provider)
agent = Agent(client=client, context_providers=[mock_provider])
await agent.run("Hello")
assert mock_provider.invoked_called
assert mock_provider.after_run_called
# Should be called with both input and response messages
assert len(mock_provider.new_messages) >= 2
@@ -293,12 +387,13 @@ async def test_chat_agent_context_providers_messages_adding(client: SupportsChat
async def test_chat_agent_context_instructions_in_messages(client: SupportsChatGetResponse) -> None:
"""Test that AI context instructions are included in messages."""
mock_provider = MockContextProvider(messages=[Message(role="system", text="Context-specific instructions")])
agent = Agent(client=client, instructions="Agent instructions", context_provider=mock_provider)
agent = Agent(client=client, instructions="Agent instructions", context_providers=[mock_provider])
# We need to test the _prepare_thread_and_messages method directly
_, _, messages = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
thread=None, input_messages=[Message(role="user", text="Hello")]
# We need to test the _prepare_session_and_messages method directly
session_context, _ = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
session=None, input_messages=[Message(role="user", text="Hello")]
)
messages = session_context.get_messages(include_input=True)
# Should have context instructions, and user message
assert len(messages) == 2
@@ -312,11 +407,12 @@ async def test_chat_agent_context_instructions_in_messages(client: SupportsChatG
async def test_chat_agent_no_context_instructions(client: SupportsChatGetResponse) -> None:
"""Test behavior when AI context has no instructions."""
mock_provider = MockContextProvider()
agent = Agent(client=client, instructions="Agent instructions", context_provider=mock_provider)
agent = Agent(client=client, instructions="Agent instructions", context_providers=[mock_provider])
_, _, messages = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
thread=None, input_messages=[Message(role="user", text="Hello")]
session_context, _ = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
session=None, input_messages=[Message(role="user", text="Hello")]
)
messages = session_context.get_messages(include_input=True)
# Should have agent instructions and user message only
assert len(messages) == 1
@@ -327,7 +423,7 @@ async def test_chat_agent_no_context_instructions(client: SupportsChatGetRespons
async def test_chat_agent_run_stream_context_providers(client: SupportsChatGetResponse) -> None:
"""Test that context providers work with run method."""
mock_provider = MockContextProvider(messages=[Message(role="system", text="Stream context instructions")])
agent = Agent(client=client, context_provider=mock_provider)
agent = Agent(client=client, context_providers=[mock_provider])
# Collect all stream updates and get final response
stream = agent.run("Hello", stream=True)
@@ -338,14 +434,12 @@ async def test_chat_agent_run_stream_context_providers(client: SupportsChatGetRe
await stream.get_final_response()
# Verify context provider was called
assert mock_provider.invoking_called
# no conversation id is created, so no need to thread_create to be called.
assert not mock_provider.thread_created_called
assert mock_provider.invoked_called
assert mock_provider.before_run_called
assert mock_provider.after_run_called
async def test_chat_agent_context_providers_with_thread_service_id(chat_client_base: SupportsChatGetResponse) -> None:
"""Test context providers with service-managed thread."""
async def test_chat_agent_context_providers_with_service_session_id(chat_client_base: SupportsChatGetResponse) -> None:
"""Test context providers with service-managed session."""
mock_provider = MockContextProvider()
chat_client_base.run_responses = [
ChatResponse(
@@ -354,14 +448,14 @@ async def test_chat_agent_context_providers_with_thread_service_id(chat_client_b
)
]
agent = Agent(client=chat_client_base, context_provider=mock_provider)
agent = Agent(client=chat_client_base, context_providers=[mock_provider])
# Use existing service-managed thread
thread = agent.get_new_thread(service_thread_id="existing-thread-id")
await agent.run("Hello", thread=thread)
# Use existing service-managed session
session = agent.get_session(service_session_id="existing-thread-id")
await agent.run("Hello", session=session)
# invoked should be called with the service thread ID from response
assert mock_provider.invoked_called
# after_run should be called
assert mock_provider.after_run_called
# Tests for as_tool method
@@ -562,16 +656,16 @@ async def test_chat_agent_with_local_mcp_tools(client: SupportsChatGetResponse)
pass
async def test_agent_tool_receives_thread_in_kwargs(chat_client_base: Any) -> None:
"""Verify tool execution receives 'thread' inside **kwargs when function is called by client."""
async def test_agent_tool_receives_session_in_kwargs(chat_client_base: Any) -> None:
"""Verify tool execution receives 'session' inside **kwargs when function is called by client."""
captured: dict[str, Any] = {}
@tool(name="echo_thread_info", approval_mode="never_require")
def echo_thread_info(text: str, **kwargs: Any) -> str: # type: ignore[reportUnknownParameterType]
thread = kwargs.get("thread")
captured["has_thread"] = thread is not None
captured["has_message_store"] = thread.message_store is not None if isinstance(thread, AgentThread) else False
@tool(name="echo_session_info", approval_mode="never_require")
def echo_session_info(text: str, **kwargs: Any) -> str: # type: ignore[reportUnknownParameterType]
session = kwargs.get("session")
captured["has_session"] = session is not None
captured["has_state"] = session.state is not None if isinstance(session, AgentSession) else False
return f"echo: {text}"
# Make the base client emit a function call for our tool
@@ -580,21 +674,21 @@ async def test_agent_tool_receives_thread_in_kwargs(chat_client_base: Any) -> No
messages=Message(
role="assistant",
contents=[
Content.from_function_call(call_id="1", name="echo_thread_info", arguments='{"text": "hello"}')
Content.from_function_call(call_id="1", name="echo_session_info", arguments='{"text": "hello"}')
],
)
),
ChatResponse(messages=Message(role="assistant", text="done")),
]
agent = Agent(client=chat_client_base, tools=[echo_thread_info], chat_message_store_factory=ChatMessageStore)
thread = agent.get_new_thread()
agent = Agent(client=chat_client_base, tools=[echo_session_info])
session = agent.create_session()
result = await agent.run("hello", thread=thread, options={"additional_function_arguments": {"thread": thread}})
result = await agent.run("hello", session=session, options={"additional_function_arguments": {"session": session}})
assert result.text == "done"
assert captured.get("has_thread") is True
assert captured.get("has_message_store") is True
assert captured.get("has_session") is True
assert captured.get("has_state") is True
async def test_chat_agent_tool_choice_run_level_overrides_agent_level(chat_client_base: Any, tool_tool: Any) -> None:
@@ -801,73 +895,67 @@ def test_sanitize_agent_name_replaces_invalid_chars():
# endregion
# region Test SupportsAgentRun.get_new_thread and deserialize_thread
# region Test SupportsAgentRun.create_session
@pytest.mark.asyncio
async def test_agent_get_new_thread(chat_client_base: SupportsChatGetResponse, tool_tool: FunctionTool):
"""Test that get_new_thread returns a new AgentThread."""
async def test_agent_create_session(chat_client_base: SupportsChatGetResponse, tool_tool: FunctionTool):
"""Test that create_session returns a new AgentSession."""
agent = Agent(client=chat_client_base, tools=[tool_tool])
thread = agent.get_new_thread()
session = agent.create_session()
assert thread is not None
assert isinstance(thread, AgentThread)
assert session is not None
assert isinstance(session, AgentSession)
@pytest.mark.asyncio
async def test_agent_get_new_thread_with_context_provider(
async def test_agent_create_session_with_context_providers(
chat_client_base: SupportsChatGetResponse, tool_tool: FunctionTool
):
"""Test that get_new_thread passes context_provider to the thread."""
"""Test that create_session works when context_providers are set on the agent."""
class TestContextProvider(ContextProvider):
async def invoking(self, messages, **kwargs):
return Context()
class TestContextProvider(BaseContextProvider):
def __init__(self):
super().__init__(source_id="test")
provider = TestContextProvider()
agent = Agent(client=chat_client_base, tools=[tool_tool], context_provider=provider)
agent = Agent(client=chat_client_base, tools=[tool_tool], context_providers=[provider])
thread = agent.get_new_thread()
session = agent.create_session()
assert thread is not None
assert thread.context_provider is provider
assert session is not None
assert agent.context_providers[0] is provider
@pytest.mark.asyncio
async def test_agent_get_new_thread_with_service_thread_id(
async def test_agent_get_session_with_service_session_id(
chat_client_base: SupportsChatGetResponse, tool_tool: FunctionTool
):
"""Test that get_new_thread passes kwargs like service_thread_id to the thread."""
"""Test that get_session creates a session with service_session_id."""
agent = Agent(client=chat_client_base, tools=[tool_tool])
thread = agent.get_new_thread(service_thread_id="test-thread-123")
session = agent.get_session(service_session_id="test-thread-123")
assert thread is not None
assert thread.service_thread_id == "test-thread-123"
assert session is not None
assert session.service_session_id == "test-thread-123"
@pytest.mark.asyncio
async def test_agent_deserialize_thread(chat_client_base: SupportsChatGetResponse, tool_tool: FunctionTool):
"""Test deserialize_thread restores a thread from serialized state."""
agent = Agent(client=chat_client_base, tools=[tool_tool])
# Create serialized thread state with messages
def test_agent_session_from_dict(chat_client_base: SupportsChatGetResponse, tool_tool: FunctionTool):
"""Test AgentSession.from_dict restores a session from serialized state."""
# Create serialized session state
serialized_state = {
"service_thread_id": None,
"chat_message_store_state": {
"messages": [{"role": "user", "text": "Hello"}],
},
"type": "session",
"session_id": "test-session",
"service_session_id": None,
"state": {},
}
thread = await agent.deserialize_thread(serialized_state)
session = AgentSession.from_dict(serialized_state)
assert thread is not None
assert isinstance(thread, AgentThread)
assert thread.message_store is not None
messages = await thread.message_store.list_messages()
assert len(messages) == 1
assert messages[0].text == "Hello"
assert session is not None
assert isinstance(session, AgentSession)
assert session.session_id == "test-session"
# endregion
@@ -876,20 +964,6 @@ async def test_agent_deserialize_thread(chat_client_base: SupportsChatGetRespons
# region Test Agent initialization edge cases
@pytest.mark.asyncio
async def test_chat_agent_raises_with_both_conversation_id_and_store():
"""Test Agent raises error with both conversation_id and chat_message_store_factory."""
mock_client = MagicMock()
mock_store_factory = MagicMock()
with pytest.raises(AgentInitializationError, match="Cannot specify both"):
Agent(
client=mock_client,
default_options={"conversation_id": "test_id"},
chat_message_store_factory=mock_store_factory,
)
def test_chat_agent_calls_update_agent_name_on_client():
"""Test that Agent calls _update_agent_name_and_description on client if available."""
mock_client = MagicMock()
@@ -914,19 +988,22 @@ async def test_chat_agent_context_provider_adds_tools_when_agent_has_none(chat_c
"""A tool provided by context."""
return text
class ToolContextProvider(ContextProvider):
async def invoking(self, messages, **kwargs):
return Context(tools=[context_tool])
class ToolContextProvider(BaseContextProvider):
def __init__(self):
super().__init__(source_id="tool-context")
async def before_run(self, *, agent, session, context, state):
context.extend_tools("tool-context", [context_tool])
provider = ToolContextProvider()
agent = Agent(client=chat_client_base, context_provider=provider)
agent = Agent(client=chat_client_base, context_providers=[provider])
# Agent starts with empty tools list
assert agent.default_options.get("tools") == []
# Run the agent and verify context tools are added
_, options, _ = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
thread=None, input_messages=[Message(role="user", text="Hello")]
_, options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
session=None, input_messages=[Message(role="user", text="Hello")]
)
# The context tools should now be in the options
@@ -940,40 +1017,76 @@ async def test_chat_agent_context_provider_adds_instructions_when_agent_has_none
):
"""Test that context provider instructions are used when agent has no default instructions."""
class InstructionContextProvider(ContextProvider):
async def invoking(self, messages, **kwargs):
return Context(instructions="Context-provided instructions")
class InstructionContextProvider(BaseContextProvider):
def __init__(self):
super().__init__(source_id="instruction-context")
async def before_run(self, *, agent, session, context, state):
context.extend_instructions("instruction-context", "Context-provided instructions")
provider = InstructionContextProvider()
agent = Agent(client=chat_client_base, context_provider=provider)
agent = Agent(client=chat_client_base, context_providers=[provider])
# Verify agent has no default instructions
assert agent.default_options.get("instructions") is None
# Run the agent and verify context instructions are available
_, options, _ = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
thread=None, input_messages=[Message(role="user", text="Hello")]
_, options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
session=None, input_messages=[Message(role="user", text="Hello")]
)
# The context instructions should now be in the options
assert options.get("instructions") == "Context-provided instructions"
@pytest.mark.asyncio
async def test_chat_agent_raises_on_conversation_id_mismatch(chat_client_base: SupportsChatGetResponse):
"""Test that Agent raises when thread and agent have different conversation IDs."""
agent = Agent(
client=chat_client_base,
default_options={"conversation_id": "agent-conversation-id"},
)
# region STORES_BY_DEFAULT tests
# Create a thread with a different service_thread_id
thread = AgentThread(service_thread_id="different-thread-id")
with pytest.raises(AgentExecutionException, match="conversation_id set on the agent is different"):
await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
thread=thread, input_messages=[Message(role="user", text="Hello")]
)
async def test_stores_by_default_skips_inmemory_injection(client: SupportsChatGetResponse) -> None:
"""Client with STORES_BY_DEFAULT=True should not auto-inject InMemoryHistoryProvider."""
from agent_framework._sessions import InMemoryHistoryProvider
# Simulate a client that stores by default
client.STORES_BY_DEFAULT = True # type: ignore[attr-defined]
agent = Agent(client=client)
session = agent.create_session()
await agent.run("Hello", session=session)
# No InMemoryHistoryProvider should have been injected
assert not any(isinstance(p, InMemoryHistoryProvider) for p in agent.context_providers)
async def test_stores_by_default_false_injects_inmemory(client: SupportsChatGetResponse) -> None:
"""Client with STORES_BY_DEFAULT=False (default) should auto-inject InMemoryHistoryProvider."""
from agent_framework._sessions import InMemoryHistoryProvider
agent = Agent(client=client)
session = agent.create_session()
await agent.run("Hello", session=session)
# InMemoryHistoryProvider should have been injected
assert any(isinstance(p, InMemoryHistoryProvider) for p in agent.context_providers)
async def test_stores_by_default_with_store_false_injects_inmemory(client: SupportsChatGetResponse) -> None:
"""Client with STORES_BY_DEFAULT=True but store=False should still inject InMemoryHistoryProvider."""
from agent_framework._sessions import InMemoryHistoryProvider
client.STORES_BY_DEFAULT = True # type: ignore[attr-defined]
agent = Agent(client=client)
session = agent.create_session()
await agent.run("Hello", session=session, options={"store": False})
# User explicitly disabled server storage, so InMemoryHistoryProvider should be injected
assert any(isinstance(p, InMemoryHistoryProvider) for p in agent.context_providers)
# endregion
# endregion
@@ -168,8 +168,8 @@ async def test_function_invocation_inside_aiohttp_server(chat_client_base: Suppo
agent = Agent(client=chat_client_base, tools=[ai_func])
async def handler(request: web.Request) -> web.Response:
thread = agent.get_new_thread()
result = await agent.run("Fix issue", thread=thread)
session = agent.create_session()
result = await agent.run("Fix issue", session=session)
return web.Response(text=result.text or "")
app = web.Application()
@@ -230,8 +230,8 @@ async def test_function_invocation_in_threaded_aiohttp_app(chat_client_base: Sup
async def init_app() -> web.Application:
async def handler(request: web.Request) -> web.Response:
thread = agent.get_new_thread()
result = await agent.run("Fix issue", thread=thread)
session = agent.create_session()
result = await agent.run("Fix issue", session=session)
return web.Response(text=result.text or "")
app = web.Application()
+3 -5
View File
@@ -25,8 +25,8 @@ from agent_framework._mcp import (
_get_input_model_from_mcp_tool,
_normalize_mcp_name,
_parse_content_from_mcp,
_parse_tool_result_from_mcp,
_parse_message_from_mcp,
_parse_tool_result_from_mcp,
_prepare_content_for_mcp,
_prepare_message_for_mcp,
logger,
@@ -97,9 +97,7 @@ def test_parse_tool_result_from_mcp():
def test_parse_tool_result_from_mcp_single_text():
"""Test conversion from MCP tool result with a single text item."""
mcp_result = types.CallToolResult(
content=[types.TextContent(type="text", text="Simple result")]
)
mcp_result = types.CallToolResult(content=[types.TextContent(type="text", text="Simple result")])
result = _parse_tool_result_from_mcp(mcp_result)
# Single text item returns just the text
@@ -2590,7 +2588,7 @@ async def test_mcp_tool_filters_framework_kwargs():
chat_options={"some": "option"}, # Should be filtered
tools=[Mock()], # Should be filtered
tool_choice="auto", # Should be filtered
thread=Mock(), # Should be filtered
session=Mock(), # Should be filtered
conversation_id="conv-123", # Should be filtered
options={"metadata": "value"}, # Should be filtered
)
@@ -1,136 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import sys
from collections.abc import MutableSequence
from typing import Any
from agent_framework import Message
from agent_framework._memory import Context, ContextProvider
class MockContextProvider(ContextProvider):
"""Mock ContextProvider for testing."""
def __init__(self, messages: list[Message] | None = None) -> None:
self.context_messages = messages
self.thread_created_called = False
self.invoked_called = False
self.invoking_called = False
self.thread_created_thread_id = None
self.new_messages = None
self.model_invoking_messages = None
async def thread_created(self, thread_id: str | None) -> None:
"""Track thread_created calls."""
self.thread_created_called = True
self.thread_created_thread_id = thread_id
async def invoked(
self,
request_messages: Any,
response_messages: Any | None = None,
invoke_exception: Exception | None = None,
**kwargs: Any,
) -> None:
"""Track invoked calls."""
self.invoked_called = True
self.new_messages = request_messages
async def invoking(self, messages: Message | MutableSequence[Message], **kwargs: Any) -> Context:
"""Track invoking calls and return context."""
self.invoking_called = True
self.model_invoking_messages = messages
context = Context()
context.messages = self.context_messages
return context
class MinimalContextProvider(ContextProvider):
"""Minimal ContextProvider that only implements the required abstract method.
Used to test the base class default implementations of thread_created,
invoked, __aenter__, and __aexit__.
"""
async def invoking(self, messages: Message | MutableSequence[Message], **kwargs: Any) -> Context:
"""Return empty context."""
return Context()
class TestContext:
"""Tests for Context class."""
def test_context_default_values(self) -> None:
"""Test Context has correct default values."""
context = Context()
assert context.instructions is None
assert context.messages == []
assert context.tools == []
def test_context_with_values(self) -> None:
"""Test Context can be initialized with values."""
messages = [Message(role="user", text="Test message")]
context = Context(instructions="Test instructions", messages=messages)
assert context.instructions == "Test instructions"
assert len(context.messages) == 1
assert context.messages[0].text == "Test message"
class TestContextProvider:
"""Tests for ContextProvider class."""
async def test_thread_created(self) -> None:
"""Test thread_created is called."""
provider = MockContextProvider()
await provider.thread_created("test-thread-id")
assert provider.thread_created_called
assert provider.thread_created_thread_id == "test-thread-id"
async def test_invoked(self) -> None:
"""Test invoked is called."""
provider = MockContextProvider()
message = Message(role="user", text="Test message")
await provider.invoked(message)
assert provider.invoked_called
assert provider.new_messages == message
async def test_invoking(self) -> None:
"""Test invoking is called and returns context."""
provider = MockContextProvider(messages=[Message(role="user", text="Context message")])
message = Message(role="user", text="Test message")
context = await provider.invoking(message)
assert provider.invoking_called
assert provider.model_invoking_messages == message
assert context.messages is not None
assert len(context.messages) == 1
assert context.messages[0].text == "Context message"
async def test_base_thread_created_does_nothing(self) -> None:
"""Test that base ContextProvider.thread_created does nothing by default."""
provider = MinimalContextProvider()
await provider.thread_created("some-thread-id")
await provider.thread_created(None)
async def test_base_invoked_does_nothing(self) -> None:
"""Test that base ContextProvider.invoked does nothing by default."""
provider = MinimalContextProvider()
message = Message(role="user", text="Test")
await provider.invoked(message)
await provider.invoked(message, response_messages=message)
await provider.invoked(message, invoke_exception=Exception("test"))
async def test_base_aenter_returns_self(self) -> None:
"""Test that base ContextProvider.__aenter__ returns self."""
provider = MinimalContextProvider()
async with provider as p:
assert p is provider
async def test_base_aexit_does_nothing(self) -> None:
"""Test that base ContextProvider.__aexit__ handles exceptions gracefully."""
provider = MinimalContextProvider()
await provider.__aexit__(None, None, None)
try:
raise ValueError("test error")
except ValueError:
exc_info = sys.exc_info()
await provider.__aexit__(exc_info[0], exc_info[1], exc_info[2])
@@ -56,17 +56,17 @@ class TestAgentContext:
assert context.stream is True
assert context.metadata == metadata
def test_init_with_thread(self, mock_agent: SupportsAgentRun) -> None:
"""Test AgentContext initialization with thread parameter."""
from agent_framework import AgentThread
def test_init_with_session(self, mock_agent: SupportsAgentRun) -> None:
"""Test AgentContext initialization with session parameter."""
from agent_framework import AgentSession
messages = [Message(role="user", text="test")]
thread = AgentThread()
context = AgentContext(agent=mock_agent, messages=messages, thread=thread)
session = AgentSession()
context = AgentContext(agent=mock_agent, messages=messages, session=session)
assert context.agent is mock_agent
assert context.messages == messages
assert context.thread is thread
assert context.session is session
assert context.stream is False
assert context.metadata == {}
@@ -356,23 +356,23 @@ class TestAgentMiddlewarePipeline:
assert updates[1].text == "chunk2"
assert execution_order == ["handler_start", "handler_end"]
async def test_execute_with_thread_in_context(self, mock_agent: SupportsAgentRun) -> None:
"""Test pipeline execution properly passes thread to middleware."""
from agent_framework import AgentThread
async def test_execute_with_session_in_context(self, mock_agent: SupportsAgentRun) -> None:
"""Test pipeline execution properly passes session to middleware."""
from agent_framework import AgentSession
captured_thread = None
captured_session = None
class ThreadCapturingMiddleware(AgentMiddleware):
class SessionCapturingMiddleware(AgentMiddleware):
async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None:
nonlocal captured_thread
captured_thread = context.thread
nonlocal captured_session
captured_session = context.session
await call_next()
middleware = ThreadCapturingMiddleware()
middleware = SessionCapturingMiddleware()
pipeline = AgentMiddlewarePipeline(middleware)
messages = [Message(role="user", text="test")]
thread = AgentThread()
context = AgentContext(agent=mock_agent, messages=messages, thread=thread)
session = AgentSession()
context = AgentContext(agent=mock_agent, messages=messages, session=session)
expected_response = AgentResponse(messages=[Message(role="assistant", text="response")])
@@ -381,22 +381,22 @@ class TestAgentMiddlewarePipeline:
result = await pipeline.execute(context, final_handler)
assert result == expected_response
assert captured_thread is thread
assert captured_session is session
async def test_execute_with_no_thread_in_context(self, mock_agent: SupportsAgentRun) -> None:
"""Test pipeline execution when no thread is provided."""
captured_thread = "not_none" # Use string to distinguish from None
async def test_execute_with_no_session_in_context(self, mock_agent: SupportsAgentRun) -> None:
"""Test pipeline execution when no session is provided."""
captured_session = "not_none" # Use string to distinguish from None
class ThreadCapturingMiddleware(AgentMiddleware):
class SessionCapturingMiddleware(AgentMiddleware):
async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None:
nonlocal captured_thread
captured_thread = context.thread
nonlocal captured_session
captured_session = context.session
await call_next()
middleware = ThreadCapturingMiddleware()
middleware = SessionCapturingMiddleware()
pipeline = AgentMiddlewarePipeline(middleware)
messages = [Message(role="user", text="test")]
context = AgentContext(agent=mock_agent, messages=messages, thread=None)
context = AgentContext(agent=mock_agent, messages=messages, session=None)
expected_response = AgentResponse(messages=[Message(role="assistant", text="response")])
@@ -405,7 +405,7 @@ class TestAgentMiddlewarePipeline:
result = await pipeline.execute(context, final_handler)
assert result == expected_response
assert captured_thread is None
assert captured_session is None
class TestFunctionMiddlewarePipeline:
@@ -1405,19 +1405,19 @@ class TestMiddlewareDecoratorLogic:
assert test_function_middleware._middleware_type == MiddlewareType.FUNCTION # type: ignore[attr-defined]
class TestChatAgentThreadBehavior:
"""Test cases for thread behavior in AgentContext across multiple runs."""
class TestChatAgentSessionBehavior:
"""Test cases for session behavior in AgentContext across multiple runs."""
async def test_agent_context_thread_behavior_across_multiple_runs(self, client: "MockChatClient") -> None:
"""Test that AgentContext.thread property behaves correctly across multiple agent runs."""
async def test_agent_context_session_behavior_across_multiple_runs(self, client: "MockChatClient") -> None:
"""Test that AgentContext.session property behaves correctly across multiple agent runs."""
thread_states: list[dict[str, Any]] = []
class ThreadTrackingMiddleware(AgentMiddleware):
class SessionTrackingMiddleware(AgentMiddleware):
async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None:
# Capture state before next() call
thread_messages = []
if context.thread and context.thread.message_store:
thread_messages = await context.thread.message_store.list_messages()
if context.session and context.session.state.get("memory"):
thread_messages = context.session.state.get("memory", {}).get("messages", [])
before_state = {
"before_next": True,
@@ -1432,8 +1432,8 @@ class TestChatAgentThreadBehavior:
# Capture state after next() call
thread_messages_after = []
if context.thread and context.thread.message_store:
thread_messages_after = await context.thread.message_store.list_messages()
if context.session and context.session.state.get("memory"):
thread_messages_after = context.session.state.get("memory", {}).get("messages", [])
after_state = {
"before_next": False,
@@ -1444,19 +1444,16 @@ class TestChatAgentThreadBehavior:
}
thread_states.append(after_state)
# Import the ChatMessageStore to configure the agent with a message store factory
from agent_framework import ChatMessageStore
# Create Agent with session tracking middleware
middleware = SessionTrackingMiddleware()
agent = Agent(client=client, middleware=[middleware])
# Create Agent with thread tracking middleware and a message store factory
middleware = ThreadTrackingMiddleware()
agent = Agent(client=client, middleware=[middleware], chat_message_store_factory=ChatMessageStore)
# Create a thread that will persist messages between runs
thread = agent.get_new_thread()
# Create a session that will persist messages between runs
session = agent.create_session()
# First run
first_messages = [Message(role="user", text="first message")]
first_response = await agent.run(first_messages, thread=thread)
first_response = await agent.run(first_messages, session=session)
# Verify first response
assert first_response is not None
@@ -1464,7 +1461,7 @@ class TestChatAgentThreadBehavior:
# Second run - use the same thread
second_messages = [Message(role="user", text="second message")]
second_response = await agent.run(second_messages, thread=thread)
second_response = await agent.run(second_messages, session=session)
# Verify second response
assert second_response is not None
@@ -30,6 +30,7 @@ from agent_framework.observability import (
ChatTelemetryLayer,
MessageListTimestampFilter,
OtelAttr,
_capture_messages,
get_function_span,
)
@@ -441,19 +442,19 @@ def mock_chat_agent():
self.description = "Test agent description"
self.default_options: dict[str, Any] = {"model_id": "TestModel"}
def run(self, messages=None, *, thread=None, stream=False, **kwargs):
def run(self, messages=None, *, session=None, stream=False, **kwargs):
if stream:
return self._run_stream_impl(messages=messages, **kwargs)
return self._run_impl(messages=messages, **kwargs)
async def _run_impl(self, messages=None, *, thread=None, **kwargs):
async def _run_impl(self, messages=None, *, session=None, **kwargs):
return AgentResponse(
messages=[Message("assistant", ["Agent response"])],
usage_details=UsageDetails(input_token_count=15, output_token_count=25),
response_id="test_response_id",
)
async def _run_stream_impl(self, messages=None, *, thread=None, **kwargs):
async def _run_stream_impl(self, messages=None, *, session=None, **kwargs):
from agent_framework import AgentResponse, AgentResponseUpdate, ResponseStream
async def _stream():
@@ -1572,12 +1573,12 @@ async def test_agent_observability(span_exporter: InMemorySpanExporter, enable_s
messages=None,
*,
stream: bool = False,
thread=None,
session=None,
**kwargs,
):
if stream:
return ResponseStream(
self._run_stream(messages=messages, thread=thread),
self._run_stream(messages=messages, session=session),
finalizer=lambda x: AgentResponse.from_updates(x),
)
return AgentResponse(messages=[Message("assistant", ["Test response"])])
@@ -1586,7 +1587,7 @@ async def test_agent_observability(span_exporter: InMemorySpanExporter, enable_s
self,
messages=None,
*,
thread=None,
session=None,
**kwargs,
):
from agent_framework import AgentResponseUpdate
@@ -1635,7 +1636,7 @@ async def test_agent_observability_with_exception(span_exporter: InMemorySpanExp
def default_options(self):
return self._default_options
async def run(self, messages=None, *, stream: bool = False, thread=None, **kwargs):
async def run(self, messages=None, *, stream: bool = False, session=None, **kwargs):
raise RuntimeError("Agent failed")
class FailingAgent(AgentTelemetryLayer, _FailingAgent):
@@ -1685,15 +1686,15 @@ async def test_agent_streaming_observability(span_exporter: InMemorySpanExporter
def default_options(self):
return self._default_options
def run(self, messages=None, *, stream=False, thread=None, **kwargs):
def run(self, messages=None, *, stream=False, session=None, **kwargs):
if stream:
return self._run_stream_impl(messages=messages, **kwargs)
return self._run_impl(messages=messages, **kwargs)
async def _run_impl(self, messages=None, *, thread=None, **kwargs):
async def _run_impl(self, messages=None, *, session=None, **kwargs):
return AgentResponse(messages=[Message("assistant", ["Test"])])
def _run_stream_impl(self, messages=None, *, thread=None, **kwargs):
def _run_stream_impl(self, messages=None, *, session=None, **kwargs):
async def _stream():
yield AgentResponseUpdate(contents=[Content.from_text("Hello ")], role="assistant")
yield AgentResponseUpdate(contents=[Content.from_text("World")], role="assistant")
@@ -1822,15 +1823,15 @@ async def test_agent_streaming_exception(span_exporter: InMemorySpanExporter, en
def default_options(self):
return self._default_options
def run(self, messages=None, *, stream=False, thread=None, **kwargs):
def run(self, messages=None, *, stream=False, session=None, **kwargs):
if stream:
return self._run_stream_impl(messages=messages, **kwargs)
return self._run_impl(messages=messages, **kwargs)
async def _run_impl(self, messages=None, *, thread=None, **kwargs):
async def _run_impl(self, messages=None, *, session=None, **kwargs):
return AgentResponse(messages=[])
def _run_stream_impl(self, messages=None, *, thread=None, **kwargs):
def _run_stream_impl(self, messages=None, *, session=None, **kwargs):
async def _stream():
yield AgentResponseUpdate(contents=[Content.from_text("Starting")], role="assistant")
raise RuntimeError("Stream failed")
@@ -1919,7 +1920,7 @@ async def test_agent_when_disabled(span_exporter: InMemorySpanExporter):
def default_options(self):
return self._default_options
async def run(self, messages=None, *, stream: bool = False, thread=None, **kwargs):
async def run(self, messages=None, *, stream: bool = False, session=None, **kwargs):
if stream:
return ResponseStream(
self._run_stream(messages=messages, **kwargs),
@@ -1927,7 +1928,7 @@ async def test_agent_when_disabled(span_exporter: InMemorySpanExporter):
)
return AgentResponse(messages=[])
async def _run_stream(self, messages=None, *, thread=None, **kwargs):
async def _run_stream(self, messages=None, *, session=None, **kwargs):
from agent_framework import AgentResponseUpdate
yield AgentResponseUpdate(contents=[Content.from_text("test")], role="assistant")
@@ -1974,15 +1975,15 @@ async def test_agent_streaming_when_disabled(span_exporter: InMemorySpanExporter
def default_options(self):
return self._default_options
def run(self, messages=None, *, stream=False, thread=None, **kwargs):
def run(self, messages=None, *, stream=False, session=None, **kwargs):
if stream:
return self._run_stream(messages=messages, **kwargs)
return self._run(messages=messages, **kwargs)
async def _run(self, messages=None, *, thread=None, **kwargs):
async def _run(self, messages=None, *, session=None, **kwargs):
return AgentResponse(messages=[])
async def _run_stream(self, messages=None, *, thread=None, **kwargs):
async def _run_stream(self, messages=None, *, session=None, **kwargs):
yield AgentResponseUpdate(contents=[Content.from_text("test")], role="assistant")
class TestAgent(AgentTelemetryLayer, _TestAgent):
@@ -2263,3 +2264,176 @@ async def test_layer_ordering_span_sequence_with_function_calling(span_exporter:
# Third span: second chat (LLM call with function result)
assert sorted_spans[2].name.startswith("chat"), f"Third span should be 'chat', got '{sorted_spans[2].name}'"
# region Test non-ASCII character handling in JSON serialization
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
async def test_capture_messages_preserves_non_ascii_characters(mock_chat_client, span_exporter: InMemorySpanExporter):
"""Test that non-ASCII characters (e.g., Japanese) are preserved in span attributes."""
import json
japanese_text = "こんにちは世界" # "Hello World" in Japanese
class ClientWithJapanese(mock_chat_client):
async def _inner_get_response(self, *, messages, options, **kwargs):
return ChatResponse(
messages=[Message(role="assistant", text=japanese_text)],
usage_details=UsageDetails(input_token_count=5, output_token_count=10),
)
client = ClientWithJapanese()
messages = [Message(role="user", text=japanese_text)]
span_exporter.clear()
response = await client.get_response(messages=messages, model_id="Test")
assert response is not None
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
# Verify input messages preserve Japanese characters
input_messages_json = span.attributes[OtelAttr.INPUT_MESSAGES]
assert japanese_text in input_messages_json
# Ensure it's not escaped to Unicode
assert "\\u" not in input_messages_json
# Verify output messages preserve Japanese characters
output_messages_json = span.attributes[OtelAttr.OUTPUT_MESSAGES]
assert japanese_text in output_messages_json
assert "\\u" not in output_messages_json
# Verify JSON is valid and contains the text
input_messages = json.loads(input_messages_json)
assert input_messages[0]["parts"][0]["content"] == japanese_text
output_messages = json.loads(output_messages_json)
assert output_messages[0]["parts"][0]["content"] == japanese_text
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
async def test_system_instructions_preserves_non_ascii_characters(span_exporter: InMemorySpanExporter):
"""Test that non-ASCII characters are preserved in system instructions span attribute."""
import json
from opentelemetry import trace
chinese_text = "你好世界" # "Hello World" in Chinese
tracer = trace.get_tracer("test")
span_exporter.clear()
with tracer.start_as_current_span("test_span") as span:
_capture_messages(
span=span,
provider_name="test_provider",
messages=[Message(role="user", text="Test")],
system_instructions=chinese_text,
)
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
# Verify system instructions preserve Chinese characters
system_instructions_json = span.attributes[OtelAttr.SYSTEM_INSTRUCTIONS]
assert chinese_text in system_instructions_json
assert "\\u" not in system_instructions_json
# Verify JSON is valid and contains the text
system_instructions = json.loads(system_instructions_json)
assert system_instructions[0]["content"] == chinese_text
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
async def test_tool_arguments_preserves_non_ascii_characters(span_exporter: InMemorySpanExporter):
"""Test that non-ASCII characters are preserved in tool arguments span attribute."""
import json
korean_text = "안녕하세요" # "Hello" in Korean
@tool
def greet(message: str) -> str:
"""Greet with a message."""
return f"Greeted: {message}"
span_exporter.clear()
await greet.invoke(message=korean_text)
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
# Verify tool arguments preserve Korean characters
tool_arguments_json = span.attributes[OtelAttr.TOOL_ARGUMENTS]
assert korean_text in tool_arguments_json
assert "\\u" not in tool_arguments_json
# Verify JSON is valid and contains the text
tool_arguments = json.loads(tool_arguments_json)
assert tool_arguments["message"] == korean_text
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
async def test_tool_result_preserves_non_ascii_characters(span_exporter: InMemorySpanExporter):
"""Test that non-ASCII characters are preserved in tool result span attribute."""
arabic_text = "مرحبا بالعالم" # "Hello World" in Arabic
@tool
def echo(text: str) -> str:
"""Echo the text back."""
return text
span_exporter.clear()
result = await echo.invoke(text=arabic_text)
assert result == arabic_text
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
# Verify tool result preserves Arabic characters
tool_result = span.attributes[OtelAttr.TOOL_RESULT]
assert arabic_text in tool_result
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
async def test_tool_arguments_pydantic_preserves_non_ascii_characters(
span_exporter: InMemorySpanExporter,
) -> None:
"""Test that non-ASCII characters are preserved in tool arguments when using a Pydantic model."""
import json
from pydantic import BaseModel
japanese_text = "こんにちは" # "Hello" in Japanese
class Greeting(BaseModel):
message: str
@tool
def greet_with_model(greeting: Greeting) -> str:
"""Greet with a message contained in a Pydantic model."""
# When invoked via the tool's input_model, greeting is passed as a dict
if isinstance(greeting, dict):
return f"Greeted: {greeting['message']}"
return f"Greeted: {greeting.message}"
span_exporter.clear()
# Use the tool's input_model to properly pass the Pydantic model argument
input_model = greet_with_model.input_model
await greet_with_model.invoke(arguments=input_model(greeting=Greeting(message=japanese_text)))
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
# Verify tool arguments preserve Japanese characters
tool_arguments_json = span.attributes[OtelAttr.TOOL_ARGUMENTS]
assert japanese_text in tool_arguments_json
assert "\\u" not in tool_arguments_json
# Verify JSON is valid and contains the text
tool_arguments = json.loads(tool_arguments_json)
assert tool_arguments["greeting"]["message"] == japanese_text
@@ -28,6 +28,12 @@ class SecretSettings(TypedDict, total=False):
username: str | None
class ExclusiveSettings(TypedDict, total=False):
source_a: str | None
source_b: str | None
other: str | None
class TestLoadSettingsBasic:
"""Test basic load_settings functionality."""
@@ -236,3 +242,89 @@ class TestOverrideTypeValidation:
assert isinstance(settings["api_key"], SecretString)
assert settings["api_key"] == "plain-string"
class TestMutuallyExclusive:
"""Test mutually exclusive field validation via tuple entries in required_fields."""
def test_exactly_one_set_passes(self) -> None:
settings = load_settings(
ExclusiveSettings,
env_prefix="TEST_",
required_fields=[("source_a", "source_b")],
source_a="value-a",
)
assert settings["source_a"] == "value-a"
assert settings["source_b"] is None
def test_none_set_raises(self) -> None:
from agent_framework.exceptions import SettingNotFoundError
with pytest.raises(SettingNotFoundError, match="none was set"):
load_settings(
ExclusiveSettings,
env_prefix="TEST_",
required_fields=[("source_a", "source_b")],
)
def test_both_set_raises(self) -> None:
from agent_framework.exceptions import SettingNotFoundError
with pytest.raises(SettingNotFoundError, match="multiple were set"):
load_settings(
ExclusiveSettings,
env_prefix="TEST_",
required_fields=[("source_a", "source_b")],
source_a="a",
source_b="b",
)
def test_env_var_counts_as_set(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("TEST_SOURCE_B", "env-b")
settings = load_settings(
ExclusiveSettings,
env_prefix="TEST_",
required_fields=[("source_a", "source_b")],
)
assert settings["source_b"] == "env-b"
def test_env_var_and_override_both_set_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
from agent_framework.exceptions import SettingNotFoundError
monkeypatch.setenv("TEST_SOURCE_B", "env-b")
with pytest.raises(SettingNotFoundError, match="multiple were set"):
load_settings(
ExclusiveSettings,
env_prefix="TEST_",
required_fields=[("source_a", "source_b")],
source_a="a",
)
def test_other_fields_unaffected(self) -> None:
settings = load_settings(
ExclusiveSettings,
env_prefix="TEST_",
required_fields=[("source_a", "source_b")],
source_a="a",
other="extra",
)
assert settings["source_a"] == "a"
assert settings["other"] == "extra"
def test_mixed_required_and_exclusive(self) -> None:
settings = load_settings(
ExclusiveSettings,
env_prefix="TEST_",
required_fields=["other", ("source_a", "source_b")],
source_b="b",
other="required-val",
)
assert settings["other"] == "required-val"
assert settings["source_b"] == "b"
assert settings["source_a"] is None
@@ -1,600 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Sequence
from typing import Any
import pytest
from agent_framework import AgentThread, ChatMessageStore, Message
from agent_framework._threads import AgentThreadState, ChatMessageStoreState
from agent_framework.exceptions import AgentThreadException
class MockChatMessageStore:
"""Mock implementation of ChatMessageStoreProtocol for testing."""
def __init__(self, messages: list[Message] | None = None) -> None:
self._messages = messages or []
self._serialize_calls = 0
self._deserialize_calls = 0
async def list_messages(self) -> list[Message]:
return self._messages
async def add_messages(self, messages: Sequence[Message]) -> None:
self._messages.extend(messages)
async def serialize(self, **kwargs: Any) -> Any:
self._serialize_calls += 1
return {"messages": [msg.__dict__ for msg in self._messages], "kwargs": kwargs}
async def update_from_state(self, serialized_store_state: Any, **kwargs: Any) -> None:
self._deserialize_calls += 1
if serialized_store_state and "messages" in serialized_store_state:
self._messages = serialized_store_state["messages"]
@classmethod
async def deserialize(cls, serialized_store_state: Any, **kwargs: Any) -> "MockChatMessageStore":
instance = cls()
await instance.update_from_state(serialized_store_state, **kwargs)
return instance
@pytest.fixture
def sample_messages() -> list[Message]:
"""Fixture providing sample chat messages for testing."""
return [
Message(role="user", text="Hello", message_id="msg1"),
Message(role="assistant", text="Hi there!", message_id="msg2"),
Message(role="user", text="How are you?", message_id="msg3"),
]
@pytest.fixture
def sample_message() -> Message:
"""Fixture providing a single sample chat message for testing."""
return Message(role="user", text="Test message", message_id="test1")
class TestAgentThread:
"""Test cases for AgentThread class."""
def test_init_with_no_parameters(self) -> None:
"""Test AgentThread initialization with no parameters."""
thread = AgentThread()
assert thread.service_thread_id is None
assert thread.message_store is None
def test_init_with_service_thread_id(self) -> None:
"""Test AgentThread initialization with service_thread_id."""
service_thread_id = "test-conversation-123"
thread = AgentThread(service_thread_id=service_thread_id)
assert thread.service_thread_id == service_thread_id
assert thread.message_store is None
def test_init_with_message_store(self) -> None:
"""Test AgentThread initialization with message_store."""
store = ChatMessageStore()
thread = AgentThread(message_store=store)
assert thread.service_thread_id is None
assert thread.message_store is store
def test_service_thread_id_property_setter(self) -> None:
"""Test service_thread_id property setter."""
thread = AgentThread()
service_thread_id = "test-conversation-456"
thread.service_thread_id = service_thread_id
assert thread.service_thread_id == service_thread_id
def test_service_thread_id_setter_with_existing_message_store_raises_error(self) -> None:
"""Test that setting service_thread_id when message_store exists raises AgentThreadException."""
store = ChatMessageStore()
thread = AgentThread(message_store=store)
with pytest.raises(AgentThreadException, match="Only the service_thread_id or message_store may be set"):
thread.service_thread_id = "test-conversation-789"
def test_service_thread_id_setter_with_none_values(self) -> None:
"""Test service_thread_id setter with None values does nothing."""
thread = AgentThread()
thread.service_thread_id = None # Should not raise error
assert thread.service_thread_id is None
def test_message_store_property_setter(self) -> None:
"""Test message_store property setter."""
thread = AgentThread()
store = ChatMessageStore()
thread.message_store = store
assert thread.message_store is store
def test_message_store_setter_with_existing_service_thread_id_raises_error(self) -> None:
"""Test that setting message_store when service_thread_id exists raises AgentThreadException."""
service_thread_id = "test-conversation-999"
thread = AgentThread(service_thread_id=service_thread_id)
store = ChatMessageStore()
with pytest.raises(AgentThreadException, match="Only the service_thread_id or message_store may be set"):
thread.message_store = store
def test_message_store_setter_with_none_values(self) -> None:
"""Test message_store setter with None values does nothing."""
thread = AgentThread()
thread.message_store = None # Should not raise error
assert thread.message_store is None
async def test_get_messages_with_message_store(self, sample_messages: list[Message]) -> None:
"""Test get_messages when message_store is set."""
store = ChatMessageStore(sample_messages)
thread = AgentThread(message_store=store)
assert thread.message_store is not None
messages: list[Message] = await thread.message_store.list_messages()
assert messages is not None
assert len(messages) == 3
assert messages[0].text == "Hello"
assert messages[1].text == "Hi there!"
assert messages[2].text == "How are you?"
async def test_get_messages_with_no_message_store(self) -> None:
"""Test get_messages when no message_store is set."""
thread = AgentThread()
assert thread.message_store is None
async def test_on_new_messages_with_service_thread_id(self, sample_message: Message) -> None:
"""Test _on_new_messages when service_thread_id is set (should do nothing)."""
thread = AgentThread(service_thread_id="test-conv")
await thread.on_new_messages(sample_message)
# Should not create a message store
assert thread.message_store is None
async def test_on_new_messages_single_message_creates_store(self, sample_message: Message) -> None:
"""Test _on_new_messages with single message creates ChatMessageStore."""
thread = AgentThread()
await thread.on_new_messages(sample_message)
assert thread.message_store is not None
assert isinstance(thread.message_store, ChatMessageStore)
messages = await thread.message_store.list_messages()
assert len(messages) == 1
assert messages[0].text == "Test message"
async def test_on_new_messages_multiple_messages(self, sample_messages: list[Message]) -> None:
"""Test _on_new_messages with multiple messages."""
thread = AgentThread()
await thread.on_new_messages(sample_messages)
assert thread.message_store is not None
messages = await thread.message_store.list_messages()
assert len(messages) == 3
async def test_on_new_messages_with_existing_store(self, sample_message: Message) -> None:
"""Test _on_new_messages adds to existing message store."""
initial_messages = [Message(role="user", text="Initial", message_id="init1")]
store = ChatMessageStore(initial_messages)
thread = AgentThread(message_store=store)
await thread.on_new_messages(sample_message)
assert thread.message_store is not None
messages = await thread.message_store.list_messages()
assert len(messages) == 2
assert messages[0].text == "Initial"
assert messages[1].text == "Test message"
async def test_deserialize_with_service_thread_id(self) -> None:
"""Test _deserialize with service_thread_id."""
serialized_data = {"service_thread_id": "test-conv-123", "chat_message_store_state": None}
thread = await AgentThread.deserialize(serialized_data)
assert thread.service_thread_id == "test-conv-123"
assert thread.message_store is None
async def test_deserialize_with_store_state(self, sample_messages: list[Message]) -> None:
"""Test _deserialize with chat_message_store_state."""
store_state = {"messages": sample_messages}
serialized_data = {"service_thread_id": None, "chat_message_store_state": store_state}
thread = await AgentThread.deserialize(serialized_data)
assert thread.service_thread_id is None
assert thread.message_store is not None
assert isinstance(thread.message_store, ChatMessageStore)
async def test_deserialize_with_no_state(self) -> None:
"""Test _deserialize with no state."""
thread = AgentThread()
serialized_data = {"service_thread_id": None, "chat_message_store_state": None}
await thread.deserialize(serialized_data)
assert thread.service_thread_id is None
assert thread.message_store is None
async def test_deserialize_with_existing_store(self) -> None:
"""Test _deserialize with existing message store."""
store = MockChatMessageStore()
thread = AgentThread(message_store=store)
serialized_data: dict[str, Any] = {
"service_thread_id": None,
"chat_message_store_state": {"messages": [Message(role="user", text="test")]},
}
await thread.update_from_thread_state(serialized_data)
assert store._messages
assert store._messages[0].text == "test"
async def test_serialize_with_service_thread_id(self) -> None:
"""Test serialize with service_thread_id."""
thread = AgentThread(service_thread_id="test-conv-456")
result = await thread.serialize()
assert result["service_thread_id"] == "test-conv-456"
assert result["chat_message_store_state"] is None
async def test_serialize_with_message_store(self) -> None:
"""Test serialize with message_store."""
store = MockChatMessageStore()
thread = AgentThread(message_store=store)
result = await thread.serialize()
assert result["service_thread_id"] is None
assert result["chat_message_store_state"] is not None
assert store._serialize_calls == 1 # pyright: ignore[reportPrivateUsage]
async def test_serialize_with_no_state(self) -> None:
"""Test serialize with no state."""
thread = AgentThread()
result = await thread.serialize()
assert result["service_thread_id"] is None
assert result["chat_message_store_state"] is None
async def test_serialize_with_kwargs(self) -> None:
"""Test serialize passes kwargs to message store."""
store = MockChatMessageStore()
thread = AgentThread(message_store=store)
await thread.serialize(custom_param="test_value")
assert store._serialize_calls == 1 # pyright: ignore[reportPrivateUsage]
async def test_serialize_round_trip_messages(self, sample_messages: list[Message]) -> None:
"""Test a roundtrip of the serialization."""
store = ChatMessageStore(sample_messages)
thread = AgentThread(message_store=store)
new_thread = await AgentThread.deserialize(await thread.serialize())
assert new_thread.message_store is not None
new_messages = await new_thread.message_store.list_messages()
assert len(new_messages) == len(sample_messages)
assert {new.text for new in new_messages} == {orig.text for orig in sample_messages}
async def test_serialize_round_trip_thread_id(self) -> None:
"""Test a roundtrip of the serialization."""
thread = AgentThread(service_thread_id="test-1234")
new_thread = await AgentThread.deserialize(await thread.serialize())
assert new_thread.message_store is None
assert new_thread.service_thread_id == "test-1234"
class TestChatMessageList:
"""Test cases for ChatMessageStore class."""
def test_init_empty(self) -> None:
"""Test ChatMessageStore initialization with no messages."""
store = ChatMessageStore()
assert len(store.messages) == 0
def test_init_with_messages(self, sample_messages: list[Message]) -> None:
"""Test ChatMessageStore initialization with messages."""
store = ChatMessageStore(sample_messages)
assert len(store.messages) == 3
async def test_add_messages(self, sample_messages: list[Message]) -> None:
"""Test adding messages to the store."""
store = ChatMessageStore()
await store.add_messages(sample_messages)
assert len(store.messages) == 3
messages = await store.list_messages()
assert messages[0].text == "Hello"
async def test_get_messages(self, sample_messages: list[Message]) -> None:
"""Test getting messages from the store."""
store = ChatMessageStore(sample_messages)
messages = await store.list_messages()
assert len(messages) == 3
assert messages[0].message_id == "msg1"
async def test_serialize_state(self, sample_messages: list[Message]) -> None:
"""Test serializing store state."""
store = ChatMessageStore(sample_messages)
result = await store.serialize()
assert "messages" in result
assert len(result["messages"]) == 3
async def test_serialize_state_empty(self) -> None:
"""Test serializing empty store state."""
store = ChatMessageStore()
result = await store.serialize()
assert "messages" in result
assert len(result["messages"]) == 0
async def test_deserialize_state(self, sample_messages: list[Message]) -> None:
"""Test deserializing store state."""
store = ChatMessageStore()
state_data = {"messages": sample_messages}
await store.update_from_state(state_data)
messages = await store.list_messages()
assert len(messages) == 3
assert messages[0].text == "Hello"
async def test_deserialize_state_none(self) -> None:
"""Test deserializing None state."""
store = ChatMessageStore()
await store.update_from_state(None)
assert len(store.messages) == 0
async def test_deserialize_state_empty(self) -> None:
"""Test deserializing empty state."""
store = ChatMessageStore()
await store.update_from_state({})
assert len(store.messages) == 0
class TestStoreState:
"""Test cases for ChatMessageStoreState class."""
def test_init(self, sample_messages: list[Message]) -> None:
"""Test ChatMessageStoreState initialization."""
state = ChatMessageStoreState(messages=sample_messages)
assert len(state.messages) == 3
assert state.messages[0].text == "Hello"
def test_init_empty(self) -> None:
"""Test ChatMessageStoreState initialization with empty messages."""
state = ChatMessageStoreState(messages=[])
assert len(state.messages) == 0
def test_init_none(self) -> None:
"""Test ChatMessageStoreState initialization with None messages."""
state = ChatMessageStoreState(messages=None)
assert len(state.messages) == 0
def test_init_no_messages_arg(self) -> None:
"""Test ChatMessageStoreState initialization without messages argument."""
state = ChatMessageStoreState()
assert len(state.messages) == 0
class TestThreadState:
"""Test cases for AgentThreadState class."""
def test_init_with_service_thread_id(self) -> None:
"""Test AgentThreadState initialization with service_thread_id."""
state = AgentThreadState(service_thread_id="test-conv-123")
assert state.service_thread_id == "test-conv-123"
assert state.chat_message_store_state is None
def test_init_with_chat_message_store_state(self) -> None:
"""Test AgentThreadState initialization with chat_message_store_state."""
store_data: dict[str, Any] = {"messages": []}
state = AgentThreadState.from_dict({"chat_message_store_state": store_data})
assert state.service_thread_id is None
assert state.chat_message_store_state.messages == []
def test_init_with_both(self) -> None:
"""Test AgentThreadState initialization with both parameters."""
store_data: dict[str, Any] = {"messages": []}
with pytest.raises(AgentThreadException):
AgentThreadState(service_thread_id="test-conv-123", chat_message_store_state=store_data)
def test_init_defaults(self) -> None:
"""Test AgentThreadState initialization with defaults."""
state = AgentThreadState()
assert state.service_thread_id is None
assert state.chat_message_store_state is None
def test_init_with_chat_message_store_state_no_messages(self) -> None:
"""Test AgentThreadState initialization with chat_message_store_state without messages field.
This tests the scenario where a custom ChatMessageStore (like RedisChatMessageStore)
serializes its state without a 'messages' field, containing only configuration data
like thread_id, redis_url, etc.
"""
store_data: dict[str, Any] = {
"type": "redis_store_state",
"thread_id": "test_thread_123",
"redis_url": "redis://localhost:6379",
"key_prefix": "chat_messages",
}
state = AgentThreadState.from_dict({"chat_message_store_state": store_data})
assert state.service_thread_id is None
assert state.chat_message_store_state is not None
assert state.chat_message_store_state.messages == []
def test_init_with_chat_message_store_state_object(self) -> None:
"""Test AgentThreadState initialization with ChatMessageStoreState object."""
store_state = ChatMessageStoreState(messages=[Message(role="user", text="test")])
state = AgentThreadState(chat_message_store_state=store_state)
assert state.service_thread_id is None
assert state.chat_message_store_state is store_state
assert len(state.chat_message_store_state.messages) == 1
def test_init_with_invalid_chat_message_store_state_type(self) -> None:
"""Test AgentThreadState initialization with invalid chat_message_store_state type."""
with pytest.raises(TypeError, match="Could not parse ChatMessageStoreState"):
AgentThreadState(chat_message_store_state="invalid_type") # type: ignore[arg-type]
class TestChatMessageStoreStateEdgeCases:
"""Additional edge case tests for ChatMessageStoreState."""
def test_init_with_invalid_messages_type(self) -> None:
"""Test ChatMessageStoreState initialization with invalid messages type."""
with pytest.raises(TypeError, match="Messages should be a list"):
ChatMessageStoreState(messages="invalid") # type: ignore[arg-type]
def test_init_with_dict_messages(self) -> None:
"""Test ChatMessageStoreState initialization with dict messages."""
messages = [
{"role": "user", "text": "Hello"},
{"role": "assistant", "text": "Hi there!"},
]
state = ChatMessageStoreState(messages=messages)
assert len(state.messages) == 2
assert isinstance(state.messages[0], Message)
assert state.messages[0].text == "Hello"
class TestChatMessageStoreEdgeCases:
"""Additional edge case tests for ChatMessageStore."""
async def test_deserialize_class_method(self) -> None:
"""Test ChatMessageStore.deserialize class method."""
serialized_data = {
"messages": [
{"role": "user", "text": "Hello", "message_id": "msg1"},
]
}
store = await ChatMessageStore.deserialize(serialized_data)
assert isinstance(store, ChatMessageStore)
messages = await store.list_messages()
assert len(messages) == 1
assert messages[0].text == "Hello"
async def test_deserialize_empty_state(self) -> None:
"""Test ChatMessageStore.deserialize with empty state."""
serialized_data: dict[str, Any] = {"messages": []}
store = await ChatMessageStore.deserialize(serialized_data)
assert isinstance(store, ChatMessageStore)
messages = await store.list_messages()
assert len(messages) == 0
class TestAgentThreadEdgeCases:
"""Additional edge case tests for AgentThread."""
def test_is_initialized_with_service_thread_id(self) -> None:
"""Test is_initialized property when service_thread_id is set."""
thread = AgentThread(service_thread_id="test-123")
assert thread.is_initialized is True
def test_is_initialized_with_message_store(self) -> None:
"""Test is_initialized property when message_store is set."""
store = ChatMessageStore()
thread = AgentThread(message_store=store)
assert thread.is_initialized is True
def test_is_initialized_with_nothing(self) -> None:
"""Test is_initialized property when nothing is set."""
thread = AgentThread()
assert thread.is_initialized is False
async def test_deserialize_with_custom_message_store(self) -> None:
"""Test deserialize using a custom message store."""
serialized_data = {
"service_thread_id": None,
"chat_message_store_state": {
"messages": [{"role": "user", "text": "Hello"}],
},
}
custom_store = MockChatMessageStore()
thread = await AgentThread.deserialize(serialized_data, message_store=custom_store)
assert thread.message_store is custom_store
messages = await custom_store.list_messages()
assert len(messages) == 1
async def test_deserialize_with_failing_message_store_raises(self) -> None:
"""Test deserialize raises AgentThreadException when message store fails."""
class FailingStore:
async def add_messages(self, messages: Sequence[Message], **kwargs: Any) -> None:
raise RuntimeError("Store failed")
serialized_data = {
"service_thread_id": None,
"chat_message_store_state": {
"messages": [{"role": "user", "text": "Hello"}],
},
}
failing_store = FailingStore()
with pytest.raises(AgentThreadException, match="Failed to deserialize"):
await AgentThread.deserialize(serialized_data, message_store=failing_store)
async def test_update_from_thread_state_with_service_thread_id(self) -> None:
"""Test update_from_thread_state sets service_thread_id."""
thread = AgentThread()
serialized_data = {"service_thread_id": "new-thread-id"}
await thread.update_from_thread_state(serialized_data)
assert thread.service_thread_id == "new-thread-id"
async def test_update_from_thread_state_with_empty_chat_state(self) -> None:
"""Test update_from_thread_state with empty chat_message_store_state."""
thread = AgentThread()
serialized_data = {"service_thread_id": None, "chat_message_store_state": None}
await thread.update_from_thread_state(serialized_data)
assert thread.message_store is None
async def test_update_from_thread_state_creates_message_store(self) -> None:
"""Test update_from_thread_state creates message store if not existing."""
thread = AgentThread()
serialized_data = {
"service_thread_id": None,
"chat_message_store_state": {
"messages": [{"role": "user", "text": "Hello"}],
},
}
await thread.update_from_thread_state(serialized_data)
assert thread.message_store is not None
messages = await thread.message_store.list_messages()
assert len(messages) == 1
@@ -14,7 +14,7 @@ from agent_framework import (
Agent,
AgentResponse,
AgentResponseUpdate,
AgentThread,
AgentSession,
ChatResponse,
ChatResponseUpdate,
Content,
@@ -1264,70 +1264,70 @@ async def test_openai_assistants_agent_basic_run_streaming():
@pytest.mark.flaky
@skip_if_openai_integration_tests_disabled
async def test_openai_assistants_agent_thread_persistence():
"""Test Agent thread persistence across runs with OpenAIAssistantsClient."""
async def test_openai_assistants_agent_session_persistence():
"""Test Agent session persistence across runs with OpenAIAssistantsClient."""
async with Agent(
client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL),
instructions="You are a helpful assistant with good memory.",
) as agent:
# Create a new thread that will be reused
thread = agent.get_new_thread()
# Create a new session that will be reused
session = agent.create_session()
# First message - establish context
first_response = await agent.run(
"Remember this number: 42. What number did I just tell you to remember?", thread=thread
"Remember this number: 42. What number did I just tell you to remember?", session=session
)
assert isinstance(first_response, AgentResponse)
assert "42" in first_response.text
# Second message - test conversation memory
second_response = await agent.run(
"What number did I tell you to remember in my previous message?", thread=thread
"What number did I tell you to remember in my previous message?", session=session
)
assert isinstance(second_response, AgentResponse)
assert "42" in second_response.text
# Verify thread has been populated with conversation ID
assert thread.service_thread_id is not None
# Verify session has been populated with conversation ID
assert session.service_session_id is not None
@pytest.mark.flaky
@skip_if_openai_integration_tests_disabled
async def test_openai_assistants_agent_existing_thread_id():
"""Test Agent with existing thread ID to continue conversations across agent instances."""
# First, create a conversation and capture the thread ID
existing_thread_id = None
async def test_openai_assistants_agent_existing_session_id():
"""Test Agent with existing session ID to continue conversations across agent instances."""
# First, create a conversation and capture the session ID
existing_session_id = None
async with Agent(
client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL),
instructions="You are a helpful weather agent.",
tools=[get_weather],
) as agent:
# Start a conversation and get the thread ID
thread = agent.get_new_thread()
response1 = await agent.run("What's the weather in Paris?", thread=thread)
# Start a conversation and get the session ID
session = agent.create_session()
response1 = await agent.run("What's the weather in Paris?", session=session)
# Validate first response
assert isinstance(response1, AgentResponse)
assert response1.text is not None
assert any(word in response1.text.lower() for word in ["weather", "paris"])
# The thread ID is set after the first response
existing_thread_id = thread.service_thread_id
assert existing_thread_id is not None
# The session ID is set after the first response
existing_session_id = session.service_session_id
assert existing_session_id is not None
# Now continue with the same thread ID in a new agent instance
# Now continue with the same session ID in a new agent instance
async with Agent(
client=OpenAIAssistantsClient(thread_id=existing_thread_id),
client=OpenAIAssistantsClient(thread_id=existing_session_id),
instructions="You are a helpful weather agent.",
tools=[get_weather],
) as agent:
# Create a thread with the existing ID
thread = AgentThread(service_thread_id=existing_thread_id)
# Create a session with the existing ID
session = AgentSession(service_session_id=existing_session_id)
# Ask about the previous conversation
response2 = await agent.run("What was the last city I asked about?", thread=thread)
response2 = await agent.run("What was the last city I asked about?", session=session)
# Validate that the agent remembers the previous conversation
assert isinstance(response2, AgentResponse)
@@ -0,0 +1 @@
# Copyright (c) Microsoft. All rights reserved.
@@ -7,9 +7,8 @@ from agent_framework import (
AgentExecutor,
AgentResponse,
AgentResponseUpdate,
AgentThread,
AgentSession,
BaseAgent,
ChatMessageStore,
Content,
Message,
ResponseStream,
@@ -21,7 +20,7 @@ from agent_framework.orchestrations import SequentialBuilder
class _CountingAgent(BaseAgent):
"""Agent that echoes messages with a counter to verify thread state persistence."""
"""Agent that echoes messages with a counter to verify session state persistence."""
def __init__(self, **kwargs: Any):
super().__init__(**kwargs)
@@ -32,7 +31,7 @@ class _CountingAgent(BaseAgent):
messages: str | Message | list[str] | list[Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
self.call_count += 1
@@ -51,23 +50,74 @@ class _CountingAgent(BaseAgent):
return _run()
class _StreamingHookAgent(BaseAgent):
"""Agent that exposes whether its streaming result hook was executed."""
def __init__(self, **kwargs: Any):
super().__init__(**kwargs)
self.result_hook_called = False
def run(
self,
messages: str | Message | list[str] | list[Message] | None = None,
*,
stream: bool = False,
**kwargs: Any,
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
if stream:
async def _stream() -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(
contents=[Content.from_text(text="hook test")],
role="assistant",
)
async def _mark_result_hook_called(response: AgentResponse) -> AgentResponse:
self.result_hook_called = True
return response
return ResponseStream(_stream(), finalizer=AgentResponse.from_updates).with_result_hook(
_mark_result_hook_called
)
async def _run() -> AgentResponse:
return AgentResponse(messages=[Message("assistant", ["hook test"])])
return _run()
async def test_agent_executor_streaming_finalizes_stream_and_runs_result_hooks() -> None:
"""AgentExecutor should call get_final_response() so stream result hooks execute."""
agent = _StreamingHookAgent(id="hook_agent", name="HookAgent")
executor = AgentExecutor(agent, id="hook_exec")
workflow = SequentialBuilder(participants=[executor]).build()
output_events: list[Any] = []
async for event in workflow.run("run hook test", stream=True):
if event.type == "output":
output_events.append(event)
assert output_events
assert agent.result_hook_called
async def test_agent_executor_checkpoint_stores_and_restores_state() -> None:
"""Test that workflow checkpoint stores AgentExecutor's cache and thread states and restores them correctly."""
"""Test that workflow checkpoint stores AgentExecutor's cache and session states and restores them correctly."""
storage = InMemoryCheckpointStorage()
# Create initial agent with a custom thread that has a message store
# Create initial agent with a custom session
initial_agent = _CountingAgent(id="test_agent", name="TestAgent")
initial_thread = AgentThread(message_store=ChatMessageStore())
initial_session = AgentSession()
# Add some initial messages to the thread to verify thread state persistence
# Add some initial messages to the session state to verify session state persistence
initial_messages = [
Message(role="user", text="Initial message 1"),
Message(role="assistant", text="Initial response 1"),
]
await initial_thread.on_new_messages(initial_messages)
initial_session.state["history"] = {"messages": initial_messages}
# Create AgentExecutor with the thread
executor = AgentExecutor(initial_agent, agent_thread=initial_thread)
# Create AgentExecutor with the session
executor = AgentExecutor(initial_agent, session=initial_session)
# Build workflow with checkpointing enabled
wf = SequentialBuilder(participants=[executor], checkpoint_storage=storage).build()
@@ -95,7 +145,7 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None:
checkpoints.sort(key=lambda cp: cp.timestamp)
restore_checkpoint = checkpoints[1]
# Verify checkpoint contains executor state with both cache and thread
# Verify checkpoint contains executor state with both cache and session
assert "_executor_state" in restore_checkpoint.state
executor_states = restore_checkpoint.state["_executor_state"]
assert isinstance(executor_states, dict)
@@ -103,13 +153,12 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None:
executor_state = executor_states[executor.id] # type: ignore[index]
assert "cache" in executor_state, "Checkpoint should store executor cache state"
assert "agent_thread" in executor_state, "Checkpoint should store executor thread state"
assert "agent_session" in executor_state, "Checkpoint should store executor session state"
# Verify thread state includes message store
thread_state = executor_state["agent_thread"] # type: ignore[index]
assert "chat_message_store_state" in thread_state, "Thread state should include message store"
chat_store_state = thread_state["chat_message_store_state"] # type: ignore[index]
assert "messages" in chat_store_state, "Message store state should include messages"
# Verify session state structure
session_state = executor_state["agent_session"] # type: ignore[index]
assert "session_id" in session_state, "Session state should include session_id"
assert "state" in session_state, "Session state should include state dict"
# Verify checkpoint contains pending requests from agents and responses to be sent
assert "pending_agent_requests" in executor_state
@@ -118,8 +167,8 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None:
# Create a new agent and executor for restoration
# This simulates starting from a fresh state and restoring from checkpoint
restored_agent = _CountingAgent(id="test_agent", name="TestAgent")
restored_thread = AgentThread(message_store=ChatMessageStore())
restored_executor = AgentExecutor(restored_agent, agent_thread=restored_thread)
restored_session = AgentSession()
restored_executor = AgentExecutor(restored_agent, session=restored_session)
# Verify the restored agent starts with a fresh state
assert restored_agent.call_count == 0
@@ -140,39 +189,27 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None:
assert resumed_output is not None
# Verify the restored executor's state matches the original
# The cache should be restored (though it may be cleared after processing)
# The thread should have all messages including those from the initial state
message_store = restored_executor._agent_thread.message_store # type: ignore[reportPrivateUsage]
assert message_store is not None
thread_messages = await message_store.list_messages()
# Thread should contain:
# 1. Initial messages from before the checkpoint (2 messages)
# 2. User message from first run (1 message)
# 3. Assistant response from first run (1 message)
assert len(thread_messages) >= 2, "Thread should preserve initial messages from before checkpoint"
# Verify initial messages are preserved
assert thread_messages[0].text == "Initial message 1"
assert thread_messages[1].text == "Initial response 1"
# Verify the restored executor's session state was restored
restored_session_obj = restored_executor._session # type: ignore[reportPrivateUsage]
assert restored_session_obj is not None
assert restored_session_obj.session_id == initial_session.session_id
async def test_agent_executor_save_and_restore_state_directly() -> None:
"""Test AgentExecutor's on_checkpoint_save and on_checkpoint_restore methods directly."""
# Create agent with thread containing messages
# Create agent with session containing state
agent = _CountingAgent(id="direct_test_agent", name="DirectTestAgent")
thread = AgentThread(message_store=ChatMessageStore())
session = AgentSession()
# Add messages to thread
thread_messages = [
Message(role="user", text="Message in thread 1"),
Message(role="assistant", text="Thread response 1"),
Message(role="user", text="Message in thread 2"),
# Add messages to session state
session_messages = [
Message(role="user", text="Message in session 1"),
Message(role="assistant", text="Session response 1"),
Message(role="user", text="Message in session 2"),
]
await thread.on_new_messages(thread_messages)
session.state["history"] = {"messages": session_messages}
executor = AgentExecutor(agent, agent_thread=thread)
executor = AgentExecutor(agent, session=session)
# Add messages to executor cache
cache_messages = [
@@ -184,26 +221,23 @@ async def test_agent_executor_save_and_restore_state_directly() -> None:
# Snapshot the state
state = await executor.on_checkpoint_save()
# Verify snapshot contains both cache and thread
# Verify snapshot contains both cache and session
assert "cache" in state
assert "agent_thread" in state
assert "agent_session" in state
# Verify thread state structure
thread_state = state["agent_thread"] # type: ignore[index]
assert "chat_message_store_state" in thread_state
assert "messages" in thread_state["chat_message_store_state"]
# Verify session state structure
session_state = state["agent_session"] # type: ignore[index]
assert "session_id" in session_state
assert "state" in session_state
# Create new executor to restore into
new_agent = _CountingAgent(id="direct_test_agent", name="DirectTestAgent")
new_thread = AgentThread(message_store=ChatMessageStore())
new_executor = AgentExecutor(new_agent, agent_thread=new_thread)
new_session = AgentSession()
new_executor = AgentExecutor(new_agent, session=new_session)
# Verify new executor starts empty
assert len(new_executor._cache) == 0 # type: ignore[reportPrivateUsage]
initial_message_store = new_thread.message_store
assert initial_message_store is not None
initial_thread_msgs = await initial_message_store.list_messages()
assert len(initial_thread_msgs) == 0
assert len(new_session.state) == 0
# Restore state
await new_executor.on_checkpoint_restore(state)
@@ -214,11 +248,6 @@ async def test_agent_executor_save_and_restore_state_directly() -> None:
assert restored_cache[0].text == "Cached user message"
assert restored_cache[1].text == "Cached assistant response"
# Verify thread messages are restored
restored_message_store = new_executor._agent_thread.message_store # type: ignore[reportPrivateUsage]
assert restored_message_store is not None
restored_thread_msgs = await restored_message_store.list_messages()
assert len(restored_thread_msgs) == len(thread_messages)
assert restored_thread_msgs[0].text == "Message in thread 1"
assert restored_thread_msgs[1].text == "Thread response 1"
assert restored_thread_msgs[2].text == "Message in thread 2"
# Verify session was restored with correct session_id
restored_session = new_executor._session # type: ignore[reportPrivateUsage]
assert restored_session.session_id == session.session_id
@@ -13,7 +13,7 @@ from agent_framework import (
AgentExecutorResponse,
AgentResponse,
AgentResponseUpdate,
AgentThread,
AgentSession,
BaseAgent,
ChatResponse,
ChatResponseUpdate,
@@ -42,7 +42,7 @@ class _ToolCallingAgent(BaseAgent):
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
if stream:
@@ -3,7 +3,7 @@
from collections.abc import AsyncIterable
from typing import Any
from agent_framework import AgentResponse, AgentResponseUpdate, AgentThread, Message
from agent_framework import AgentResponse, AgentResponseUpdate, AgentSession, Message
from agent_framework._workflows._agent_utils import resolve_agent_id
@@ -37,12 +37,12 @@ class MockAgent:
messages: str | Message | list[str] | list[Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> AgentResponse | AsyncIterable[AgentResponseUpdate]: ...
def get_new_thread(self, **kwargs: Any) -> AgentThread:
"""Creates a new conversation thread for the agent."""
def create_session(self, **kwargs: Any) -> AgentSession:
"""Creates a new conversation session for the agent."""
...
@@ -12,7 +12,7 @@ from agent_framework import (
AgentExecutorResponse,
AgentResponse,
AgentResponseUpdate,
AgentThread,
AgentSession,
BaseAgent,
Content,
Executor,
@@ -38,7 +38,7 @@ class _SimpleAgent(BaseAgent):
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
if stream:
@@ -108,7 +108,7 @@ class _CaptureAgent(BaseAgent):
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
# Normalize and record messages for verification
@@ -12,7 +12,7 @@ from agent_framework import (
WorkflowRunState,
)
from agent_framework._workflows._checkpoint_encoding import (
_PICKLE_MARKER,
_PICKLE_MARKER, # type: ignore
encode_checkpoint_value,
)
from agent_framework._workflows._events import WorkflowEvent
@@ -13,7 +13,7 @@ from agent_framework import (
AgentExecutor,
AgentResponse,
AgentResponseUpdate,
AgentThread,
AgentSession,
BaseAgent,
Content,
Executor,
@@ -838,7 +838,7 @@ class _StreamingTestAgent(BaseAgent):
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
if stream:
@@ -11,8 +11,7 @@ from agent_framework import (
AgentExecutorRequest,
AgentResponse,
AgentResponseUpdate,
AgentThread,
ChatMessageStore,
AgentSession,
Content,
Executor,
Message,
@@ -511,80 +510,53 @@ class TestWorkflowAgent:
texts = [message.text for message in result.messages]
assert texts == ["first message", "second message", "third fourth"]
async def test_thread_conversation_history_included_in_workflow_run(self) -> None:
"""Test that conversation history from thread is included when running WorkflowAgent.
This verifies that when a thread with existing messages is provided to agent.run(),
the workflow receives the complete conversation history (thread history + new messages).
"""
async def test_session_conversation_history_included_in_workflow_run(self) -> None:
"""Test that messages provided to agent.run() are passed through to the workflow."""
# Create an executor that captures all received messages
capturing_executor = ConversationHistoryCapturingExecutor(id="capturing", streaming=False)
workflow = WorkflowBuilder(start_executor=capturing_executor).build()
agent = WorkflowAgent(workflow=workflow, name="Thread History Test Agent")
agent = WorkflowAgent(workflow=workflow, name="Session History Test Agent")
# Create a thread with existing conversation history
history_messages = [
Message(role="user", text="Previous user message"),
Message(role="assistant", text="Previous assistant response"),
]
message_store = ChatMessageStore(messages=history_messages)
thread = AgentThread(message_store=message_store)
# Create a session
session = AgentSession()
# Run the agent with the thread and a new message
# Run the agent with the session and a new message
new_message = "New user question"
await agent.run(new_message, thread=thread)
await agent.run(new_message, session=session)
# Verify the executor received both history AND new message
assert len(capturing_executor.received_messages) == 3
# Verify the executor received the message
assert len(capturing_executor.received_messages) == 1
assert capturing_executor.received_messages[0].text == "New user question"
# Verify the order: history first, then new message
assert capturing_executor.received_messages[0].text == "Previous user message"
assert capturing_executor.received_messages[1].text == "Previous assistant response"
assert capturing_executor.received_messages[2].text == "New user question"
async def test_thread_conversation_history_included_in_workflow_stream(self) -> None:
"""Test that conversation history from thread is included when streaming WorkflowAgent.
This verifies that stream=True also includes thread history.
"""
async def test_session_conversation_history_included_in_workflow_stream(self) -> None:
"""Test that messages provided to agent.run() are passed through when streaming WorkflowAgent."""
# Create an executor that captures all received messages
capturing_executor = ConversationHistoryCapturingExecutor(id="capturing_stream")
workflow = WorkflowBuilder(start_executor=capturing_executor).build()
agent = WorkflowAgent(workflow=workflow, name="Thread Stream Test Agent")
agent = WorkflowAgent(workflow=workflow, name="Session Stream Test Agent")
# Create a thread with existing conversation history
history_messages = [
Message(role="system", text="You are a helpful assistant"),
Message(role="user", text="Hello"),
Message("assistant", ["Hi there!"]),
]
message_store = ChatMessageStore(messages=history_messages)
thread = AgentThread(message_store=message_store)
# Create a session
session = AgentSession()
# Stream from the agent with the thread and a new message
async for _ in agent.run("How are you?", stream=True, thread=thread):
# Stream from the agent with the session and a new message
async for _ in agent.run("How are you?", stream=True, session=session):
pass
# Verify the executor received all messages (3 from history + 1 new)
assert len(capturing_executor.received_messages) == 4
# Verify the executor received the message
assert len(capturing_executor.received_messages) == 1
assert capturing_executor.received_messages[0].text == "How are you?"
# Verify the order
assert capturing_executor.received_messages[0].text == "You are a helpful assistant"
assert capturing_executor.received_messages[1].text == "Hello"
assert capturing_executor.received_messages[2].text == "Hi there!"
assert capturing_executor.received_messages[3].text == "How are you?"
async def test_empty_thread_works_correctly(self) -> None:
"""Test that an empty thread (no message store) works correctly."""
capturing_executor = ConversationHistoryCapturingExecutor(id="empty_thread_test")
async def test_empty_session_works_correctly(self) -> None:
"""Test that an empty session (no message store) works correctly."""
capturing_executor = ConversationHistoryCapturingExecutor(id="empty_session_test")
workflow = WorkflowBuilder(start_executor=capturing_executor).build()
agent = WorkflowAgent(workflow=workflow, name="Empty Thread Test Agent")
agent = WorkflowAgent(workflow=workflow, name="Empty Session Test Agent")
# Create an empty thread
thread = AgentThread()
# Create an empty session
session = AgentSession()
# Run with the empty thread
await agent.run("Just a new message", thread=thread)
# Run with the empty session
await agent.run("Just a new message", session=session)
# Should only receive the new message
assert len(capturing_executor.received_messages) == 1
@@ -622,27 +594,27 @@ class TestWorkflowAgent:
self.description: str | None = None
self._response_text = response_text
def get_new_thread(self, **kwargs: Any) -> AgentThread:
return AgentThread()
def create_session(self, **kwargs: Any) -> AgentSession:
return AgentSession()
def run(
self,
messages: str | Content | Message | Sequence[str | Content | Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
if stream:
return self._run_stream(messages=messages, thread=thread, **kwargs)
return self._run(messages=messages, thread=thread, **kwargs)
return self._run_stream(messages=messages, session=session, **kwargs)
return self._run(messages=messages, session=session, **kwargs)
async def _run(
self,
messages: str | Content | Message | Sequence[str | Content | Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> AgentResponse:
@@ -654,7 +626,7 @@ class TestWorkflowAgent:
self,
messages: str | Content | Message | Sequence[str | Content | Message] | None = None,
*,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> ResponseStream[AgentResponseUpdate, AgentResponse]:
async def _iter():
@@ -710,27 +682,27 @@ class TestWorkflowAgent:
self.description: str | None = None
self._response_text = response_text
def get_new_thread(self, **kwargs: Any) -> AgentThread:
return AgentThread()
def create_session(self, **kwargs: Any) -> AgentSession:
return AgentSession()
def run(
self,
messages: str | Content | Message | Sequence[str | Content | Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
if stream:
return self._run_stream(messages=messages, thread=thread, **kwargs)
return self._run(messages=messages, thread=thread, **kwargs)
return self._run_stream(messages=messages, session=session, **kwargs)
return self._run(messages=messages, session=session, **kwargs)
async def _run(
self,
messages: str | Content | Message | Sequence[str | Content | Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> AgentResponse:
@@ -742,7 +714,7 @@ class TestWorkflowAgent:
self,
messages: str | Content | Message | Sequence[str | Content | Message] | None = None,
*,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> ResponseStream[AgentResponseUpdate, AgentResponse]:
async def _iter():
@@ -1037,7 +1009,7 @@ class TestWorkflowAgentMergeUpdates:
def test_merge_updates_function_result_ordering_github_2977(self):
"""Test that FunctionResultContent updates are placed after their FunctionCallContent.
This test reproduces GitHub issue #2977: When using a thread with WorkflowAgent,
This test reproduces GitHub issue #2977: When using a session with WorkflowAgent,
FunctionResultContent updates without response_id were being added to global_dangling
and placed at the end of messages. This caused OpenAI to reject the conversation because
"An assistant message with 'tool_calls' must be followed by tool messages responding
@@ -9,7 +9,7 @@ from agent_framework import (
AgentExecutor,
AgentResponse,
AgentResponseUpdate,
AgentThread,
AgentSession,
BaseAgent,
Executor,
Message,
@@ -21,7 +21,7 @@ from agent_framework import (
class DummyAgent(BaseAgent):
def run(self, messages=None, *, stream: bool = False, thread: AgentThread | None = None, **kwargs): # type: ignore[override]
def run(self, messages=None, *, stream: bool = False, session: AgentSession | None = None, **kwargs): # type: ignore[override]
if stream:
return self._run_stream_impl()
return self._run_impl(messages)
@@ -8,7 +8,7 @@ import pytest
from agent_framework import (
AgentResponse,
AgentResponseUpdate,
AgentThread,
AgentSession,
BaseAgent,
Content,
Message,
@@ -55,7 +55,7 @@ class _KwargsCapturingAgent(BaseAgent):
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
self.captured_kwargs.append(dict(kwargs))
@@ -88,7 +88,7 @@ class _OptionsAwareAgent(BaseAgent):
messages: str | Message | Sequence[str | Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
options: dict[str, Any] | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260210"
version = "1.0.0b260212"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0b260210",
"agent-framework-core>=1.0.0b260212",
"powerfx>=0.0.31; python_version < '3.14'",
"pyyaml>=6.0,<7.0",
]
@@ -3,7 +3,7 @@
"""Conversation storage abstraction for OpenAI Conversations API.
This module provides a clean abstraction layer for managing conversations
while wrapping AgentFramework's AgentThread underneath.
with in-memory message storage.
"""
from __future__ import annotations
@@ -13,8 +13,8 @@ import uuid
from abc import ABC, abstractmethod
from typing import Any, Literal, cast
from agent_framework import AgentThread, Message
from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage
from agent_framework import AgentSession, Message
from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage, WorkflowCheckpoint
from openai.types.conversations import Conversation, ConversationDeletedResource
from openai.types.conversations.conversation_item import ConversationItem
from openai.types.conversations.message import Message as OpenAIMessage
@@ -38,14 +38,14 @@ class ConversationStore(ABC):
"""Abstract base class for conversation storage.
Provides OpenAI Conversations API interface while managing
AgentThread instances underneath.
message storage internally.
"""
@abstractmethod
def create_conversation(
self, metadata: dict[str, str] | None = None, conversation_id: str | None = None
) -> Conversation:
"""Create a new conversation (wraps AgentThread creation).
"""Create a new conversation.
Args:
metadata: Optional metadata dict (e.g., {"agent_id": "weather_agent"})
@@ -86,7 +86,7 @@ class ConversationStore(ABC):
@abstractmethod
def delete_conversation(self, conversation_id: str) -> ConversationDeletedResource:
"""Delete conversation (including AgentThread).
"""Delete conversation.
Args:
conversation_id: Conversation ID
@@ -101,7 +101,7 @@ class ConversationStore(ABC):
@abstractmethod
async def add_items(self, conversation_id: str, items: list[dict[str, Any]]) -> list[ConversationItem]:
"""Add items to conversation (syncs to AgentThread.message_store).
"""Add items to conversation.
Args:
conversation_id: Conversation ID
@@ -119,7 +119,7 @@ class ConversationStore(ABC):
async def list_items(
self, conversation_id: str, limit: int = 100, after: str | None = None, order: str = "asc"
) -> tuple[list[ConversationItem], bool]:
"""List conversation items from AgentThread.message_store.
"""List conversation items.
Args:
conversation_id: Conversation ID
@@ -152,17 +152,17 @@ class ConversationStore(ABC):
pass
@abstractmethod
def get_thread(self, conversation_id: str) -> AgentThread | None:
"""Get underlying AgentThread for execution (internal use).
def get_session(self, conversation_id: str) -> AgentSession | None:
"""Get AgentSession for agent execution.
This is the critical method that allows the executor to get the
AgentThread for running agents with conversation context.
AgentSession for running agents with conversation context.
Args:
conversation_id: Conversation ID
Returns:
AgentThread object or None if not found
AgentSession object or None if not found
"""
pass
@@ -183,7 +183,7 @@ class ConversationStore(ABC):
"""Add a trace event to the conversation for context inspection.
Traces capture execution metadata like token usage, timing, and LLM context
that isn't stored in the AgentThread but is useful for debugging.
that is useful for debugging.
Args:
conversation_id: Conversation ID
@@ -205,17 +205,17 @@ class ConversationStore(ABC):
class InMemoryConversationStore(ConversationStore):
"""In-memory conversation storage wrapping AgentThread.
"""In-memory conversation storage.
This implementation stores conversations in memory with their
underlying AgentThread instances for execution.
underlying message lists and AgentSession instances for execution.
"""
def __init__(self) -> None:
"""Initialize in-memory conversation storage.
Storage structure maps conversation IDs to conversation data including
the underlying AgentThread, metadata, and cached ConversationItems.
messages, metadata, and cached ConversationItems.
"""
self._conversations: dict[str, dict[str, Any]] = {}
@@ -225,20 +225,22 @@ class InMemoryConversationStore(ConversationStore):
def create_conversation(
self, metadata: dict[str, str] | None = None, conversation_id: str | None = None
) -> Conversation:
"""Create a new conversation with underlying AgentThread and checkpoint storage."""
"""Create a new conversation with message storage and checkpoint storage."""
conv_id = conversation_id or f"conv_{uuid.uuid4().hex}"
created_at = int(time.time())
# Create AgentThread with default ChatMessageStore
thread = AgentThread()
# Create message list for internal storage and AgentSession for execution
messages: list[Message] = []
session = AgentSession(session_id=conv_id)
# Create session-scoped checkpoint storage (one per conversation)
checkpoint_storage = InMemoryCheckpointStorage()
self._conversations[conv_id] = {
"id": conv_id,
"thread": thread,
"checkpoint_storage": checkpoint_storage, # Stored alongside thread
"messages": messages,
"session": session,
"checkpoint_storage": checkpoint_storage,
"metadata": metadata or {},
"created_at": created_at,
"items": [],
@@ -279,7 +281,7 @@ class InMemoryConversationStore(ConversationStore):
)
def delete_conversation(self, conversation_id: str) -> ConversationDeletedResource:
"""Delete conversation and its AgentThread."""
"""Delete conversation."""
if conversation_id not in self._conversations:
raise ValueError(f"Conversation {conversation_id} not found")
@@ -290,14 +292,14 @@ class InMemoryConversationStore(ConversationStore):
return ConversationDeletedResource(id=conversation_id, object="conversation.deleted", deleted=True)
async def add_items(self, conversation_id: str, items: list[dict[str, Any]]) -> list[ConversationItem]:
"""Add items to conversation and sync to AgentThread."""
"""Add items to conversation."""
conv_data = self._conversations.get(conversation_id)
if not conv_data:
raise ValueError(f"Conversation {conversation_id} not found")
thread: AgentThread = conv_data["thread"]
stored_messages: list[Message] = conv_data["messages"]
# Convert items to ChatMessages and add to thread
# Convert items to Messages and add to storage
chat_messages = []
for item in items:
# Simple conversion - assume text content for now
@@ -308,8 +310,8 @@ class InMemoryConversationStore(ConversationStore):
chat_msg = Message(role=role, text=text) # type: ignore[arg-type]
chat_messages.append(chat_msg)
# Add messages to AgentThread
await thread.on_new_messages(chat_messages)
# Add messages to internal storage
stored_messages.extend(chat_messages)
# Create Message objects (ConversationItem is a Union - use concrete Message type)
conv_items: list[ConversationItem] = []
@@ -354,9 +356,9 @@ class InMemoryConversationStore(ConversationStore):
async def list_items(
self, conversation_id: str, limit: int = 100, after: str | None = None, order: str = "asc"
) -> tuple[list[ConversationItem], bool]:
"""List conversation items from AgentThread message store.
"""List conversation items.
Converts AgentFramework ChatMessages to proper OpenAI ConversationItem types:
Converts stored Messages to proper OpenAI ConversationItem types:
- Messages with text/images/files Message
- Function calls ResponseFunctionToolCallItem
- Function results ResponseFunctionToolCallOutputItem
@@ -365,125 +367,120 @@ class InMemoryConversationStore(ConversationStore):
if not conv_data:
raise ValueError(f"Conversation {conversation_id} not found")
thread: AgentThread = conv_data["thread"]
stored_messages: list[Message] = conv_data["messages"]
# Get messages from thread's message store
# Convert stored messages to ConversationItem types
items: list[ConversationItem] = []
if thread.message_store:
af_messages = await thread.message_store.list_messages()
af_messages = stored_messages
# Convert each AgentFramework Message to appropriate ConversationItem type(s)
for i, msg in enumerate(af_messages):
item_id = f"item_{i}"
role_str = msg.role if hasattr(msg.role, "value") else str(msg.role)
role = cast(MessageRole, role_str) # Safe: Agent Framework roles match OpenAI roles
# Convert each AgentFramework Message to appropriate ConversationItem type(s)
for i, msg in enumerate(af_messages):
item_id = f"item_{i}"
role_str = msg.role if hasattr(msg.role, "value") else str(msg.role)
role = cast(MessageRole, role_str) # Safe: Agent Framework roles match OpenAI roles
# Process each content item in the message
# A single Message may produce multiple ConversationItems
# (e.g., a message with both text and a function call)
message_contents: list[TextContent | ResponseInputImage | ResponseInputFile] = []
function_calls = []
function_results = []
# Process each content item in the message
# A single Message may produce multiple ConversationItems
# (e.g., a message with both text and a function call)
message_contents: list[TextContent | ResponseInputImage | ResponseInputFile] = []
function_calls = []
function_results = []
for content in msg.contents:
content_type = getattr(content, "type", None)
for content in msg.contents:
content_type = getattr(content, "type", None)
if content_type == "text":
# Text content for Message
text_value = getattr(content, "text", "")
message_contents.append(TextContent(type="text", text=text_value))
if content_type == "text":
# Text content for Message
text_value = getattr(content, "text", "")
message_contents.append(TextContent(type="text", text=text_value))
elif content_type == "data":
# Data content (images, files, PDFs)
uri = getattr(content, "uri", "")
media_type = getattr(content, "media_type", None)
elif content_type == "data":
# Data content (images, files, PDFs)
uri = getattr(content, "uri", "")
media_type = getattr(content, "media_type", None)
if media_type and media_type.startswith("image/"):
# Convert to ResponseInputImage
message_contents.append(
ResponseInputImage(type="input_image", image_url=uri, detail="auto")
if media_type and media_type.startswith("image/"):
# Convert to ResponseInputImage
message_contents.append(ResponseInputImage(type="input_image", image_url=uri, detail="auto"))
else:
# Convert to ResponseInputFile
# Extract filename from URI if possible
filename = None
if media_type == "application/pdf":
filename = "document.pdf"
message_contents.append(ResponseInputFile(type="input_file", file_url=uri, filename=filename))
elif content_type == "function_call":
# Function call - create separate ConversationItem
call_id = getattr(content, "call_id", None)
name = getattr(content, "name", "")
arguments = getattr(content, "arguments", "")
if call_id and name:
function_calls.append(
ResponseFunctionToolCallItem(
id=f"{item_id}_call_{call_id}",
call_id=call_id,
name=name,
arguments=arguments,
type="function_call",
status="completed",
)
else:
# Convert to ResponseInputFile
# Extract filename from URI if possible
filename = None
if media_type == "application/pdf":
filename = "document.pdf"
)
message_contents.append(
ResponseInputFile(type="input_file", file_url=uri, filename=filename)
elif content_type == "function_result":
# Function result - create separate ConversationItem
call_id = getattr(content, "call_id", None)
# Output is stored in the 'result' field of FunctionResultContent
result_value = getattr(content, "result", None)
# Convert result to string (it could be dict, list, or other types)
if result_value is None:
output = ""
elif isinstance(result_value, str):
output = result_value
else:
import json
try:
output = json.dumps(result_value)
except (TypeError, ValueError):
output = str(result_value)
if call_id:
function_results.append(
ResponseFunctionToolCallOutputItem(
id=f"{item_id}_result_{call_id}",
call_id=call_id,
output=output,
type="function_call_output",
status="completed",
)
)
elif content_type == "function_call":
# Function call - create separate ConversationItem
call_id = getattr(content, "call_id", None)
name = getattr(content, "name", "")
arguments = getattr(content, "arguments", "")
# Create ConversationItems based on what we found
# If message has text/images/files, create a Message item
if message_contents:
message = OpenAIMessage(
id=item_id,
type="message",
role=role, # type: ignore
content=message_contents, # type: ignore
status="completed",
)
items.append(message)
if call_id and name:
function_calls.append(
ResponseFunctionToolCallItem(
id=f"{item_id}_call_{call_id}",
call_id=call_id,
name=name,
arguments=arguments,
type="function_call",
status="completed",
)
)
# Add function call items
items.extend(function_calls)
elif content_type == "function_result":
# Function result - create separate ConversationItem
call_id = getattr(content, "call_id", None)
# Output is stored in the 'result' field of FunctionResultContent
result_value = getattr(content, "result", None)
# Convert result to string (it could be dict, list, or other types)
if result_value is None:
output = ""
elif isinstance(result_value, str):
output = result_value
else:
import json
try:
output = json.dumps(result_value)
except (TypeError, ValueError):
output = str(result_value)
if call_id:
function_results.append(
ResponseFunctionToolCallOutputItem(
id=f"{item_id}_result_{call_id}",
call_id=call_id,
output=output,
type="function_call_output",
status="completed",
)
)
# Create ConversationItems based on what we found
# If message has text/images/files, create a Message item
if message_contents:
message = OpenAIMessage(
id=item_id,
type="message",
role=role, # type: ignore
content=message_contents, # type: ignore
status="completed",
)
items.append(message)
# Add function call items
items.extend(function_calls)
# Add function result items
items.extend(function_results)
# Add function result items
items.extend(function_results)
# Include checkpoints from checkpoint storage as conversation items
checkpoint_storage = conv_data.get("checkpoint_storage")
if checkpoint_storage:
# Get all checkpoints for this conversation
checkpoints = await checkpoint_storage.list_checkpoints()
checkpoints = self._list_all_checkpoints(checkpoint_storage)
for checkpoint in checkpoints:
# Create a conversation item for each checkpoint with summary metadata
# Full checkpoint state is NOT included here (too large for list view)
@@ -498,7 +495,9 @@ class InMemoryConversationStore(ConversationStore):
"id": f"checkpoint_{checkpoint.checkpoint_id}",
"type": "checkpoint",
"checkpoint_id": checkpoint.checkpoint_id,
"workflow_id": checkpoint.workflow_id,
# Keep workflow_id for backward compatibility with existing UI payloads.
"workflow_id": checkpoint.workflow_name,
"workflow_name": checkpoint.workflow_name,
"timestamp": checkpoint.timestamp,
"status": "completed",
"metadata": {
@@ -509,6 +508,7 @@ class InMemoryConversationStore(ConversationStore):
"message_count": sum(len(msgs) for msgs in checkpoint.messages.values()),
"size_bytes": checkpoint_size,
"version": checkpoint.version,
"graph_signature_hash": checkpoint.graph_signature_hash,
},
}
items.append(cast(ConversationItem, checkpoint_item))
@@ -554,8 +554,9 @@ class InMemoryConversationStore(ConversationStore):
return None
# Load full checkpoint from storage
checkpoint = await checkpoint_storage.load_checkpoint(checkpoint_id)
if not checkpoint:
try:
checkpoint = await checkpoint_storage.load(checkpoint_id)
except Exception:
return None
# Calculate size of checkpoint
@@ -569,7 +570,9 @@ class InMemoryConversationStore(ConversationStore):
"id": item_id,
"type": "checkpoint",
"checkpoint_id": checkpoint.checkpoint_id,
"workflow_id": checkpoint.workflow_id,
# Keep workflow_id for backward compatibility with existing UI payloads.
"workflow_id": checkpoint.workflow_name,
"workflow_name": checkpoint.workflow_name,
"timestamp": checkpoint.timestamp,
"status": "completed",
"metadata": {
@@ -580,6 +583,7 @@ class InMemoryConversationStore(ConversationStore):
"message_count": sum(len(msgs) for msgs in checkpoint.messages.values()),
"size_bytes": checkpoint_size,
"version": checkpoint.version,
"graph_signature_hash": checkpoint.graph_signature_hash,
# 🔥 FULL checkpoint state (lazy loaded)
"full_checkpoint": checkpoint.to_dict(),
},
@@ -589,16 +593,16 @@ class InMemoryConversationStore(ConversationStore):
return None
def get_thread(self, conversation_id: str) -> AgentThread | None:
"""Get AgentThread for execution - CRITICAL for agent.run()."""
def get_session(self, conversation_id: str) -> AgentSession | None:
"""Get AgentSession for execution - CRITICAL for agent.run()."""
conv_data = self._conversations.get(conversation_id)
return conv_data["thread"] if conv_data else None
return conv_data["session"] if conv_data else None
def add_trace(self, conversation_id: str, trace_event: dict[str, Any]) -> None:
"""Add a trace event to the conversation for context inspection.
Traces capture execution metadata like token usage, timing, and LLM context
that isn't stored in the AgentThread but is useful for debugging.
that is useful for debugging.
Args:
conversation_id: Conversation ID
@@ -634,8 +638,8 @@ class InMemoryConversationStore(ConversationStore):
if conv_meta.get("type") == "workflow_session":
checkpoint_storage = conv_data.get("checkpoint_storage")
if checkpoint_storage:
checkpoints = await checkpoint_storage.list_checkpoints()
latest = checkpoints[0] if checkpoints else None
checkpoints = self._list_all_checkpoints(checkpoint_storage)
latest = max(checkpoints, key=lambda cp: cp.timestamp) if checkpoints else None
conv_meta["checkpoint_summary"] = {
"count": len(checkpoints),
"latest_iteration": latest.iteration_count if latest else 0,
@@ -657,6 +661,19 @@ class InMemoryConversationStore(ConversationStore):
return results
@staticmethod
def _list_all_checkpoints(checkpoint_storage: Any) -> list[WorkflowCheckpoint]:
"""Return all checkpoints from a conversation-scoped storage instance.
DevUI uses one checkpoint storage per conversation. Core storage APIs now
require workflow_name filters, so we gather directly from in-memory storage
internals to provide conversation-wide listing for UI views.
"""
checkpoint_map = getattr(checkpoint_storage, "_checkpoints", None)
if isinstance(checkpoint_map, dict):
return list(cast(dict[str, WorkflowCheckpoint], checkpoint_map).values())
return []
class CheckpointConversationManager:
"""Manages checkpoint storage for workflow sessions - SESSION-SCOPED.
@@ -308,15 +308,15 @@ class AgentFrameworkExecutor:
# Convert input to proper Message or string
user_message = self._convert_input_to_chat_message(request.input)
# Get thread from conversation parameter (OpenAI standard!)
thread = None
# Get session from conversation parameter (OpenAI standard!)
session = None
conversation_id = request._get_conversation_id()
if conversation_id:
thread = self.conversation_store.get_thread(conversation_id)
if thread:
session = self.conversation_store.get_session(conversation_id)
if session:
logger.debug(f"Using existing conversation: {conversation_id}")
else:
logger.warning(f"Conversation {conversation_id} not found, proceeding without thread")
logger.warning(f"Conversation {conversation_id} not found, proceeding without session")
if isinstance(user_message, str):
logger.debug(f"Executing agent with text input: {user_message[:100]}...")
@@ -331,8 +331,8 @@ class AgentFrameworkExecutor:
# Agent must have run() method - use stream=True for streaming
if hasattr(agent, "run") and callable(agent.run):
# Use Agent Framework's run() with stream=True for streaming
if thread:
async for update in agent.run(user_message, stream=True, thread=thread):
if session:
async for update in agent.run(user_message, stream=True, session=session):
for trace_event in trace_collector.get_pending_events():
yield trace_event
File diff suppressed because one or more lines are too long
@@ -30,7 +30,7 @@ export const SAMPLE_ENTITIES: SampleEntity[] = [
description:
"Weather agent using Azure AI Agent (Foundry) with Azure CLI authentication",
type: "agent",
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/getting_started/devui/foundry_agent/agent.py",
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/foundry_agent/agent.py",
tags: ["azure-ai", "foundry", "tools"],
author: "Microsoft",
difficulty: "beginner",
@@ -61,7 +61,7 @@ export const SAMPLE_ENTITIES: SampleEntity[] = [
description:
"Weather agent using Azure OpenAI with API key authentication",
type: "agent",
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/getting_started/devui/weather_agent_azure/agent.py",
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/weather_agent_azure/agent.py",
tags: ["azure", "openai", "tools"],
author: "Microsoft",
difficulty: "beginner",
@@ -99,7 +99,7 @@ export const SAMPLE_ENTITIES: SampleEntity[] = [
description:
"5-step workflow demonstrating email spam detection with branching logic",
type: "workflow",
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/getting_started/devui/spam_workflow/workflow.py",
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/spam_workflow/workflow.py",
tags: ["workflow", "branching", "multi-step"],
author: "Microsoft",
difficulty: "beginner",
@@ -117,7 +117,7 @@ export const SAMPLE_ENTITIES: SampleEntity[] = [
description:
"Advanced data processing workflow with parallel validation, transformation, and quality assurance stages",
type: "workflow",
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/getting_started/devui/fanout_workflow/workflow.py",
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/fanout_workflow/workflow.py",
tags: ["workflow", "fan-out", "fan-in", "parallel"],
author: "Microsoft",
difficulty: "advanced",
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260210"
version = "1.0.0b260212"
license-files = ["LICENSE"]
urls.homepage = "https://github.com/microsoft/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0b260210",
"agent-framework-core>=1.0.0b260212",
"fastapi>=0.104.0",
"uvicorn[standard]>=0.24.0",
"python-dotenv>=1.0.0",
+11 -11
View File
@@ -20,7 +20,7 @@ from agent_framework import (
Agent,
AgentResponse,
AgentResponseUpdate,
AgentThread,
AgentSession,
BaseAgent,
BaseChatClient,
ChatResponse,
@@ -162,19 +162,19 @@ class MockAgent(BaseAgent):
messages: str | Message | list[str] | list[Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
self.call_count += 1
if stream:
return self._run_stream(messages=messages, thread=thread, **kwargs)
return self._run(messages=messages, thread=thread, **kwargs)
return self._run_stream(messages=messages, session=session, **kwargs)
return self._run(messages=messages, session=session, **kwargs)
async def _run(
self,
messages: str | Message | list[str] | list[Message] | None = None,
*,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> AgentResponse:
self.call_count += 1
@@ -184,7 +184,7 @@ class MockAgent(BaseAgent):
self,
messages: str | Message | list[str] | list[Message] | None = None,
*,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> ResponseStream[AgentResponseUpdate, AgentResponse]:
self.call_count += 1
@@ -208,19 +208,19 @@ class MockToolCallingAgent(BaseAgent):
messages: str | Message | list[str] | list[Message] | None = None,
*,
stream: bool = False,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
self.call_count += 1
if stream:
return self._run_stream(messages=messages, thread=thread, **kwargs)
return self._run(messages=messages, thread=thread, **kwargs)
return self._run_stream(messages=messages, session=session, **kwargs)
return self._run(messages=messages, session=session, **kwargs)
async def _run(
self,
messages: str | Message | list[str] | list[Message] | None = None,
*,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> AgentResponse:
return AgentResponse(messages=[Message("assistant", ["done"])])
@@ -229,7 +229,7 @@ class MockToolCallingAgent(BaseAgent):
self,
messages: str | Message | list[str] | list[Message] | None = None,
*,
thread: AgentThread | None = None,
session: AgentSession | None = None,
**kwargs: Any,
) -> ResponseStream[AgentResponseUpdate, AgentResponse]:
async def _iter() -> AsyncIterable[AgentResponseUpdate]:
@@ -104,17 +104,21 @@ class TestCheckpointConversationManager:
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
checkpoint = WorkflowCheckpoint(
checkpoint_id=str(uuid.uuid4()), workflow_id=test_workflow.id, messages={}, state={"test": "data"}
checkpoint_id=str(uuid.uuid4()),
workflow_name=test_workflow.name,
graph_signature_hash=test_workflow.graph_signature_hash,
messages={},
state={"test": "data"},
)
# Get checkpoint storage for this conversation and save
storage = checkpoint_manager.get_checkpoint_storage(conversation_id)
checkpoint_id = await storage.save_checkpoint(checkpoint)
checkpoint_id = await storage.save(checkpoint)
assert checkpoint_id == checkpoint.checkpoint_id
# Verify checkpoint stored in THIS conversation only
checkpoints = await storage.list_checkpoints()
checkpoints = await storage.list_checkpoints(workflow_name=test_workflow.name)
assert len(checkpoints) == 1
assert checkpoints[0].checkpoint_id == checkpoint.checkpoint_id
@@ -140,20 +144,21 @@ class TestCheckpointConversationManager:
checkpoint_a = WorkflowCheckpoint(
checkpoint_id=str(uuid.uuid4()),
workflow_id=test_workflow.id,
workflow_name=test_workflow.name,
graph_signature_hash=test_workflow.graph_signature_hash,
messages={},
state={"conversation": "A"},
)
storage_a = checkpoint_manager.get_checkpoint_storage(conv_a)
await storage_a.save_checkpoint(checkpoint_a)
await storage_a.save(checkpoint_a)
# Verify conversation A has checkpoint
checkpoints_a = await storage_a.list_checkpoints()
checkpoints_a = await storage_a.list_checkpoints(workflow_name=test_workflow.name)
assert len(checkpoints_a) == 1
# Verify conversation B has NO checkpoints (isolation)
storage_b = checkpoint_manager.get_checkpoint_storage(conv_b)
checkpoints_b = await storage_b.list_checkpoints()
checkpoints_b = await storage_b.list_checkpoints(workflow_name=test_workflow.name)
assert len(checkpoints_b) == 0
@pytest.mark.asyncio
@@ -177,15 +182,16 @@ class TestCheckpointConversationManager:
for i in range(3):
checkpoint = WorkflowCheckpoint(
checkpoint_id=str(uuid.uuid4()),
workflow_id=test_workflow.id,
workflow_name=test_workflow.name,
graph_signature_hash=test_workflow.graph_signature_hash,
messages={},
state={"iteration": i},
)
saved_id = await storage.save_checkpoint(checkpoint)
saved_id = await storage.save(checkpoint)
checkpoint_ids.append(saved_id)
# List checkpoints using the storage
checkpoints_list = await storage.list_checkpoints()
checkpoints_list = await storage.list_checkpoints(workflow_name=test_workflow.name)
assert len(checkpoints_list) == 3
# Verify all checkpoint IDs are present
@@ -213,11 +219,12 @@ class TestCheckpointConversationManager:
for i in range(2):
checkpoint = WorkflowCheckpoint(
checkpoint_id=f"checkpoint_{i}",
workflow_id=test_workflow.id,
workflow_name=test_workflow.name,
graph_signature_hash=test_workflow.graph_signature_hash,
messages={},
state={"iteration": i},
)
saved_id = await storage.save_checkpoint(checkpoint)
saved_id = await storage.save(checkpoint)
checkpoint_ids.append(saved_id)
# List conversation items - should include checkpoints
@@ -233,7 +240,7 @@ class TestCheckpointConversationManager:
for item in checkpoint_items:
assert item.get("type") == "checkpoint"
assert item.get("checkpoint_id") in checkpoint_ids
assert item.get("workflow_id") == test_workflow.id
assert item.get("workflow_name") == test_workflow.name
assert "timestamp" in item
assert item.get("id").startswith("checkpoint_") # ID format: checkpoint_{checkpoint_id}
@@ -255,21 +262,22 @@ class TestCheckpointConversationManager:
original_checkpoint = WorkflowCheckpoint(
checkpoint_id=str(uuid.uuid4()),
workflow_id=test_workflow.id,
workflow_name=test_workflow.name,
graph_signature_hash=test_workflow.graph_signature_hash,
messages={},
state={"test_key": "test_value"},
)
# Save to this session
storage = checkpoint_manager.get_checkpoint_storage(conversation_id)
await storage.save_checkpoint(original_checkpoint)
await storage.save(original_checkpoint)
# Load checkpoint from this session
loaded_checkpoint = await storage.load_checkpoint(original_checkpoint.checkpoint_id)
loaded_checkpoint = await storage.load(original_checkpoint.checkpoint_id)
assert loaded_checkpoint is not None
assert loaded_checkpoint.checkpoint_id == original_checkpoint.checkpoint_id
assert loaded_checkpoint.workflow_id == original_checkpoint.workflow_id
assert loaded_checkpoint.workflow_name == original_checkpoint.workflow_name
assert loaded_checkpoint.state == {"test_key": "test_value"}
@@ -296,24 +304,28 @@ class TestCheckpointStorage:
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
checkpoint = WorkflowCheckpoint(
checkpoint_id=str(uuid.uuid4()), workflow_id=test_workflow.id, messages={}, state={"test": "data"}
checkpoint_id=str(uuid.uuid4()),
workflow_name=test_workflow.name,
graph_signature_hash=test_workflow.graph_signature_hash,
messages={},
state={"test": "data"},
)
# Test save_checkpoint
checkpoint_id = await storage.save_checkpoint(checkpoint)
# Test save
checkpoint_id = await storage.save(checkpoint)
assert checkpoint_id == checkpoint.checkpoint_id
# Test load_checkpoint
loaded = await storage.load_checkpoint(checkpoint_id)
# Test load
loaded = await storage.load(checkpoint_id)
assert loaded is not None
assert loaded.checkpoint_id == checkpoint_id
# Test list_checkpoint_ids
ids = await storage.list_checkpoint_ids(workflow_id=test_workflow.id)
ids = await storage.list_checkpoint_ids(workflow_name=test_workflow.name)
assert checkpoint_id in ids
# Test list_checkpoints
checkpoints_list = await storage.list_checkpoints(workflow_id=test_workflow.id)
checkpoints_list = await storage.list_checkpoints(workflow_name=test_workflow.name)
assert len(checkpoints_list) >= 1
assert any(cp.checkpoint_id == checkpoint_id for cp in checkpoints_list)
@@ -346,12 +358,16 @@ class TestIntegration:
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
checkpoint = WorkflowCheckpoint(
checkpoint_id=str(uuid.uuid4()), workflow_id=test_workflow.id, messages={}, state={"injected": True}
checkpoint_id=str(uuid.uuid4()),
workflow_name=test_workflow.name,
graph_signature_hash=test_workflow.graph_signature_hash,
messages={},
state={"injected": True},
)
await checkpoint_storage.save_checkpoint(checkpoint)
await checkpoint_storage.save(checkpoint)
# Verify checkpoint is accessible via storage (in this session)
storage_checkpoints = await checkpoint_storage.list_checkpoints()
storage_checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=test_workflow.name)
assert len(storage_checkpoints) > 0
assert storage_checkpoints[0].checkpoint_id == checkpoint.checkpoint_id
@@ -377,20 +393,21 @@ class TestIntegration:
checkpoint = WorkflowCheckpoint(
checkpoint_id=str(uuid.uuid4()),
workflow_id=test_workflow.id,
workflow_name=test_workflow.name,
graph_signature_hash=test_workflow.graph_signature_hash,
messages={},
state={"ready_to_resume": True},
)
checkpoint_id = await checkpoint_storage.save_checkpoint(checkpoint)
checkpoint_id = await checkpoint_storage.save(checkpoint)
# Verify checkpoint can be loaded for resume
loaded = await checkpoint_storage.load_checkpoint(checkpoint_id)
loaded = await checkpoint_storage.load(checkpoint_id)
assert loaded is not None
assert loaded.checkpoint_id == checkpoint_id
assert loaded.state == {"ready_to_resume": True}
# Verify checkpoint is accessible via storage (for UI to list checkpoints)
checkpoints = await checkpoint_storage.list_checkpoints()
checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=test_workflow.name)
assert len(checkpoints) > 0
assert checkpoints[0].checkpoint_id == checkpoint_id
@@ -420,7 +437,7 @@ class TestIntegration:
test_workflow._runner.context._checkpoint_storage = checkpoint_storage
# Verify no checkpoints initially
checkpoints_before = await checkpoint_storage.list_checkpoints()
checkpoints_before = await checkpoint_storage.list_checkpoints(workflow_name=test_workflow.name)
assert len(checkpoints_before) == 0
# Run workflow until it reaches IDLE_WITH_PENDING_REQUESTS (after checkpoint is created)
@@ -435,9 +452,9 @@ class TestIntegration:
assert saw_request_event, "Test workflow should have emitted request_info event (type='request_info')"
# Verify checkpoint was AUTOMATICALLY saved to our storage by the framework
checkpoints_after = await checkpoint_storage.list_checkpoints()
checkpoints_after = await checkpoint_storage.list_checkpoints(workflow_name=test_workflow.name)
assert len(checkpoints_after) > 0, "Workflow should have auto-saved checkpoint at HIL pause"
# Verify checkpoint has correct workflow_id
# Verify checkpoint has correct workflow identity
checkpoint = checkpoints_after[0]
assert checkpoint.workflow_id == test_workflow.id
assert checkpoint.workflow_name == test_workflow.name
@@ -83,29 +83,29 @@ async def test_delete_conversation():
@pytest.mark.asyncio
async def test_get_thread():
"""Test getting underlying AgentThread."""
async def test_get_session():
"""Test getting AgentSession for execution."""
store = InMemoryConversationStore()
# Create conversation
conversation = store.create_conversation(metadata={"agent_id": "test_agent"})
# Get thread
thread = store.get_thread(conversation.id)
# Get session
session = store.get_session(conversation.id)
assert thread is not None
# AgentThread should have message_store
assert hasattr(thread, "message_store")
assert session is not None
# AgentSession should have session_id
assert hasattr(session, "session_id")
@pytest.mark.asyncio
async def test_get_thread_not_found():
"""Test getting thread for non-existent conversation."""
async def test_get_session_not_found():
"""Test getting session for non-existent conversation."""
store = InMemoryConversationStore()
thread = store.get_thread("conv_nonexistent")
session = store.get_session("conv_nonexistent")
assert thread is None
assert session is None
@pytest.mark.asyncio
@@ -199,21 +199,13 @@ async def test_list_items_pagination():
@pytest.mark.asyncio
async def test_list_items_converts_function_calls():
"""Test that list_items properly converts function calls to ResponseFunctionToolCallItem."""
from agent_framework import ChatMessageStore, Message
from agent_framework import Message
store = InMemoryConversationStore()
# Create conversation
conversation = store.create_conversation(metadata={"agent_id": "test_agent"})
# Get the underlying thread and set up message store
thread = store.get_thread(conversation.id)
assert thread is not None
# Initialize message store if not present
if thread.message_store is None:
thread.message_store = ChatMessageStore()
# Simulate messages from agent execution with function calls
messages = [
Message(role="user", contents=[{"type": "text", "text": "What's the weather in SF?"}]),
@@ -241,8 +233,8 @@ async def test_list_items_converts_function_calls():
Message(role="assistant", contents=[{"type": "text", "text": "The weather is sunny, 65°F"}]),
]
# Add messages to thread
await thread.on_new_messages(messages)
# Add messages to internal storage
store._conversations[conversation.id]["messages"].extend(messages)
# List conversation items
items, has_more = await store.list_items(conversation.id)
@@ -284,20 +276,13 @@ async def test_list_items_converts_function_calls():
@pytest.mark.asyncio
async def test_list_items_handles_images_and_files():
"""Test that list_items properly converts data content (images/files) to OpenAI types."""
from agent_framework import ChatMessageStore, Message
from agent_framework import Message
store = InMemoryConversationStore()
# Create conversation
conversation = store.create_conversation(metadata={"agent_id": "test_agent"})
# Get the underlying thread
thread = store.get_thread(conversation.id)
assert thread is not None
if thread.message_store is None:
thread.message_store = ChatMessageStore()
# Simulate message with image and file
messages = [
Message(
@@ -310,7 +295,8 @@ async def test_list_items_handles_images_and_files():
),
]
await thread.on_new_messages(messages)
# Add messages to internal storage
store._conversations[conversation.id]["messages"].extend(messages)
# List items
items, has_more = await store.list_items(conversation.id)
@@ -74,14 +74,14 @@ async def test_discovery_accepts_agents_with_only_run():
init_file = agent_dir / "__init__.py"
init_file.write_text("""
from agent_framework import AgentResponse, AgentThread, Message, Role, Content
from agent_framework import AgentResponse, AgentSession, Message, Role, Content
class NonStreamingAgent:
id = "non_streaming"
name = "Non-Streaming Agent"
description = "Agent with run() method"
async def run(self, messages=None, *, thread=None, **kwargs):
async def run(self, messages=None, *, session=None, **kwargs):
return AgentResponse(
messages=[Message(
role="assistant",
@@ -90,8 +90,8 @@ class NonStreamingAgent:
response_id="test"
)
def get_new_thread(self, **kwargs):
return AgentThread()
def create_session(self, **kwargs):
return AgentSession()
agent = NonStreamingAgent()
""")
@@ -188,19 +188,19 @@ workflow = WorkflowBuilder(start_executor=executor).build()
agent_dir = temp_path / "my_agent"
agent_dir.mkdir()
(agent_dir / "agent.py").write_text("""
from agent_framework import AgentResponse, AgentThread, Message, Role, TextContent
from agent_framework import AgentResponse, AgentSession, Message, Role, TextContent
class TestAgent:
name = "Test Agent"
async def run(self, messages=None, *, thread=None, **kwargs):
async def run(self, messages=None, *, session=None, **kwargs):
return AgentResponse(
messages=[Message(role="assistant", contents=[Content.from_text(text="test")])],
response_id="test"
)
def get_new_thread(self, **kwargs):
return AgentThread()
def create_session(self, **kwargs):
return AgentSession()
agent = TestAgent()
""")
@@ -320,7 +320,7 @@ class WeatherAgent:
name = "Weather Agent"
description = "Gets weather information"
def run(self, input_str, *, stream: bool = False, thread=None, **kwargs):
def run(self, input_str, *, stream: bool = False, session=None, **kwargs):
return f"Weather in {input_str}"
""")

Some files were not shown because too many files have changed in this diff Show More