Python: [Breaking] Simplified Content types to a single class with classmethod constructors. (#3252)

* ported Content to a new model

* fixed linting

* fixes

* fixed data format handling

* fix for 3.10 mypy

* fix

* fix int test
This commit is contained in:
Eduard van Valkenburg
2026-01-20 22:09:39 +00:00
committed by GitHub
parent 73761aa4a3
commit 83e6229c11
132 changed files with 3949 additions and 4741 deletions
@@ -31,11 +31,8 @@ from agent_framework import (
AgentThread,
BaseAgent,
ChatMessage,
Contents,
DataContent,
Content,
Role,
TextContent,
UriContent,
normalize_messages,
prepend_agent_framework_to_user_agent,
)
@@ -333,7 +330,7 @@ class A2AAgent(BaseAgent):
A2APart(
root=FilePart(
file=FileWithBytes(
bytes=_get_uri_data(content.uri),
bytes=_get_uri_data(content.uri), # type: ignore[arg-type]
mime_type=content.media_type,
),
metadata=content.additional_properties,
@@ -362,19 +359,19 @@ class A2AAgent(BaseAgent):
metadata=cast(dict[str, Any], message.additional_properties),
)
def _parse_contents_from_a2a(self, parts: Sequence[A2APart]) -> list[Contents]:
"""Parse A2A Parts into Agent Framework Contents.
def _parse_contents_from_a2a(self, parts: Sequence[A2APart]) -> list[Content]:
"""Parse A2A Parts into Agent Framework Content.
Transforms A2A protocol Parts into framework-native Content objects,
handling text, file (URI/bytes), and data parts with metadata preservation.
"""
contents: list[Contents] = []
contents: list[Content] = []
for part in parts:
inner_part = part.root
match inner_part.kind:
case "text":
contents.append(
TextContent(
Content.from_text(
text=inner_part.text,
additional_properties=inner_part.metadata,
raw_representation=inner_part,
@@ -383,7 +380,7 @@ class A2AAgent(BaseAgent):
case "file":
if isinstance(inner_part.file, FileWithUri):
contents.append(
UriContent(
Content.from_uri(
uri=inner_part.file.uri,
media_type=inner_part.file.mime_type or "",
additional_properties=inner_part.metadata,
@@ -392,7 +389,7 @@ class A2AAgent(BaseAgent):
)
elif isinstance(inner_part.file, FileWithBytes):
contents.append(
DataContent(
Content.from_data(
data=base64.b64decode(inner_part.file.bytes),
media_type=inner_part.file.mime_type or "",
additional_properties=inner_part.metadata,
@@ -401,7 +398,7 @@ class A2AAgent(BaseAgent):
)
case "data":
contents.append(
TextContent(
Content.from_text(
text=json.dumps(inner_part.data),
additional_properties=inner_part.metadata,
raw_representation=inner_part,
+14 -18
View File
@@ -24,12 +24,8 @@ from agent_framework import (
AgentResponse,
AgentResponseUpdate,
ChatMessage,
DataContent,
ErrorContent,
HostedFileContent,
Content,
Role,
TextContent,
UriContent,
)
from agent_framework.a2a import A2AAgent
from pytest import fixture, raises
@@ -289,8 +285,8 @@ def test_parse_contents_from_a2a_conversion(a2a_agent: A2AAgent) -> None:
# Verify conversion
assert len(contents) == 2
assert isinstance(contents[0], TextContent)
assert isinstance(contents[1], TextContent)
assert contents[0].type == "text"
assert contents[1].type == "text"
assert contents[0].text == "First part"
assert contents[1].text == "Second part"
@@ -299,7 +295,7 @@ def test_prepare_message_for_a2a_with_error_content(a2a_agent: A2AAgent) -> None
"""Test _prepare_message_for_a2a with ErrorContent."""
# Create ChatMessage with ErrorContent
error_content = ErrorContent(message="Test error message")
error_content = Content.from_error(message="Test error message")
message = ChatMessage(role=Role.USER, contents=[error_content])
# Convert to A2A message
@@ -314,7 +310,7 @@ def test_prepare_message_for_a2a_with_uri_content(a2a_agent: A2AAgent) -> None:
"""Test _prepare_message_for_a2a with UriContent."""
# Create ChatMessage with UriContent
uri_content = UriContent(uri="http://example.com/file.pdf", media_type="application/pdf")
uri_content = Content.from_uri(uri="http://example.com/file.pdf", media_type="application/pdf")
message = ChatMessage(role=Role.USER, contents=[uri_content])
# Convert to A2A message
@@ -330,7 +326,7 @@ def test_prepare_message_for_a2a_with_data_content(a2a_agent: A2AAgent) -> None:
"""Test _prepare_message_for_a2a with DataContent."""
# Create ChatMessage with DataContent (base64 data URI)
data_content = DataContent(uri="data:text/plain;base64,SGVsbG8gV29ybGQ=", media_type="text/plain")
data_content = Content.from_uri(uri="data:text/plain;base64,SGVsbG8gV29ybGQ=", media_type="text/plain")
message = ChatMessage(role=Role.USER, contents=[data_content])
# Convert to A2A message
@@ -368,7 +364,7 @@ async def test_run_stream_with_message_response(a2a_agent: A2AAgent, mock_a2a_cl
assert len(updates[0].contents) == 1
content = updates[0].contents[0]
assert isinstance(content, TextContent)
assert content.type == "text"
assert content.text == "Streaming response from agent!"
assert updates[0].response_id == "msg-stream-123"
@@ -414,10 +410,10 @@ def test_prepare_message_for_a2a_with_multiple_contents() -> None:
message = ChatMessage(
role=Role.USER,
contents=[
TextContent(text="Here's the analysis:"),
DataContent(data=b"binary data", media_type="application/octet-stream"),
UriContent(uri="https://example.com/image.png", media_type="image/png"),
TextContent(text='{"structured": "data"}'),
Content.from_text(text="Here's the analysis:"),
Content.from_data(data=b"binary data", media_type="application/octet-stream"),
Content.from_uri(uri="https://example.com/image.png", media_type="image/png"),
Content.from_text(text='{"structured": "data"}'),
],
)
@@ -445,7 +441,7 @@ def test_parse_contents_from_a2a_with_data_part() -> None:
assert len(contents) == 1
assert isinstance(contents[0], TextContent)
assert contents[0].type == "text"
assert contents[0].text == '{"key": "value", "number": 42}'
assert contents[0].additional_properties == {"source": "test"}
@@ -470,7 +466,7 @@ def test_prepare_message_for_a2a_with_hosted_file() -> None:
# Create message with hosted file content
message = ChatMessage(
role=Role.USER,
contents=[HostedFileContent(file_id="hosted://storage/document.pdf")],
contents=[Content.from_hosted_file(file_id="hosted://storage/document.pdf")],
)
result = agent._prepare_message_for_a2a(message) # noqa: SLF001
@@ -507,7 +503,7 @@ def test_parse_contents_from_a2a_with_hosted_file_uri() -> None:
assert len(contents) == 1
assert isinstance(contents[0], UriContent)
assert contents[0].type == "uri"
assert contents[0].uri == "hosted://storage/document.pdf"
assert contents[0].media_type == "" # Converted None to empty string
@@ -17,12 +17,10 @@ from agent_framework import (
ChatMessage,
ChatResponse,
ChatResponseUpdate,
DataContent,
FunctionCallContent,
Content,
use_chat_middleware,
use_function_invocation,
)
from agent_framework._middleware import use_chat_middleware
from agent_framework._tools import use_function_invocation
from agent_framework._types import BaseContent, Contents
from agent_framework.observability import use_instrumentation
from ._event_converters import AGUIEventConverter
@@ -53,26 +51,11 @@ else:
logger: logging.Logger = logging.getLogger(__name__)
class ServerFunctionCallContent(BaseContent):
"""Wrapper for server function calls to prevent client re-execution.
All function calls from the remote server are server-side executions.
This wrapper prevents @use_function_invocation from trying to execute them again.
"""
function_call_content: FunctionCallContent
def __init__(self, function_call_content: FunctionCallContent) -> None:
"""Initialize with the function call content."""
super().__init__(type="server_function_call")
self.function_call_content = function_call_content
def _unwrap_server_function_call_contents(contents: MutableSequence[Contents | dict[str, Any]]) -> None:
"""Replace ServerFunctionCallContent instances with their underlying call content."""
def _unwrap_server_function_call_contents(contents: MutableSequence[Content | dict[str, Any]]) -> None:
"""Replace server_function_call instances with their underlying call content."""
for idx, content in enumerate(contents):
if isinstance(content, ServerFunctionCallContent):
contents[idx] = content.function_call_content # type: ignore[assignment]
if content.type == "server_function_call": # type: ignore[union-attr]
contents[idx] = content.function_call # type: ignore[assignment, union-attr]
TBaseChatClient = TypeVar("TBaseChatClient", bound=type[BaseChatClient[Any]])
@@ -93,7 +76,7 @@ def _apply_server_function_call_unwrap(chat_client: TBaseChatClient) -> TBaseCha
@wraps(original_get_streaming_response)
async def streaming_wrapper(self, *args: Any, **kwargs: Any) -> AsyncIterable[ChatResponseUpdate]:
async for update in original_get_streaming_response(self, *args, **kwargs):
_unwrap_server_function_call_contents(cast(MutableSequence[Contents | dict[str, Any]], update.contents))
_unwrap_server_function_call_contents(cast(MutableSequence[Content | dict[str, Any]], update.contents))
yield update
chat_client.get_streaming_response = streaming_wrapper # type: ignore[assignment]
@@ -105,9 +88,7 @@ def _apply_server_function_call_unwrap(chat_client: TBaseChatClient) -> TBaseCha
response = await original_get_response(self, *args, **kwargs)
if response.messages:
for message in response.messages:
_unwrap_server_function_call_contents(
cast(MutableSequence[Contents | dict[str, Any]], message.contents)
)
_unwrap_server_function_call_contents(cast(MutableSequence[Content | dict[str, Any]], message.contents))
return response
chat_client.get_response = response_wrapper # type: ignore[assignment]
@@ -289,13 +270,13 @@ class AGUIChatClient(BaseChatClient[TAGUIChatOptions], Generic[TAGUIChatOptions]
last_message = messages[-1]
for content in last_message.contents:
if isinstance(content, DataContent) and content.media_type == "application/json":
if isinstance(content, Content) and content.type == "data" and content.media_type == "application/json":
try:
uri = content.uri
if uri.startswith("data:application/json;base64,"):
if uri.startswith("data:application/json;base64,"): # type: ignore[union-attr]
import base64
encoded_data = uri.split(",", 1)[1]
encoded_data = uri.split(",", 1)[1] # type: ignore[union-attr]
decoded_bytes = base64.b64decode(encoded_data)
state = json.loads(decoded_bytes.decode("utf-8"))
@@ -433,19 +414,19 @@ class AGUIChatClient(BaseChatClient[TAGUIChatOptions], Generic[TAGUIChatOptions]
)
# Distinguish client vs server tools
for i, content in enumerate(update.contents):
if isinstance(content, FunctionCallContent):
if content.type == "function_call": # type: ignore[attr-defined]
logger.debug(
f"[AGUIChatClient] Function call: {content.name}, in client_tool_set: {content.name in client_tool_set}"
f"[AGUIChatClient] Function call: {content.name}, in client_tool_set: {content.name in client_tool_set}" # type: ignore[attr-defined]
)
if content.name in client_tool_set:
if content.name in client_tool_set: # type: ignore[attr-defined]
# Client tool - let @use_function_invocation execute it
if not content.additional_properties:
content.additional_properties = {}
content.additional_properties["agui_thread_id"] = thread_id
if not content.additional_properties: # type: ignore[attr-defined]
content.additional_properties = {} # type: ignore[attr-defined]
content.additional_properties["agui_thread_id"] = thread_id # type: ignore[attr-defined]
else:
# Server tool - wrap so @use_function_invocation ignores it
logger.debug(f"[AGUIChatClient] Wrapping server tool: {content.name}")
self._register_server_tool_placeholder(content.name)
update.contents[i] = ServerFunctionCallContent(content) # type: ignore
logger.debug(f"[AGUIChatClient] Wrapping server tool: {content.name}") # type: ignore[union-attr]
self._register_server_tool_placeholder(content.name) # type: ignore[arg-type]
update.contents[i] = Content(type="server_function_call", function_call=content) # type: ignore
yield update
@@ -6,12 +6,9 @@ from typing import Any
from agent_framework import (
ChatResponseUpdate,
ErrorContent,
Content,
FinishReason,
FunctionCallContent,
FunctionResultContent,
Role,
TextContent,
)
@@ -117,7 +114,7 @@ class AGUIEventConverter:
return ChatResponseUpdate(
role=Role.ASSISTANT,
message_id=self.current_message_id,
contents=[TextContent(text=delta)],
contents=[Content.from_text(text=delta)],
)
def _handle_text_message_end(self, event: dict[str, Any]) -> ChatResponseUpdate | None:
@@ -133,7 +130,7 @@ class AGUIEventConverter:
return ChatResponseUpdate(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
Content.from_function_call(
call_id=self.current_tool_call_id or "",
name=self.current_tool_name or "",
arguments="",
@@ -149,7 +146,7 @@ class AGUIEventConverter:
return ChatResponseUpdate(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
Content.from_function_call(
call_id=self.current_tool_call_id or "",
name=self.current_tool_name or "",
arguments=delta,
@@ -170,7 +167,7 @@ class AGUIEventConverter:
return ChatResponseUpdate(
role=Role.TOOL,
contents=[
FunctionResultContent(
Content.from_function_result(
call_id=tool_call_id,
result=result,
)
@@ -197,7 +194,7 @@ class AGUIEventConverter:
role=Role.ASSISTANT,
finish_reason=FinishReason.CONTENT_FILTER,
contents=[
ErrorContent(
Content.from_error(
message=error_message,
error_code="RUN_ERROR",
)
@@ -25,10 +25,7 @@ from ag_ui.core import (
)
from agent_framework import (
AgentResponseUpdate,
FunctionApprovalRequestContent,
FunctionCallContent,
FunctionResultContent,
TextContent,
Content,
prepare_function_call_results,
)
@@ -96,20 +93,22 @@ class AgentFrameworkEventBridge:
logger.info(f"Processing AgentRunUpdate with {len(update.contents)} content items")
for idx, content in enumerate(update.contents):
logger.info(f" Content {idx}: type={type(content).__name__}")
if isinstance(content, TextContent):
events.extend(self._handle_text_content(content))
elif isinstance(content, FunctionCallContent):
events.extend(self._handle_function_call_content(content))
elif isinstance(content, FunctionResultContent):
events.extend(self._handle_function_result_content(content))
elif isinstance(content, FunctionApprovalRequestContent):
events.extend(self._handle_function_approval_request_content(content))
match content.type:
case "text":
events.extend(self._handle_text_content(content))
case "function_call":
events.extend(self._handle_function_call_content(content))
case "function_result":
events.extend(self._handle_function_result_content(content))
case "function_approval_request":
events.extend(self._handle_function_approval_request_content(content))
case _:
logger.warning(f" Unsupported content type: {content.type}, skipping.")
return events
def _handle_text_content(self, content: TextContent) -> list[BaseEvent]:
def _handle_text_content(self, content: Content) -> list[BaseEvent]:
events: list[BaseEvent] = []
logger.info(f" TextContent found: length={len(content.text)}")
logger.info(f" TextContent found: length={len(content.text)}") # type: ignore[arg-type]
logger.info(
" Flags: skip_text_content=%s, should_stop_after_confirm=%s",
self.skip_text_content,
@@ -122,7 +121,7 @@ class AgentFrameworkEventBridge:
if self.should_stop_after_confirm:
logger.info(" SKIPPING TextContent: waiting for confirm_changes response")
self.suppressed_summary += content.text
self.suppressed_summary += content.text # type: ignore[operator]
logger.info(f" Suppressed summary length={len(self.suppressed_summary)}")
return events
@@ -150,14 +149,14 @@ class AgentFrameworkEventBridge:
events.append(event)
return events
def _handle_function_call_content(self, content: FunctionCallContent) -> list[BaseEvent]:
def _handle_function_call_content(self, content: Content) -> list[BaseEvent]:
events: list[BaseEvent] = []
if content.name:
logger.debug(f"Tool call: {content.name} (call_id: {content.call_id})")
if not content.name and not content.call_id and not self.current_tool_call_name:
args_length = len(str(content.arguments)) if content.arguments else 0
logger.warning(f"FunctionCallContent missing name and call_id. args_length={args_length}")
logger.warning(f"Content missing name and call_id. args_length={args_length}")
tool_call_id = self._coalesce_tool_call_id(content)
# Only emit ToolCallStartEvent once per tool call (when it's a new tool call)
@@ -190,7 +189,7 @@ class AgentFrameworkEventBridge:
return events
def _coalesce_tool_call_id(self, content: FunctionCallContent) -> str:
def _coalesce_tool_call_id(self, content: Content) -> str:
if content.call_id:
return content.call_id
if self.current_tool_call_id:
@@ -286,7 +285,7 @@ class AgentFrameworkEventBridge:
self.pending_state_updates[state_key] = state_value
return events
def _handle_function_result_content(self, content: FunctionResultContent) -> list[BaseEvent]:
def _handle_function_result_content(self, content: Content) -> list[BaseEvent]:
events: list[BaseEvent] = []
if content.call_id:
end_event = ToolCallEndEvent(
@@ -310,7 +309,7 @@ class AgentFrameworkEventBridge:
result_event = ToolCallResultEvent(
message_id=result_message_id,
tool_call_id=content.call_id,
tool_call_id=content.call_id, # type: ignore[arg-type]
content=result_content,
role="tool",
)
@@ -367,7 +366,7 @@ class AgentFrameworkEventBridge:
self.current_tool_call_name = None
return events
def _emit_confirm_changes_tool_call(self, function_call: FunctionCallContent | None = None) -> list[BaseEvent]:
def _emit_confirm_changes_tool_call(self, function_call: Content | None = None) -> list[BaseEvent]:
"""Emit a confirm_changes tool call for Dojo UI compatibility.
Args:
@@ -419,7 +418,7 @@ class AgentFrameworkEventBridge:
logger.info("Set flag to stop run after confirm_changes")
return events
def _emit_function_approval_tool_call(self, function_call: FunctionCallContent) -> list[BaseEvent]:
def _emit_function_approval_tool_call(self, function_call: Content) -> list[BaseEvent]:
"""Emit a tool call that can drive UI approval for function requests."""
tool_call_name = "confirm_changes"
if self.approval_tool_name and self.approval_tool_name != function_call.name:
@@ -462,13 +461,13 @@ class AgentFrameworkEventBridge:
logger.info("Set flag to stop run after confirm_changes")
return events
def _handle_function_approval_request_content(self, content: FunctionApprovalRequestContent) -> list[BaseEvent]:
def _handle_function_approval_request_content(self, content: Content) -> list[BaseEvent]:
events: list[BaseEvent] = []
logger.info("=== FUNCTION APPROVAL REQUEST ===")
logger.info(f" Function: {content.function_call.name}")
logger.info(f" Call ID: {content.function_call.call_id}")
logger.info(f" Function: {content.function_call.name}") # type: ignore[union-attr]
logger.info(f" Call ID: {content.function_call.call_id}") # type: ignore[union-attr]
parsed_args = content.function_call.parse_arguments()
parsed_args = content.function_call.parse_arguments() # type: ignore[union-attr]
parsed_arg_keys = list(parsed_args.keys()) if parsed_args else "None"
logger.info(f" Parsed args keys: {parsed_arg_keys}")
@@ -478,12 +477,12 @@ class AgentFrameworkEventBridge:
list(self.predict_state_config.keys()) if self.predict_state_config else "None",
)
for state_key, config in self.predict_state_config.items():
if config["tool"] != content.function_call.name:
if config["tool"] != content.function_call.name: # type: ignore[union-attr]
continue
tool_arg_name = config["tool_argument"]
logger.info(
" MATCHED tool '%s' for state key '%s', arg='%s'",
content.function_call.name,
content.function_call.name, # type: ignore[union-attr]
state_key,
tool_arg_name,
)
@@ -500,11 +499,11 @@ class AgentFrameworkEventBridge:
)
events.append(state_snapshot)
if content.function_call.call_id:
if content.function_call.call_id: # type: ignore[union-attr]
end_event = ToolCallEndEvent(
tool_call_id=content.function_call.call_id,
tool_call_id=content.function_call.call_id, # type: ignore[union-attr]
)
logger.info(f"Emitting ToolCallEndEvent for approval-required tool '{content.function_call.call_id}'")
logger.info(f"Emitting ToolCallEndEvent for approval-required tool '{content.function_call.call_id}'") # type: ignore[union-attr]
events.append(end_event)
# Emit the function_approval_request custom event for UI implementations that support it
@@ -513,18 +512,18 @@ class AgentFrameworkEventBridge:
value={
"id": content.id,
"function_call": {
"call_id": content.function_call.call_id,
"name": content.function_call.name,
"arguments": content.function_call.parse_arguments(),
"call_id": content.function_call.call_id, # type: ignore[union-attr]
"name": content.function_call.name, # type: ignore[union-attr]
"arguments": content.function_call.parse_arguments(), # type: ignore[union-attr]
},
},
)
logger.info(f"Emitting function_approval_request custom event for '{content.function_call.name}'")
logger.info(f"Emitting function_approval_request custom event for '{content.function_call.name}'") # type: ignore[union-attr]
events.append(approval_event)
# Emit a UI-friendly approval tool call for function approvals.
if self.require_confirmation:
events.extend(self._emit_function_approval_tool_call(content.function_call))
events.extend(self._emit_function_approval_tool_call(content.function_call)) # type: ignore[arg-type]
# Signal orchestrator to stop the run and wait for user approval response
self.should_stop_after_confirm = True
@@ -8,11 +8,8 @@ from typing import Any, cast
from agent_framework import (
ChatMessage,
FunctionApprovalResponseContent,
FunctionCallContent,
FunctionResultContent,
Content,
Role,
TextContent,
prepare_function_call_results,
)
@@ -40,11 +37,11 @@ def _sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]:
tool_ids = {
str(content.call_id)
for content in msg.contents or []
if isinstance(content, FunctionCallContent) and content.call_id
if content.type == "function_call" and content.call_id
}
confirm_changes_call = None
for content in msg.contents or []:
if isinstance(content, FunctionCallContent) and content.name == "confirm_changes":
if content.type == "function_call" and content.name == "confirm_changes":
confirm_changes_call = content
break
@@ -59,7 +56,7 @@ def _sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]:
approval_call_ids: set[str] = set()
approval_accepted: bool | None = None
for content in msg.contents or []:
if type(content) is FunctionApprovalResponseContent:
if content.type == "function_approval_response":
if content.function_call and content.function_call.call_id:
approval_call_ids.add(str(content.function_call.call_id))
if approval_accepted is None:
@@ -79,7 +76,7 @@ def _sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]:
synthetic_result = ChatMessage(
role="tool",
contents=[
FunctionResultContent(
Content.from_function_result(
call_id=pending_confirm_changes_id,
result="Confirmed" if approval_accepted else "Rejected",
)
@@ -93,12 +90,12 @@ def _sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]:
if pending_confirm_changes_id:
user_text = ""
for content in msg.contents or []:
if isinstance(content, TextContent):
user_text = content.text
if content.type == "text":
user_text = content.text # type: ignore[assignment]
break
try:
parsed = json.loads(user_text)
parsed = json.loads(user_text) # type: ignore[arg-type]
if "accepted" in parsed:
logger.info(
f"Injecting synthetic tool result for confirm_changes call_id={pending_confirm_changes_id}"
@@ -106,7 +103,7 @@ def _sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]:
synthetic_result = ChatMessage(
role="tool",
contents=[
FunctionResultContent(
Content.from_function_result(
call_id=pending_confirm_changes_id,
result="Confirmed" if parsed.get("accepted") else "Rejected",
)
@@ -130,7 +127,7 @@ def _sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]:
synthetic_result = ChatMessage(
role="tool",
contents=[
FunctionResultContent(
Content.from_function_result(
call_id=pending_call_id,
result="Tool execution skipped - user provided follow-up message",
)
@@ -149,7 +146,7 @@ def _sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]:
continue
keep = False
for content in msg.contents or []:
if isinstance(content, FunctionResultContent):
if content.type == "function_result" and content.call_id:
call_id = str(content.call_id)
if call_id in pending_tool_call_ids:
keep = True
@@ -175,7 +172,7 @@ def _deduplicate_messages(messages: list[ChatMessage]) -> list[ChatMessage]:
for idx, msg in enumerate(messages):
role_value = get_role_value(msg)
if role_value == "tool" and msg.contents and isinstance(msg.contents[0], FunctionResultContent):
if role_value == "tool" and msg.contents and msg.contents[0].type == "function_result":
call_id = str(msg.contents[0].call_id)
key: Any = (role_value, call_id)
@@ -184,7 +181,7 @@ def _deduplicate_messages(messages: list[ChatMessage]) -> list[ChatMessage]:
existing_msg = unique_messages[existing_idx]
existing_result = None
if existing_msg.contents and isinstance(existing_msg.contents[0], FunctionResultContent):
if existing_msg.contents and existing_msg.contents[0].type == "function_result":
existing_result = existing_msg.contents[0].result
new_result = msg.contents[0].result
@@ -198,11 +195,9 @@ def _deduplicate_messages(messages: list[ChatMessage]) -> list[ChatMessage]:
seen_keys[key] = len(unique_messages)
unique_messages.append(msg)
elif (
role_value == "assistant" and msg.contents and any(isinstance(c, FunctionCallContent) for c in msg.contents)
):
elif role_value == "assistant" and msg.contents and any(c.type == "function_call" for c in msg.contents):
tool_call_ids = tuple(
sorted(str(c.call_id) for c in msg.contents if isinstance(c, FunctionCallContent) and c.call_id)
sorted(str(c.call_id) for c in msg.contents if c.type == "function_call" and c.call_id)
)
key = (role_value, tool_call_ids)
@@ -275,15 +270,14 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
function_payload_dict["arguments"] = modified_args
return
def _find_matching_func_call(call_id: str) -> FunctionCallContent | None:
def _find_matching_func_call(call_id: str) -> Content | None:
for prev_msg in result:
role_val = prev_msg.role.value if hasattr(prev_msg.role, "value") else str(prev_msg.role)
if role_val != "assistant":
continue
for content in prev_msg.contents or []:
if isinstance(content, FunctionCallContent):
if content.call_id == call_id and content.name != "confirm_changes":
return content
if content.type == "function_call" and content.call_id == call_id and content.name != "confirm_changes":
return content
return None
def _parse_arguments(arguments: Any) -> dict[str, Any] | None:
@@ -301,9 +295,9 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
continue
direct_call = None
confirm_call = None
sibling_calls: list[FunctionCallContent] = []
sibling_calls: list[Content] = []
for content in prev_msg.contents or []:
if not isinstance(content, FunctionCallContent):
if content.type != "function_call":
continue
if content.call_id == tool_call_id:
direct_call = content
@@ -407,7 +401,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
if not (
(m.role.value if hasattr(m.role, "value") else str(m.role)) == "tool"
and any(
isinstance(c, FunctionResultContent) and c.call_id == approval_call_id
c.type == "function_result" and c.call_id == approval_call_id
for c in (m.contents or [])
)
)
@@ -465,9 +459,9 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
matching_func_call.arguments = updated_args
_update_tool_call_arguments(messages, str(approval_call_id), merged_args)
# Create a new FunctionCallContent with the modified arguments
func_call_for_approval = FunctionCallContent(
call_id=matching_func_call.call_id,
name=matching_func_call.name,
func_call_for_approval = Content.from_function_call(
call_id=matching_func_call.call_id, # type: ignore[arg-type]
name=matching_func_call.name, # type: ignore[arg-type]
arguments=json.dumps(filtered_args),
)
logger.info(f"Using modified arguments from approval: {filtered_args}")
@@ -476,7 +470,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
func_call_for_approval = matching_func_call
# Create FunctionApprovalResponseContent for the agent framework
approval_response = FunctionApprovalResponseContent(
approval_response = Content.from_function_approval_response(
approved=accepted,
id=str(approval_call_id),
function_call=func_call_for_approval,
@@ -491,7 +485,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
# Keep the old behavior for backwards compatibility
chat_msg = ChatMessage(
role=Role.USER,
contents=[TextContent(text=approval_payload_text)],
contents=[Content.from_text(text=approval_payload_text)],
additional_properties={"is_tool_result": True, "tool_call_id": str(tool_call_id or "")},
)
if "id" in msg:
@@ -511,7 +505,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
func_result = str(result_content)
chat_msg = ChatMessage(
role=Role.TOOL,
contents=[FunctionResultContent(call_id=str(tool_call_id), result=func_result)],
contents=[Content.from_function_result(call_id=str(tool_call_id), result=func_result)],
)
if "id" in msg:
chat_msg.message_id = msg["id"]
@@ -527,21 +521,21 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
chat_msg = ChatMessage(
role=Role.TOOL,
contents=[FunctionResultContent(call_id=str(tool_call_id), result=result_content)],
contents=[Content.from_function_result(call_id=str(tool_call_id), result=result_content)],
)
if "id" in msg:
chat_msg.message_id = msg["id"]
result.append(chat_msg)
continue
# If assistant message includes tool calls, convert to FunctionCallContent(s)
# If assistant message includes tool calls, convert to Content.from_function_call(s)
tool_calls = msg.get("tool_calls") or msg.get("toolCalls")
if tool_calls:
contents: list[Any] = []
# Include any assistant text content if present
content_text = msg.get("content")
if isinstance(content_text, str) and content_text:
contents.append(TextContent(text=content_text))
contents.append(Content.from_text(text=content_text))
# Convert each tool call entry
for tc in tool_calls:
if not isinstance(tc, dict):
@@ -558,7 +552,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
arguments = func_dict.get("arguments")
contents.append(
FunctionCallContent(
Content.from_function_call(
call_id=call_id,
name=name,
arguments=arguments,
@@ -580,14 +574,14 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
approval_contents: list[Any] = []
for approval in msg["function_approvals"]:
# Create FunctionCallContent with the modified arguments
func_call = FunctionCallContent(
func_call = Content.from_function_call(
call_id=approval.get("call_id", ""),
name=approval.get("name", ""),
arguments=approval.get("arguments", {}),
)
# Create the approval response
approval_response = FunctionApprovalResponseContent(
approval_response = Content.from_function_approval_response(
approved=approval.get("approved", True),
id=approval.get("id", ""),
function_call=func_call,
@@ -599,9 +593,9 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
# Regular text message
content = msg.get("content", "")
if isinstance(content, str):
chat_msg = ChatMessage(role=role, contents=[TextContent(text=content)])
chat_msg = ChatMessage(role=role, contents=[Content.from_text(text=content)])
else:
chat_msg = ChatMessage(role=role, contents=[TextContent(text=str(content))])
chat_msg = ChatMessage(role=role, contents=[Content.from_text(text=str(content))])
if "id" in msg:
chat_msg.message_id = msg["id"]
@@ -652,9 +646,9 @@ def agent_framework_messages_to_agui(messages: list[ChatMessage] | list[dict[str
tool_result_call_id: str | None = None
for content in msg.contents:
if isinstance(content, TextContent):
content_text += content.text
elif isinstance(content, FunctionCallContent):
if content.type == "text":
content_text += content.text # type: ignore[operator]
elif content.type == "function_call":
tool_calls.append(
{
"id": content.call_id,
@@ -665,7 +659,7 @@ def agent_framework_messages_to_agui(messages: list[ChatMessage] | list[dict[str
},
}
)
elif isinstance(content, FunctionResultContent):
elif content.type == "function_result":
# Tool result content - extract call_id and result
tool_result_call_id = content.call_id
# Serialize result to string using core utility
@@ -702,8 +696,13 @@ def extract_text_from_contents(contents: list[Any]) -> str:
"""
text_parts: list[str] = []
for content in contents:
if isinstance(content, TextContent):
text_parts.append(content.text)
if type_ := getattr(content, "type", None):
if type_ == "text_reasoning":
continue
if text := getattr(content, "text", None):
text_parts.append(text)
continue
# TODO (moonbox3): should this handle both text and text_reasoning?
elif hasattr(content, "text"):
text_parts.append(content.text)
return "".join(text_parts)
@@ -9,10 +9,7 @@ from typing import TYPE_CHECKING, Any
from ag_ui.core import StateSnapshotEvent
from agent_framework import (
ChatMessage,
FunctionApprovalResponseContent,
FunctionCallContent,
FunctionResultContent,
TextContent,
Content,
)
from .._utils import get_role_value, safe_json_parse
@@ -37,9 +34,9 @@ def pending_tool_call_ids(messages: list[ChatMessage]) -> set[str]:
resolved_ids: set[str] = set()
for msg in messages:
for content in msg.contents:
if isinstance(content, FunctionCallContent) and content.call_id:
if content.type == "function_call" and content.call_id:
pending_ids.add(str(content.call_id))
elif isinstance(content, FunctionResultContent) and content.call_id:
elif content.type == "function_result" and content.call_id:
resolved_ids.add(str(content.call_id))
return pending_ids - resolved_ids
@@ -56,7 +53,7 @@ def is_state_context_message(message: ChatMessage) -> bool:
if get_role_value(message) != "system":
return False
for content in message.contents:
if isinstance(content, TextContent) and content.text.startswith("Current state of the application:"):
if content.type == "text" and content.text.startswith("Current state of the application:"): # type: ignore[union-attr]
return True
return False
@@ -139,7 +136,7 @@ def tool_calls_match_state(
if get_role_value(msg) != "assistant":
continue
for content in msg.contents:
if isinstance(content, FunctionCallContent) and content.name == tool_name:
if content.type == "function_call" and content.name == tool_name:
tool_args = safe_json_parse(content.arguments)
break
if tool_args is not None:
@@ -287,7 +284,7 @@ def collect_approved_state_snapshots(
if get_role_value(msg) != "user":
continue
for content in msg.contents:
if type(content) is FunctionApprovalResponseContent:
if content.type == "function_approval_response":
if not content.function_call or not content.approved:
continue
parsed_args = content.function_call.parse_arguments()
@@ -319,7 +316,7 @@ def collect_approved_state_snapshots(
return events
def latest_approval_response(messages: list[ChatMessage]) -> FunctionApprovalResponseContent | None:
def latest_approval_response(messages: list[ChatMessage]) -> Content | None:
"""Get the latest approval response from messages.
Args:
@@ -332,12 +329,12 @@ def latest_approval_response(messages: list[ChatMessage]) -> FunctionApprovalRes
return None
last_message = messages[-1]
for content in last_message.contents:
if type(content) is FunctionApprovalResponseContent:
if content.type == "function_approval_response":
return content
return None
def approval_steps(approval: FunctionApprovalResponseContent) -> list[Any]:
def approval_steps(approval: Content) -> list[Any]:
"""Extract steps from an approval response.
Args:
@@ -346,9 +343,7 @@ def approval_steps(approval: FunctionApprovalResponseContent) -> list[Any]:
Returns:
List of steps, or empty list if none
"""
state_args: Any | None = None
if approval.additional_properties:
state_args = approval.additional_properties.get("ag_ui_state_args")
state_args = approval.additional_properties.get("ag_ui_state_args", None)
if isinstance(state_args, dict):
steps = state_args.get("steps")
if isinstance(steps, list):
@@ -365,7 +360,7 @@ def approval_steps(approval: FunctionApprovalResponseContent) -> list[Any]:
def is_step_based_approval(
approval: FunctionApprovalResponseContent,
approval: Content,
predict_state_config: dict[str, dict[str, str]] | None,
) -> bool:
"""Check if an approval is step-based.
@@ -6,7 +6,7 @@ import json
from typing import Any
from ag_ui.core import CustomEvent, EventType
from agent_framework import ChatMessage, TextContent
from agent_framework import ChatMessage, Content
class StateManager:
@@ -71,7 +71,7 @@ class StateManager:
return ChatMessage(
role="system",
contents=[
TextContent(
Content.from_text(
text=(
"Current state of the application:\n"
f"{state_json}\n\n"
@@ -25,13 +25,11 @@ from agent_framework import (
AgentProtocol,
AgentThread,
ChatAgent,
FunctionCallContent,
FunctionResultContent,
TextContent,
Content,
FunctionInvocationConfiguration,
)
from agent_framework._middleware import extract_and_merge_function_middleware
from agent_framework._tools import (
FunctionInvocationConfiguration,
_collect_approval_responses, # type: ignore
_replace_approval_contents_with_results, # type: ignore
_try_execute_function_calls, # type: ignore
@@ -285,12 +283,12 @@ class HumanInTheLoopOrchestrator(Orchestrator):
last_message = context.last_message
if last_message:
for content in last_message.contents:
if isinstance(content, TextContent):
if content.type == "text":
tool_content_text = content.text
break
try:
tool_result = json.loads(tool_content_text)
tool_result = json.loads(tool_content_text) # type: ignore[arg-type]
accepted = tool_result.get("accepted", False)
steps = tool_result.get("steps", [])
@@ -328,7 +326,7 @@ class HumanInTheLoopOrchestrator(Orchestrator):
except json.JSONDecodeError:
logger.error(f"Failed to parse tool result: {tool_content_text}")
yield RunErrorEvent(message=f"Invalid tool result format: {tool_content_text[:100]}")
yield RunErrorEvent(message=f"Invalid tool result format: {tool_content_text[:100]}") # type: ignore[index]
yield event_bridge.create_run_finished_event()
@@ -441,25 +439,24 @@ class DefaultOrchestrator(Orchestrator):
logger.info(f" Message {i}: role={role}, id={msg_id}")
if hasattr(msg, "contents") and msg.contents:
for j, content in enumerate(msg.contents):
content_type = type(content).__name__
if isinstance(content, TextContent):
logger.debug(" Content %s: %s - text_length=%s", j, content_type, len(content.text))
elif isinstance(content, FunctionCallContent):
if content.type == "text":
logger.debug(" Content %s: %s - text_length=%s", j, content.type, len(content.text)) # type: ignore[arg-type]
elif content.type == "function_call":
arg_length = len(str(content.arguments)) if content.arguments else 0
logger.debug(
" Content %s: %s - %s args_length=%s", j, content_type, content.name, arg_length
" Content %s: %s - %s args_length=%s", j, content.type, content.name, arg_length
)
elif isinstance(content, FunctionResultContent):
elif content.type == "function_result":
result_preview = type(content.result).__name__ if content.result is not None else "None"
logger.debug(
" Content %s: %s - call_id=%s, result_type=%s",
j,
content_type,
content.type,
content.call_id,
result_preview,
)
else:
logger.debug(f" Content {j}: {content_type}")
logger.debug(f" Content {j}: {content.type}")
pending_tool_calls: list[dict[str, Any]] = []
tool_calls_by_id: dict[str, dict[str, Any]] = {}
@@ -536,16 +533,14 @@ class DefaultOrchestrator(Orchestrator):
logger.error("Failed to execute approved tool calls; injecting error results.")
approved_function_results = []
normalized_results: list[FunctionResultContent] = []
normalized_results: list[Content] = []
for idx, approval in enumerate(approved_responses):
if idx < len(approved_function_results) and isinstance(
approved_function_results[idx], FunctionResultContent
):
if idx < len(approved_function_results) and approved_function_results[idx].type == "function_result":
normalized_results.append(approved_function_results[idx])
continue
call_id = approval.function_call.call_id or approval.id
call_id = approval.function_call.call_id or approval.id # type: ignore[union-attr]
normalized_results.append(
FunctionResultContent(call_id=call_id, result="Error: Tool call invocation failed.")
Content.from_function_result(call_id=call_id, result="Error: Tool call invocation failed.") # type: ignore[arg-type]
)
_replace_approval_contents_with_results(messages, fcc_todo, normalized_results) # type: ignore
@@ -661,8 +656,8 @@ class DefaultOrchestrator(Orchestrator):
if all_updates is not None:
all_updates.append(update)
if event_bridge.current_message_id is None and update.contents:
has_tool_call = any(isinstance(content, FunctionCallContent) for content in update.contents)
has_text = any(isinstance(content, TextContent) for content in update.contents)
has_tool_call = any(content.type == "function_call" for content in update.contents)
has_text = any(content.type == "text" for content in update.contents)
if has_tool_call and not has_text:
tool_message_id = generate_event_id()
event_bridge.current_message_id = tool_message_id
@@ -6,6 +6,7 @@ import sys
from typing import Any, TypedDict
from agent_framework import ChatOptions
from pydantic import BaseModel, Field
if sys.version_info >= (3, 13):
from typing import TypeVar
@@ -19,8 +20,6 @@ __all__ = [
"RunMetadata",
]
from pydantic import BaseModel, Field
class PredictStateConfig(TypedDict):
"""Configuration for predictive state updates."""
@@ -18,7 +18,7 @@ from ag_ui.core import (
TextMessageStartEvent,
ToolCallStartEvent,
)
from agent_framework import ChatAgent, ChatClientProtocol, ai_function
from agent_framework import ChatAgent, ChatClientProtocol, ChatMessage, Content, ai_function
from agent_framework.ag_ui import AgentFrameworkAgent
from pydantic import BaseModel, Field
@@ -221,7 +221,6 @@ class TaskStepsAgentWithExecution:
chat_client = chat_agent.chat_client # type: ignore
# Build messages for summary call
from agent_framework._types import ChatMessage, TextContent
original_messages = input_data.get("messages", [])
@@ -234,7 +233,7 @@ class TaskStepsAgentWithExecution:
messages.append(
ChatMessage(
role=msg.get("role", "user"),
contents=[TextContent(text=content_str)],
contents=[Content.from_text(text=content_str)],
)
)
elif isinstance(msg, ChatMessage):
@@ -245,7 +244,7 @@ class TaskStepsAgentWithExecution:
ChatMessage(
role="user",
contents=[
TextContent(
Content.from_text(
text="The steps have been successfully executed. Provide a brief one-sentence summary."
)
],
@@ -50,11 +50,9 @@ async def main():
print("\nAssistant: ", end="", flush=True)
# Display text content as it streams
from agent_framework import TextContent
for content in update.contents:
if isinstance(content, TextContent) and content.text:
print(f"\033[96m{content.text}\033[0m", end="", flush=True)
if hasattr(content, "text") and content.text: # type: ignore[attr-defined]
print(f"\033[96m{content.text}\033[0m", end="", flush=True) # type: ignore[attr-defined]
# Display finish reason if present
if update.finish_reason:
@@ -73,11 +73,9 @@ async def streaming_example(client: AGUIChatClient, thread_id: str | None = None
if not thread_id and update.additional_properties:
thread_id = update.additional_properties.get("thread_id")
from agent_framework import TextContent
for content in update.contents:
if isinstance(content, TextContent) and content.text:
print(content.text, end="", flush=True)
if content.type == "text" and content.text: # type: ignore[attr-defined]
print(content.text, end="", flush=True) # type: ignore[attr-defined]
print("\n")
return thread_id
@@ -138,13 +136,11 @@ async def tool_example(client: AGUIChatClient, thread_id: str | None = None):
print(f"Assistant: {response.text}")
# Show tool calls if any
from agent_framework import FunctionCallContent
tool_called = False
for message in response.messages:
for content in message.contents:
if isinstance(content, FunctionCallContent):
print(f"\n[Tool Called: {content.name}]")
if content.type == "function_call": # type: ignore[attr-defined]
print(f"\n[Tool Called: {content.name}]") # type: ignore[attr-defined]
tool_called = True
if not tool_called:
@@ -176,7 +172,7 @@ async def conversation_example(client: AGUIChatClient):
# Second turn - using same thread
print("\nUser: What's my name?\n")
response2 = await client.get_response("What's my name?", metadata={"thread_id": thread_id})
response2 = await client.get_response("What's my name?", options={"metadata": {"thread_id": thread_id}})
print(f"Assistant: {response2.text}")
# Check if context was maintained
@@ -186,7 +182,7 @@ async def conversation_example(client: AGUIChatClient):
# Third turn
print("\nUser: Can you also tell me what 10 * 5 is?\n")
response3 = await client.get_response(
"Can you also tell me what 10 * 5 is?", metadata={"thread_id": thread_id}, tools=[calculate]
"Can you also tell me what 10 * 5 is?", options={"metadata": {"thread_id": thread_id}}, tools=[calculate]
)
print(f"Assistant: {response3.text}")
@@ -22,7 +22,7 @@ import asyncio
import logging
import os
from agent_framework import ChatAgent, FunctionCallContent, FunctionResultContent, TextContent, ai_function
from agent_framework import ChatAgent, ai_function
from agent_framework.ag_ui import AGUIChatClient
# Enable debug logging
@@ -141,8 +141,9 @@ async def main():
# Build from contents when no direct text
parts: list[str] = []
for c in getattr(m, "contents", []) or []:
if isinstance(c, FunctionCallContent):
args = c.arguments
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
@@ -152,12 +153,15 @@ async def main():
args_str = str(args)
else:
args_str = str(args or "{}")
parts.append(f"tool_call {c.name} {args_str}")
elif isinstance(c, FunctionResultContent):
parts.append(f"tool_result[{c.call_id}]: {str(c.result)[:40]}")
elif isinstance(c, TextContent):
if c.text:
parts.append(c.text)
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}>")
@@ -11,14 +11,13 @@ from agent_framework import (
ChatOptions,
ChatResponse,
ChatResponseUpdate,
FunctionCallContent,
Content,
Role,
TextContent,
ai_function,
)
from pytest import MonkeyPatch
from agent_framework_ag_ui._client import AGUIChatClient, ServerFunctionCallContent
from agent_framework_ag_ui._client import AGUIChatClient
from agent_framework_ag_ui._http_service import AGUIHttpService
@@ -96,13 +95,11 @@ class TestAGUIChatClient:
state_json = json.dumps(state_data)
state_b64 = base64.b64encode(state_json.encode("utf-8")).decode("utf-8")
from agent_framework import DataContent
messages = [
ChatMessage(role="user", text="Hello"),
ChatMessage(
role="user",
contents=[DataContent(uri=f"data:application/json;base64,{state_b64}")],
contents=[Content.from_uri(uri=f"data:application/json;base64,{state_b64}")],
),
]
@@ -121,12 +118,10 @@ class TestAGUIChatClient:
invalid_json = "not valid json"
state_b64 = base64.b64encode(invalid_json.encode("utf-8")).decode("utf-8")
from agent_framework import DataContent
messages = [
ChatMessage(
role="user",
contents=[DataContent(uri=f"data:application/json;base64,{state_b64}")],
contents=[Content.from_uri(uri=f"data:application/json;base64,{state_b64}")],
),
]
@@ -200,8 +195,8 @@ class TestAGUIChatClient:
first_content = updates[1].contents[0]
second_content = updates[2].contents[0]
assert isinstance(first_content, TextContent)
assert isinstance(second_content, TextContent)
assert first_content.type == "text"
assert second_content.type == "text"
assert first_content.text == "Hello"
assert second_content.text == " world"
@@ -294,13 +289,12 @@ class TestAGUIChatClient:
updates.append(update)
function_calls = [
content for update in updates for content in update.contents if isinstance(content, FunctionCallContent)
content for update in updates for content in update.contents if content.type == "function_call"
]
assert function_calls
assert function_calls[0].name == "get_time_zone"
assert not any(
isinstance(content, ServerFunctionCallContent) for update in updates for content in update.contents
)
assert not any(content.type == "server_function_call" for update in updates for content in update.contents)
async def test_server_tool_calls_not_executed_locally(self, monkeypatch: MonkeyPatch) -> None:
"""Server tools should not trigger local function invocation even when client tools exist."""
@@ -343,13 +337,11 @@ class TestAGUIChatClient:
state_json = json.dumps(state_data)
state_b64 = base64.b64encode(state_json.encode("utf-8")).decode("utf-8")
from agent_framework import DataContent
messages = [
ChatMessage(role="user", text="Hello"),
ChatMessage(
role="user",
contents=[DataContent(uri=f"data:application/json;base64,{state_b64}")],
contents=[Content.from_uri(uri=f"data:application/json;base64,{state_b64}")],
),
]
@@ -9,7 +9,7 @@ from pathlib import Path
from typing import Any
import pytest
from agent_framework import ChatAgent, ChatMessage, ChatOptions, ChatResponseUpdate, TextContent
from agent_framework import ChatAgent, ChatMessage, ChatOptions, ChatResponseUpdate, Content
from pydantic import BaseModel
sys.path.insert(0, str(Path(__file__).parent))
@@ -23,7 +23,7 @@ async def test_agent_initialization_basic():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
agent = ChatAgent[ChatOptions](
chat_client=StreamingChatClientStub(stream_fn),
@@ -45,7 +45,7 @@ async def test_agent_initialization_with_state_schema():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
state_schema: dict[str, dict[str, Any]] = {"document": {"type": "string"}}
@@ -61,7 +61,7 @@ async def test_agent_initialization_with_predict_state_config():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
predict_config = {"document": {"tool": "write_doc", "tool_argument": "content"}}
@@ -77,7 +77,7 @@ async def test_agent_initialization_with_pydantic_state_schema():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
class MyState(BaseModel):
document: str
@@ -100,7 +100,7 @@ async def test_run_started_event_emission():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
wrapper = AgentFrameworkAgent(agent=agent)
@@ -124,7 +124,7 @@ async def test_predict_state_custom_event_emission():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
predict_config = {
@@ -156,7 +156,7 @@ async def test_initial_state_snapshot_with_schema():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
state_schema = {"document": {"type": "string"}}
@@ -186,7 +186,7 @@ async def test_state_initialization_object_type():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
state_schema: dict[str, dict[str, Any]] = {"recipe": {"type": "object", "properties": {}}}
@@ -213,7 +213,7 @@ async def test_state_initialization_array_type():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
state_schema: dict[str, dict[str, Any]] = {"steps": {"type": "array", "items": {}}}
@@ -240,7 +240,7 @@ async def test_run_finished_event_emission():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
wrapper = AgentFrameworkAgent(agent=agent)
@@ -262,7 +262,7 @@ async def test_tool_result_confirm_changes_accepted():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Document updated")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Document updated")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
wrapper = AgentFrameworkAgent(
@@ -309,7 +309,7 @@ async def test_tool_result_confirm_changes_rejected():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="OK")])
yield ChatResponseUpdate(contents=[Content.from_text(text="OK")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
wrapper = AgentFrameworkAgent(agent=agent)
@@ -343,7 +343,7 @@ async def test_tool_result_function_approval_accepted():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="OK")])
yield ChatResponseUpdate(contents=[Content.from_text(text="OK")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
wrapper = AgentFrameworkAgent(agent=agent)
@@ -389,7 +389,7 @@ async def test_tool_result_function_approval_rejected():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="OK")])
yield ChatResponseUpdate(contents=[Content.from_text(text="OK")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
wrapper = AgentFrameworkAgent(agent=agent)
@@ -431,7 +431,7 @@ async def test_thread_metadata_tracking():
metadata = options.get("metadata")
if metadata:
thread_metadata.update(metadata)
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
wrapper = AgentFrameworkAgent(agent=agent)
@@ -462,7 +462,7 @@ async def test_state_context_injection():
metadata = options.get("metadata")
if metadata:
thread_metadata.update(metadata)
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
wrapper = AgentFrameworkAgent(
@@ -492,7 +492,7 @@ async def test_no_messages_provided():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
wrapper = AgentFrameworkAgent(agent=agent)
@@ -516,7 +516,7 @@ async def test_message_end_event_emission():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello world")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello world")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
wrapper = AgentFrameworkAgent(agent=agent)
@@ -602,7 +602,7 @@ async def test_suppressed_summary_with_document_state():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Response")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Response")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
wrapper = AgentFrameworkAgent(
@@ -650,7 +650,7 @@ async def test_agent_with_use_service_thread_is_false():
thread = kwargs.get("thread")
request_service_thread_id = thread.service_thread_id if thread else None
yield ChatResponseUpdate(
contents=[TextContent(text="Response")], response_id="resp_67890", conversation_id="conv_12345"
contents=[Content.from_text(text="Response")], response_id="resp_67890", conversation_id="conv_12345"
)
agent = ChatAgent(chat_client=StreamingChatClientStub(stream_fn))
@@ -677,7 +677,7 @@ async def test_agent_with_use_service_thread_is_true():
thread = kwargs.get("thread")
request_service_thread_id = thread.service_thread_id if thread else None
yield ChatResponseUpdate(
contents=[TextContent(text="Response")], response_id="resp_67890", conversation_id="conv_12345"
contents=[Content.from_text(text="Response")], response_id="resp_67890", conversation_id="conv_12345"
)
agent = ChatAgent(chat_client=StreamingChatClientStub(stream_fn))
@@ -693,7 +693,7 @@ async def test_agent_with_use_service_thread_is_true():
async def test_function_approval_mode_executes_tool():
"""Test that function approval with approval_mode='always_require' sends the correct messages."""
from agent_framework import FunctionResultContent, ai_function
from agent_framework import ai_function
from agent_framework.ag_ui import AgentFrameworkAgent
messages_received: list[Any] = []
@@ -712,7 +712,7 @@ async def test_function_approval_mode_executes_tool():
# Capture the messages received by the chat client
messages_received.clear()
messages_received.extend(messages)
yield ChatResponseUpdate(contents=[TextContent(text="Processing completed")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Processing completed")])
agent = ChatAgent(
chat_client=StreamingChatClientStub(stream_fn),
@@ -770,7 +770,7 @@ async def test_function_approval_mode_executes_tool():
tool_result_found = False
for msg in messages_received:
for content in msg.contents:
if isinstance(content, FunctionResultContent):
if content.type == "function_result":
tool_result_found = True
assert content.call_id == "call_get_datetime_123"
assert content.result == "2025/12/01 12:00:00"
@@ -784,7 +784,7 @@ async def test_function_approval_mode_executes_tool():
async def test_function_approval_mode_rejection():
"""Test that function approval rejection creates a rejection response."""
from agent_framework import FunctionResultContent, ai_function
from agent_framework import ai_function
from agent_framework.ag_ui import AgentFrameworkAgent
messages_received: list[Any] = []
@@ -803,7 +803,7 @@ async def test_function_approval_mode_rejection():
# Capture the messages received by the chat client
messages_received.clear()
messages_received.extend(messages)
yield ChatResponseUpdate(contents=[TextContent(text="Operation cancelled")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Operation cancelled")])
agent = ChatAgent(
name="test_agent",
@@ -855,7 +855,7 @@ async def test_function_approval_mode_rejection():
rejection_found = False
for msg in messages_received:
for content in msg.contents:
if isinstance(content, FunctionResultContent):
if content.type == "function_result":
rejection_found = True
assert content.call_id == "call_delete_123"
assert content.result == "Error: Tool call invocation was rejected by user."
@@ -12,7 +12,7 @@ from ag_ui.core import (
ToolCallResultEvent,
ToolCallStartEvent,
)
from agent_framework import AgentResponseUpdate, FunctionCallContent, FunctionResultContent, TextContent
from agent_framework import AgentResponseUpdate, Content
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
@@ -22,7 +22,7 @@ async def test_tool_call_flow():
bridge = AgentFrameworkEventBridge(run_id="test-run", thread_id="test-thread")
# Step 1: Tool call starts
tool_call = FunctionCallContent(
tool_call = Content.from_function_call(
call_id="weather-123",
name="get_weather",
arguments={"location": "Seattle"},
@@ -44,7 +44,7 @@ async def test_tool_call_flow():
assert "Seattle" in args_event.delta
# Step 2: Tool result comes back
tool_result = FunctionResultContent(
tool_result = Content.from_function_result(
call_id="weather-123",
result="Weather in Seattle: Rainy, 52°F",
)
@@ -71,8 +71,8 @@ async def test_text_with_tool_call():
bridge = AgentFrameworkEventBridge(run_id="test-run", thread_id="test-thread")
# Agent says something then calls a tool
text_content = TextContent(text="Let me check the weather for you.")
tool_call = FunctionCallContent(
text_content = Content.from_text(text="Let me check the weather for you.")
tool_call = Content.from_function_call(
call_id="weather-456",
name="get_forecast",
arguments={"location": "San Francisco", "days": 3},
@@ -102,9 +102,9 @@ async def test_multiple_tool_results():
# Multiple tool results
results = [
FunctionResultContent(call_id="tool-1", result="Result 1"),
FunctionResultContent(call_id="tool-2", result="Result 2"),
FunctionResultContent(call_id="tool-3", result="Result 3"),
Content.from_function_result(call_id="tool-1", result="Result 1"),
Content.from_function_result(call_id="tool-2", result="Result 2"),
Content.from_function_result(call_id="tool-3", result="Result 3"),
]
update = AgentResponseUpdate(contents=results)
@@ -3,7 +3,7 @@
"""Tests for document writer predictive state flow with confirm_changes."""
from ag_ui.core import EventType, StateDeltaEvent, ToolCallArgsEvent, ToolCallEndEvent, ToolCallStartEvent
from agent_framework import AgentResponseUpdate, FunctionCallContent, FunctionResultContent, TextContent
from agent_framework import AgentResponseUpdate, Content
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
@@ -21,7 +21,7 @@ async def test_streaming_document_with_state_deltas():
)
# Simulate streaming tool call - first chunk with name
tool_call_start = FunctionCallContent(
tool_call_start = Content.from_function_call(
call_id="call_123",
name="write_document_local",
arguments='{"document":"Once',
@@ -34,7 +34,9 @@ async def test_streaming_document_with_state_deltas():
assert any(e.type == EventType.TOOL_CALL_ARGS for e in events1)
# Second chunk - incomplete JSON, should try partial extraction
tool_call_chunk2 = FunctionCallContent(call_id="call_123", name="write_document_local", arguments=" upon a time")
tool_call_chunk2 = Content.from_function_call(
call_id="call_123", name="write_document_local", arguments=" upon a time"
)
update2 = AgentResponseUpdate(contents=[tool_call_chunk2])
events2 = await bridge.from_agent_run_update(update2)
@@ -71,7 +73,7 @@ async def test_confirm_changes_emission():
bridge.pending_state_updates = {"document": "A short story"}
# Tool result
tool_result = FunctionResultContent(
tool_result = Content.from_function_result(
call_id="call_123",
result="Document written.",
)
@@ -115,7 +117,7 @@ async def test_text_suppression_before_confirm():
bridge.should_stop_after_confirm = True
# Text content that should be suppressed
text = TextContent(text="I have written a story about pirates.")
text = Content.from_text(text="I have written a story about pirates.")
update = AgentResponseUpdate(contents=[text])
events = await bridge.from_agent_run_update(update)
@@ -146,7 +148,7 @@ async def test_no_confirm_for_non_predictive_tools():
# Different tool (not in predict_state_config)
bridge.current_tool_call_name = "get_weather"
tool_result = FunctionResultContent(
tool_result = Content.from_function_result(
call_id="call_456",
result="Sunny, 72°F",
)
@@ -175,7 +177,7 @@ async def test_state_delta_deduplication():
)
# First tool call with document
tool_call1 = FunctionCallContent(
tool_call1 = Content.from_function_call(
call_id="call_1",
name="write_document_local",
arguments='{"document":"Same text"}',
@@ -189,7 +191,7 @@ async def test_state_delta_deduplication():
# Second tool call with SAME document (shouldn't emit new delta)
bridge.current_tool_call_name = "write_document_local"
tool_call2 = FunctionCallContent(
tool_call2 = Content.from_function_call(
call_id="call_2",
name="write_document_local",
arguments='{"document":"Same text"}', # Identical content
@@ -216,7 +218,7 @@ async def test_predict_state_config_multiple_fields():
)
# Tool call with both fields
tool_call = FunctionCallContent(
tool_call = Content.from_function_call(
call_id="call_999",
name="create_post",
arguments='{"title":"My Post","body":"Post content"}',
+2 -2
View File
@@ -6,7 +6,7 @@ import json
import sys
from pathlib import Path
from agent_framework import ChatAgent, ChatResponseUpdate, TextContent
from agent_framework import ChatAgent, ChatResponseUpdate, Content
from fastapi import FastAPI, Header, HTTPException
from fastapi.params import Depends
from fastapi.testclient import TestClient
@@ -20,7 +20,7 @@ from utils_test_ag_ui import StreamingChatClientStub, stream_from_updates
def build_chat_client(response_text: str = "Test response") -> StreamingChatClientStub:
"""Create a typed chat client stub for endpoint tests."""
updates = [ChatResponseUpdate(contents=[TextContent(text=response_text)])]
updates = [ChatResponseUpdate(contents=[Content.from_text(text=response_text)])]
return StreamingChatClientStub(stream_from_updates(updates))
@@ -6,10 +6,7 @@ import json
from agent_framework import (
AgentResponseUpdate,
FunctionApprovalRequestContent,
FunctionCallContent,
FunctionResultContent,
TextContent,
Content,
)
@@ -19,7 +16,7 @@ async def test_basic_text_message_conversion():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update = AgentResponseUpdate(contents=[TextContent(text="Hello")])
update = AgentResponseUpdate(contents=[Content.from_text(text="Hello")])
events = await bridge.from_agent_run_update(update)
assert len(events) == 2
@@ -35,8 +32,8 @@ async def test_text_message_streaming():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update1 = AgentResponseUpdate(contents=[TextContent(text="Hello ")])
update2 = AgentResponseUpdate(contents=[TextContent(text="world")])
update1 = AgentResponseUpdate(contents=[Content.from_text(text="Hello ")])
update2 = AgentResponseUpdate(contents=[Content.from_text(text="world")])
events1 = await bridge.from_agent_run_update(update1)
events2 = await bridge.from_agent_run_update(update2)
@@ -61,7 +58,7 @@ async def test_skip_text_content_for_structured_outputs():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread", skip_text_content=True)
update = AgentResponseUpdate(contents=[TextContent(text='{"result": "data"}')])
update = AgentResponseUpdate(contents=[Content.from_text(text='{"result": "data"}')])
events = await bridge.from_agent_run_update(update)
# No events should be emitted
@@ -74,9 +71,9 @@ async def test_skip_text_content_for_empty_text():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update1 = AgentResponseUpdate(contents=[TextContent(text="Hello ")])
update2 = AgentResponseUpdate(contents=[TextContent(text="")]) # Empty chunk
update3 = AgentResponseUpdate(contents=[TextContent(text="world")])
update1 = AgentResponseUpdate(contents=[Content.from_text(text="Hello ")])
update2 = AgentResponseUpdate(contents=[Content.from_text(text="")]) # Empty chunk
update3 = AgentResponseUpdate(contents=[Content.from_text(text="world")])
events1 = await bridge.from_agent_run_update(update1)
events2 = await bridge.from_agent_run_update(update2)
@@ -105,7 +102,7 @@ async def test_tool_call_with_name():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update = AgentResponseUpdate(contents=[FunctionCallContent(name="search_web", call_id="call_123")])
update = AgentResponseUpdate(contents=[Content.from_function_call(name="search_web", call_id="call_123")])
events = await bridge.from_agent_run_update(update)
assert len(events) == 1
@@ -121,15 +118,17 @@ async def test_tool_call_streaming_args():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
# First chunk: name only
update1 = AgentResponseUpdate(contents=[FunctionCallContent(name="search_web", call_id="call_123")])
update1 = AgentResponseUpdate(contents=[Content.from_function_call(name="search_web", call_id="call_123")])
events1 = await bridge.from_agent_run_update(update1)
# Second chunk: arguments chunk 1 (name can be empty string for continuation)
update2 = AgentResponseUpdate(contents=[FunctionCallContent(name="", call_id="call_123", arguments='{"query": "')])
update2 = AgentResponseUpdate(
contents=[Content.from_function_call(name="", call_id="call_123", arguments='{"query": "')]
)
events2 = await bridge.from_agent_run_update(update2)
# Third chunk: arguments chunk 2
update3 = AgentResponseUpdate(contents=[FunctionCallContent(name="", call_id="call_123", arguments='AI"}')])
update3 = AgentResponseUpdate(contents=[Content.from_function_call(name="", call_id="call_123", arguments='AI"}')])
events3 = await bridge.from_agent_run_update(update3)
# First update: ToolCallStartEvent
@@ -167,9 +166,11 @@ async def test_streaming_tool_call_no_duplicate_start_events():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
# Simulate streaming tool call: first chunk has name, subsequent chunks have name=""
update1 = AgentResponseUpdate(contents=[FunctionCallContent(name="get_weather", call_id="call_789")])
update2 = AgentResponseUpdate(contents=[FunctionCallContent(name="", call_id="call_789", arguments='{"loc":')])
update3 = AgentResponseUpdate(contents=[FunctionCallContent(name="", call_id="call_789", arguments='"SF"}')])
update1 = AgentResponseUpdate(contents=[Content.from_function_call(name="get_weather", call_id="call_789")])
update2 = AgentResponseUpdate(
contents=[Content.from_function_call(name="", call_id="call_789", arguments='{"loc":')]
)
update3 = AgentResponseUpdate(contents=[Content.from_function_call(name="", call_id="call_789", arguments='"SF"}')])
events1 = await bridge.from_agent_run_update(update1)
events2 = await bridge.from_agent_run_update(update2)
@@ -193,7 +194,7 @@ async def test_tool_result_with_dict():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
result_data = {"status": "success", "count": 42}
update = AgentResponseUpdate(contents=[FunctionResultContent(call_id="call_123", result=result_data)])
update = AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_123", result=result_data)])
events = await bridge.from_agent_run_update(update)
# Should emit ToolCallEndEvent + ToolCallResultEvent
@@ -214,7 +215,7 @@ async def test_tool_result_with_string():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update = AgentResponseUpdate(contents=[FunctionResultContent(call_id="call_123", result="Search complete")])
update = AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_123", result="Search complete")])
events = await bridge.from_agent_run_update(update)
assert len(events) == 2
@@ -229,7 +230,7 @@ async def test_tool_result_with_none():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update = AgentResponseUpdate(contents=[FunctionResultContent(call_id="call_123", result=None)])
update = AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_123", result=None)])
events = await bridge.from_agent_run_update(update)
assert len(events) == 2
@@ -247,8 +248,8 @@ async def test_multiple_tool_results_in_sequence():
update = AgentResponseUpdate(
contents=[
FunctionResultContent(call_id="call_1", result="Result 1"),
FunctionResultContent(call_id="call_2", result="Result 2"),
Content.from_function_result(call_id="call_1", result="Result 1"),
Content.from_function_result(call_id="call_2", result="Result 2"),
]
)
events = await bridge.from_agent_run_update(update)
@@ -272,12 +273,12 @@ async def test_function_approval_request_basic():
require_confirmation=False,
)
func_call = FunctionCallContent(
func_call = Content.from_function_call(
call_id="call_123",
name="send_email",
arguments={"to": "user@example.com", "subject": "Test"},
)
approval = FunctionApprovalRequestContent(
approval = Content.from_function_approval_request(
id="approval_001",
function_call=func_call,
)
@@ -312,8 +313,8 @@ async def test_empty_predict_state_config():
# Tool call with arguments
update = AgentResponseUpdate(
contents=[
FunctionCallContent(name="write_doc", call_id="call_1", arguments='{"content": "test"}'),
FunctionResultContent(call_id="call_1", result="Done"),
Content.from_function_call(name="write_doc", call_id="call_1", arguments='{"content": "test"}'),
Content.from_function_result(call_id="call_1", result="Done"),
]
)
events = await bridge.from_agent_run_update(update)
@@ -347,8 +348,8 @@ async def test_tool_not_in_predict_state_config():
# Different tool name
update = AgentResponseUpdate(
contents=[
FunctionCallContent(name="search_web", call_id="call_1", arguments='{"query": "AI"}'),
FunctionResultContent(call_id="call_1", result="Results"),
Content.from_function_call(name="search_web", call_id="call_1", arguments='{"query": "AI"}'),
Content.from_function_result(call_id="call_1", result="Results"),
]
)
events = await bridge.from_agent_run_update(update)
@@ -376,8 +377,8 @@ async def test_state_management_tracking():
# Streaming tool call
update1 = AgentResponseUpdate(
contents=[
FunctionCallContent(name="write_doc", call_id="call_1"),
FunctionCallContent(name="", call_id="call_1", arguments='{"content": "Hello"}'),
Content.from_function_call(name="write_doc", call_id="call_1"),
Content.from_function_call(name="", call_id="call_1", arguments='{"content": "Hello"}'),
]
)
await bridge.from_agent_run_update(update1)
@@ -387,7 +388,7 @@ async def test_state_management_tracking():
assert bridge.pending_state_updates["document"] == "Hello"
# Tool result should update current_state
update2 = AgentResponseUpdate(contents=[FunctionResultContent(call_id="call_1", result="Done")])
update2 = AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_1", result="Done")])
await bridge.from_agent_run_update(update2)
# current_state should be updated
@@ -413,12 +414,12 @@ async def test_wildcard_tool_argument():
# Complete tool call with dict arguments
update = AgentResponseUpdate(
contents=[
FunctionCallContent(
Content.from_function_call(
name="create_recipe",
call_id="call_1",
arguments={"title": "Pasta", "ingredients": ["pasta", "sauce"]},
),
FunctionResultContent(call_id="call_1", result="Created"),
Content.from_function_result(call_id="call_1", result="Created"),
]
)
events = await bridge.from_agent_run_update(update)
@@ -503,14 +504,14 @@ async def test_state_snapshot_after_tool_result():
# Tool call with streaming args
update1 = AgentResponseUpdate(
contents=[
FunctionCallContent(name="write_doc", call_id="call_1"),
FunctionCallContent(name="", call_id="call_1", arguments='{"content": "Test"}'),
Content.from_function_call(name="write_doc", call_id="call_1"),
Content.from_function_call(name="", call_id="call_1", arguments='{"content": "Test"}'),
]
)
await bridge.from_agent_run_update(update1)
# Tool result should trigger StateSnapshotEvent
update2 = AgentResponseUpdate(contents=[FunctionResultContent(call_id="call_1", result="Done")])
update2 = AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_1", result="Done")])
events = await bridge.from_agent_run_update(update2)
# Should have: ToolCallEnd, ToolCallResult, StateSnapshot, ToolCallStart (confirm_changes), ToolCallArgs, ToolCallEnd
@@ -526,12 +527,12 @@ async def test_message_id_persistence_across_chunks():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
# First chunk
update1 = AgentResponseUpdate(contents=[TextContent(text="Hello ")])
update1 = AgentResponseUpdate(contents=[Content.from_text(text="Hello ")])
events1 = await bridge.from_agent_run_update(update1)
message_id = events1[0].message_id
# Second chunk
update2 = AgentResponseUpdate(contents=[TextContent(text="world")])
update2 = AgentResponseUpdate(contents=[Content.from_text(text="world")])
events2 = await bridge.from_agent_run_update(update2)
# Should use same message_id
@@ -546,14 +547,16 @@ async def test_tool_call_id_tracking():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
# First chunk with name
update1 = AgentResponseUpdate(contents=[FunctionCallContent(name="search", call_id="call_1")])
update1 = AgentResponseUpdate(contents=[Content.from_function_call(name="search", call_id="call_1")])
await bridge.from_agent_run_update(update1)
assert bridge.current_tool_call_id == "call_1"
assert bridge.current_tool_call_name == "search"
# Second chunk with args but no name
update2 = AgentResponseUpdate(contents=[FunctionCallContent(name="", call_id="call_1", arguments='{"q":"AI"}')])
update2 = AgentResponseUpdate(
contents=[Content.from_function_call(name="", call_id="call_1", arguments='{"q":"AI"}')]
)
events2 = await bridge.from_agent_run_update(update2)
# Should still track same tool call
@@ -576,8 +579,8 @@ async def test_tool_name_reset_after_result():
# Tool call
update1 = AgentResponseUpdate(
contents=[
FunctionCallContent(name="write_doc", call_id="call_1"),
FunctionCallContent(name="", call_id="call_1", arguments='{"content": "Test"}'),
Content.from_function_call(name="write_doc", call_id="call_1"),
Content.from_function_call(name="", call_id="call_1", arguments='{"content": "Test"}'),
]
)
await bridge.from_agent_run_update(update1)
@@ -585,7 +588,7 @@ async def test_tool_name_reset_after_result():
assert bridge.current_tool_call_name == "write_doc"
# Tool result with predictive state (should trigger confirm_changes and reset)
update2 = AgentResponseUpdate(contents=[FunctionResultContent(call_id="call_1", result="Done")])
update2 = AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_1", result="Done")])
await bridge.from_agent_run_update(update2)
# Tool name should be reset
@@ -604,9 +607,9 @@ async def test_function_approval_with_wildcard_argument():
},
)
approval_content = FunctionApprovalRequestContent(
approval_content = Content.from_function_approval_request(
id="approval_1",
function_call=FunctionCallContent(
function_call=Content.from_function_call(
name="submit", call_id="call_1", arguments='{"key1": "value1", "key2": "value2"}'
),
)
@@ -632,9 +635,11 @@ async def test_function_approval_missing_argument():
},
)
approval_content = FunctionApprovalRequestContent(
approval_content = Content.from_function_approval_request(
id="approval_1",
function_call=FunctionCallContent(name="process", call_id="call_1", arguments='{"other_field": "value"}'),
function_call=Content.from_function_call(
name="process", call_id="call_1", arguments='{"other_field": "value"}'
),
)
update = AgentResponseUpdate(contents=[approval_content])
@@ -654,8 +659,8 @@ async def test_empty_predict_state_config_no_deltas():
# Tool call with arguments
update = AgentResponseUpdate(
contents=[
FunctionCallContent(name="search", call_id="call_1"),
FunctionCallContent(name="", call_id="call_1", arguments='{"query": "test"}'),
Content.from_function_call(name="search", call_id="call_1"),
Content.from_function_call(name="", call_id="call_1", arguments='{"query": "test"}'),
]
)
events = await bridge.from_agent_run_update(update)
@@ -678,8 +683,8 @@ async def test_tool_with_no_matching_config():
# Tool call for different tool
update = AgentResponseUpdate(
contents=[
FunctionCallContent(name="search_web", call_id="call_1"),
FunctionCallContent(name="", call_id="call_1", arguments='{"query": "test"}'),
Content.from_function_call(name="search_web", call_id="call_1"),
Content.from_function_call(name="", call_id="call_1", arguments='{"query": "test"}'),
]
)
events = await bridge.from_agent_run_update(update)
@@ -696,7 +701,7 @@ async def test_tool_call_without_name_or_id():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
# This should not crash but log an error
update = AgentResponseUpdate(contents=[FunctionCallContent(name="", call_id="", arguments='{"arg": "val"}')])
update = AgentResponseUpdate(contents=[Content.from_function_call(name="", call_id="", arguments='{"arg": "val"}')])
events = await bridge.from_agent_run_update(update)
# Should emit ToolCallArgsEvent with generated ID
@@ -717,7 +722,7 @@ async def test_state_delta_count_logging():
for i in range(15):
update = AgentResponseUpdate(
contents=[
FunctionCallContent(name="", call_id="call_1", arguments=f'{{"text": "Content variation {i}"}}'),
Content.from_function_call(name="", call_id="call_1", arguments=f'{{"text": "Content variation {i}"}}'),
]
)
# Set the tool name to match config
@@ -737,7 +742,7 @@ async def test_tool_result_with_empty_list():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update = AgentResponseUpdate(contents=[FunctionResultContent(call_id="call_123", result=[])])
update = AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_123", result=[])])
events = await bridge.from_agent_run_update(update)
assert len(events) == 2
@@ -760,7 +765,7 @@ async def test_tool_result_with_single_text_content():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update = AgentResponseUpdate(
contents=[FunctionResultContent(call_id="call_123", result=[MockTextContent("Hello from MCP tool!")])]
contents=[Content.from_function_result(call_id="call_123", result=[MockTextContent("Hello from MCP tool!")])]
)
events = await bridge.from_agent_run_update(update)
@@ -785,7 +790,7 @@ async def test_tool_result_with_multiple_text_contents():
update = AgentResponseUpdate(
contents=[
FunctionResultContent(
Content.from_function_result(
call_id="call_123",
result=[MockTextContent("First result"), MockTextContent("Second result")],
)
@@ -812,7 +817,7 @@ async def test_tool_result_with_model_dump_objects():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update = AgentResponseUpdate(
contents=[FunctionResultContent(call_id="call_123", result=[MockModel(value=1), MockModel(value=2)])]
contents=[Content.from_function_result(call_id="call_123", result=[MockModel(value=1), MockModel(value=2)])]
)
events = await bridge.from_agent_run_update(update)
@@ -2,7 +2,7 @@
"""Tests for human in the loop (function approval requests)."""
from agent_framework import AgentResponseUpdate, FunctionApprovalRequestContent, FunctionCallContent
from agent_framework import AgentResponseUpdate, Content
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
@@ -17,12 +17,12 @@ async def test_function_approval_request_emission():
)
# Create approval request
func_call = FunctionCallContent(
func_call = Content.from_function_call(
call_id="call_123",
name="send_email",
arguments={"to": "user@example.com", "subject": "Test"},
)
approval_request = FunctionApprovalRequestContent(
approval_request = Content.from_function_approval_request(
id="approval_001",
function_call=func_call,
)
@@ -56,12 +56,12 @@ async def test_function_approval_request_with_confirm_changes():
require_confirmation=True,
)
func_call = FunctionCallContent(
func_call = Content.from_function_call(
call_id="call_456",
name="delete_file",
arguments={"path": "/tmp/test.txt"},
)
approval_request = FunctionApprovalRequestContent(
approval_request = Content.from_function_approval_request(
id="approval_002",
function_call=func_call,
)
@@ -109,22 +109,22 @@ async def test_multiple_approval_requests():
require_confirmation=False,
)
func_call_1 = FunctionCallContent(
func_call_1 = Content.from_function_call(
call_id="call_1",
name="create_event",
arguments={"title": "Meeting"},
)
approval_1 = FunctionApprovalRequestContent(
approval_1 = Content.from_function_approval_request(
id="approval_1",
function_call=func_call_1,
)
func_call_2 = FunctionCallContent(
func_call_2 = Content.from_function_call(
call_id="call_2",
name="book_room",
arguments={"room": "Conference A"},
)
approval_2 = FunctionApprovalRequestContent(
approval_2 = Content.from_function_approval_request(
id="approval_2",
function_call=func_call_2,
)
@@ -164,12 +164,12 @@ async def test_function_approval_request_sets_stop_flag():
assert bridge.should_stop_after_confirm is False
func_call = FunctionCallContent(
func_call = Content.from_function_call(
call_id="call_stop_test",
name="get_datetime",
arguments={},
)
approval_request = FunctionApprovalRequestContent(
approval_request = Content.from_function_approval_request(
id="approval_stop_test",
function_call=func_call,
)
@@ -5,7 +5,7 @@
import json
import pytest
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent, Role, TextContent
from agent_framework import ChatMessage, Content, Role
from agent_framework_ag_ui._message_adapters import (
agent_framework_messages_to_agui,
@@ -24,7 +24,7 @@ def sample_agui_message():
@pytest.fixture
def sample_agent_framework_message():
"""Create a sample Agent Framework message."""
return ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")], message_id="msg-123")
return ChatMessage(role=Role.USER, contents=[Content.from_text(text="Hello")], message_id="msg-123")
def test_agui_to_agent_framework_basic(sample_agui_message):
@@ -89,7 +89,7 @@ def test_agui_tool_result_to_agent_framework():
assert message.role == Role.USER
assert len(message.contents) == 1
assert isinstance(message.contents[0], TextContent)
assert message.contents[0].type == "text"
assert message.contents[0].text == '{"accepted": true, "steps": []}'
assert message.additional_properties is not None
@@ -141,7 +141,7 @@ def test_agui_tool_approval_updates_tool_call_arguments():
assert len(messages) == 2
assistant_msg = messages[0]
func_call = next(content for content in assistant_msg.contents if isinstance(content, FunctionCallContent))
func_call = next(content for content in assistant_msg.contents if content.type == "function_call")
assert func_call.arguments == {
"steps": [
{"description": "Boil water", "status": "enabled"},
@@ -157,11 +157,9 @@ def test_agui_tool_approval_updates_tool_call_arguments():
]
}
from agent_framework import FunctionApprovalResponseContent
approval_msg = messages[1]
approval_content = next(
content for content in approval_msg.contents if isinstance(content, FunctionApprovalResponseContent)
content for content in approval_msg.contents if content.type == "function_approval_response"
)
assert approval_content.function_call.parse_arguments() == {
"steps": [
@@ -211,12 +209,9 @@ def test_agui_tool_approval_from_confirm_changes_maps_to_function_call():
]
messages = agui_messages_to_agent_framework(messages_input)
from agent_framework import FunctionApprovalResponseContent
approval_msg = messages[1]
approval_content = next(
content for content in approval_msg.contents if isinstance(content, FunctionApprovalResponseContent)
content for content in approval_msg.contents if content.type == "function_approval_response"
)
assert approval_content.function_call.call_id == "call_tool"
@@ -259,12 +254,9 @@ def test_agui_tool_approval_from_confirm_changes_falls_back_to_sibling_call():
]
messages = agui_messages_to_agent_framework(messages_input)
from agent_framework import FunctionApprovalResponseContent
approval_msg = messages[1]
approval_content = next(
content for content in approval_msg.contents if isinstance(content, FunctionApprovalResponseContent)
content for content in approval_msg.contents if content.type == "function_approval_response"
)
assert approval_content.function_call.call_id == "call_tool"
@@ -315,12 +307,9 @@ def test_agui_tool_approval_from_generate_task_steps_maps_to_function_call():
]
messages = agui_messages_to_agent_framework(messages_input)
from agent_framework import FunctionApprovalResponseContent
approval_msg = messages[1]
approval_content = next(
content for content in approval_msg.contents if isinstance(content, FunctionApprovalResponseContent)
content for content in approval_msg.contents if content.type == "function_approval_response"
)
assert approval_content.function_call.call_id == "call_tool"
@@ -380,15 +369,14 @@ def test_agui_function_approvals():
assert msg.role == Role.USER
assert len(msg.contents) == 2
from agent_framework import FunctionApprovalResponseContent
assert isinstance(msg.contents[0], FunctionApprovalResponseContent)
assert msg.contents[0].type == "function_approval_response"
assert msg.contents[0].approved is True
assert msg.contents[0].id == "approval-1"
assert msg.contents[0].function_call.name == "search"
assert msg.contents[0].function_call.call_id == "call-1"
assert isinstance(msg.contents[1], FunctionApprovalResponseContent)
assert msg.contents[1].type == "function_approval_response"
assert msg.contents[1].id == "approval-2"
assert msg.contents[1].approved is False
@@ -406,7 +394,7 @@ def test_agui_non_string_content():
assert len(messages) == 1
assert len(messages[0].contents) == 1
assert isinstance(messages[0].contents[0], TextContent)
assert messages[0].contents[0].type == "text"
assert "nested" in messages[0].contents[0].text
@@ -440,9 +428,9 @@ def test_agui_with_tool_calls_to_agent_framework():
assert msg.role == Role.ASSISTANT
assert msg.message_id == "msg-789"
# First content is text, second is the function call
assert isinstance(msg.contents[0], TextContent)
assert msg.contents[0].type == "text"
assert msg.contents[0].text == "Calling tool"
assert isinstance(msg.contents[1], FunctionCallContent)
assert msg.contents[1].type == "function_call"
assert msg.contents[1].call_id == "call-123"
assert msg.contents[1].name == "get_weather"
assert msg.contents[1].arguments == {"location": "Seattle"}
@@ -453,8 +441,8 @@ def test_agent_framework_to_agui_with_tool_calls():
msg = ChatMessage(
role=Role.ASSISTANT,
contents=[
TextContent(text="Calling tool"),
FunctionCallContent(call_id="call-123", name="search", arguments={"query": "test"}),
Content.from_text(text="Calling tool"),
Content.from_function_call(call_id="call-123", name="search", arguments={"query": "test"}),
],
message_id="msg-456",
)
@@ -477,7 +465,7 @@ def test_agent_framework_to_agui_multiple_text_contents():
"""Test concatenating multiple text contents."""
msg = ChatMessage(
role=Role.ASSISTANT,
contents=[TextContent(text="Part 1 "), TextContent(text="Part 2")],
contents=[Content.from_text(text="Part 1 "), Content.from_text(text="Part 2")],
)
messages = agent_framework_messages_to_agui([msg])
@@ -488,7 +476,7 @@ def test_agent_framework_to_agui_multiple_text_contents():
def test_agent_framework_to_agui_no_message_id():
"""Test message without message_id - should auto-generate ID."""
msg = ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])
msg = ChatMessage(role=Role.USER, contents=[Content.from_text(text="Hello")])
messages = agent_framework_messages_to_agui([msg])
@@ -500,7 +488,7 @@ def test_agent_framework_to_agui_no_message_id():
def test_agent_framework_to_agui_system_role():
"""Test system role conversion."""
msg = ChatMessage(role=Role.SYSTEM, contents=[TextContent(text="System")])
msg = ChatMessage(role=Role.SYSTEM, contents=[Content.from_text(text="System")])
messages = agent_framework_messages_to_agui([msg])
@@ -510,7 +498,7 @@ def test_agent_framework_to_agui_system_role():
def test_extract_text_from_contents():
"""Test extracting text from contents list."""
contents = [TextContent(text="Hello "), TextContent(text="World")]
contents = [Content.from_text(text="Hello "), Content.from_text(text="World")]
result = extract_text_from_contents(contents)
@@ -533,7 +521,7 @@ class CustomTextContent:
def test_extract_text_from_custom_contents():
"""Test extracting text from custom content objects."""
contents = [CustomTextContent(text="Custom "), TextContent(text="Mixed")]
contents = [CustomTextContent(text="Custom "), Content.from_text(text="Mixed")]
result = extract_text_from_contents(contents)
@@ -547,7 +535,7 @@ def test_agent_framework_to_agui_function_result_dict():
"""Test converting FunctionResultContent with dict result to AG-UI."""
msg = ChatMessage(
role=Role.TOOL,
contents=[FunctionResultContent(call_id="call-123", result={"key": "value", "count": 42})],
contents=[Content.from_function_result(call_id="call-123", result={"key": "value", "count": 42})],
message_id="msg-789",
)
@@ -564,7 +552,7 @@ def test_agent_framework_to_agui_function_result_none():
"""Test converting FunctionResultContent with None result to AG-UI."""
msg = ChatMessage(
role=Role.TOOL,
contents=[FunctionResultContent(call_id="call-123", result=None)],
contents=[Content.from_function_result(call_id="call-123", result=None)],
message_id="msg-789",
)
@@ -580,7 +568,7 @@ def test_agent_framework_to_agui_function_result_string():
"""Test converting FunctionResultContent with string result to AG-UI."""
msg = ChatMessage(
role=Role.TOOL,
contents=[FunctionResultContent(call_id="call-123", result="plain text result")],
contents=[Content.from_function_result(call_id="call-123", result="plain text result")],
message_id="msg-789",
)
@@ -595,7 +583,7 @@ def test_agent_framework_to_agui_function_result_empty_list():
"""Test converting FunctionResultContent with empty list result to AG-UI."""
msg = ChatMessage(
role=Role.TOOL,
contents=[FunctionResultContent(call_id="call-123", result=[])],
contents=[Content.from_function_result(call_id="call-123", result=[])],
message_id="msg-789",
)
@@ -617,7 +605,7 @@ def test_agent_framework_to_agui_function_result_single_text_content():
msg = ChatMessage(
role=Role.TOOL,
contents=[FunctionResultContent(call_id="call-123", result=[MockTextContent("Hello from MCP!")])],
contents=[Content.from_function_result(call_id="call-123", result=[MockTextContent("Hello from MCP!")])],
message_id="msg-789",
)
@@ -640,7 +628,7 @@ def test_agent_framework_to_agui_function_result_multiple_text_contents():
msg = ChatMessage(
role=Role.TOOL,
contents=[
FunctionResultContent(
Content.from_function_result(
call_id="call-123",
result=[MockTextContent("First result"), MockTextContent("Second result")],
)
@@ -1,6 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent, TextContent
from agent_framework import ChatMessage, Content
from agent_framework_ag_ui._message_adapters import _deduplicate_messages, _sanitize_tool_history
@@ -10,7 +10,7 @@ def test_sanitize_tool_history_injects_confirm_changes_result() -> None:
ChatMessage(
role="assistant",
contents=[
FunctionCallContent(
Content.from_function_call(
name="confirm_changes",
call_id="call_confirm_123",
arguments='{"changes": "test"}',
@@ -19,7 +19,7 @@ def test_sanitize_tool_history_injects_confirm_changes_result() -> None:
),
ChatMessage(
role="user",
contents=[TextContent(text='{"accepted": true}')],
contents=[Content.from_text(text='{"accepted": true}')],
),
]
@@ -37,11 +37,11 @@ def test_deduplicate_messages_prefers_non_empty_tool_results() -> None:
messages = [
ChatMessage(
role="tool",
contents=[FunctionResultContent(call_id="call1", result="")],
contents=[Content.from_function_result(call_id="call1", result="")],
),
ChatMessage(
role="tool",
contents=[FunctionResultContent(call_id="call1", result="result data")],
contents=[Content.from_function_result(call_id="call1", result="result data")],
),
]
@@ -13,8 +13,8 @@ from agent_framework import (
BaseChatClient,
ChatAgent,
ChatResponseUpdate,
Content,
FunctionInvocationConfiguration,
TextContent,
ai_function,
)
@@ -79,11 +79,11 @@ def _create_mock_chat_agent(
if capture_messages is not None:
capture_messages.extend(messages)
yield AgentResponseUpdate(
contents=[TextContent(text="ok")],
contents=[Content.from_text(text="ok")],
role="assistant",
response_id=thread.metadata.get("ag_ui_run_id"), # type: ignore[attr-defined] (metadata always created in orchestrator)
raw_representation=ChatResponseUpdate(
contents=[TextContent(text="ok")],
contents=[Content.from_text(text="ok")],
conversation_id=thread.metadata.get("ag_ui_thread_id"), # type: ignore[attr-defined] (metadata always created in orchestrator)
response_id=thread.metadata.get("ag_ui_run_id"), # type: ignore[attr-defined] (metadata always created in orchestrator)
),
@@ -253,7 +253,7 @@ async def test_state_context_injected_when_tool_call_state_mismatch() -> None:
if role_value != "system":
continue
for content in msg.contents or []:
if isinstance(content, TextContent) and content.text.startswith("Current state of the application:"):
if content.type == "text" and content.text.startswith("Current state of the application:"):
state_messages.append(content.text)
assert state_messages
assert "Vegetarian" in state_messages[0]
@@ -302,6 +302,6 @@ async def test_state_context_not_injected_when_tool_call_matches_state() -> None
if role_value != "system":
continue
for content in msg.contents or []:
if isinstance(content, TextContent) and content.text.startswith("Current state of the application:"):
if content.type == "text" and content.text.startswith("Current state of the application:"):
state_messages.append(content.text)
assert not state_messages
@@ -8,12 +8,7 @@ from pathlib import Path
from types import SimpleNamespace
from typing import Any
from agent_framework import (
AgentResponseUpdate,
ChatMessage,
TextContent,
ai_function,
)
from agent_framework import AgentResponseUpdate, ChatMessage, Content, ai_function
from pydantic import BaseModel
from agent_framework_ag_ui._agent import AgentConfig
@@ -48,14 +43,14 @@ async def test_human_in_the_loop_json_decode_error() -> None:
messages = [
ChatMessage(
role="tool",
contents=[TextContent(text="not valid json {")],
contents=[Content.from_text(text="not valid json {")],
additional_properties={"is_tool_result": True},
)
]
agent = StubAgent(
default_options={"tools": [approval_tool], "response_format": None},
updates=[AgentResponseUpdate(contents=[TextContent(text="response")], role="assistant")],
updates=[AgentResponseUpdate(contents=[Content.from_text(text="response")], role="assistant")],
)
context = TestExecutionContext(
input_data=input_data,
@@ -78,14 +73,14 @@ async def test_human_in_the_loop_json_decode_error() -> None:
async def test_sanitize_tool_history_confirm_changes() -> None:
"""Test sanitize_tool_history logic for confirm_changes synthetic result."""
from agent_framework import ChatMessage, FunctionCallContent, TextContent
from agent_framework import ChatMessage
# Create messages that will trigger confirm_changes synthetic result injection
messages = [
ChatMessage(
role="assistant",
contents=[
FunctionCallContent(
Content.from_function_call(
name="confirm_changes",
call_id="call_confirm_123",
arguments='{"changes": "test"}',
@@ -94,7 +89,7 @@ async def test_sanitize_tool_history_confirm_changes() -> None:
),
ChatMessage(
role="user",
contents=[TextContent(text='{"accepted": true}')],
contents=[Content.from_text(text='{"accepted": true}')],
),
]
@@ -134,17 +129,17 @@ async def test_sanitize_tool_history_confirm_changes() -> None:
async def test_sanitize_tool_history_orphaned_tool_result() -> None:
"""Test sanitize_tool_history removes orphaned tool results."""
from agent_framework import ChatMessage, FunctionResultContent, TextContent
from agent_framework import ChatMessage
# Tool result without preceding assistant tool call
messages = [
ChatMessage(
role="tool",
contents=[FunctionResultContent(call_id="orphan_123", result="orphaned data")],
contents=[Content.from_function_result(call_id="orphan_123", result="orphaned data")],
),
ChatMessage(
role="user",
contents=[TextContent(text="Hello")],
contents=[Content.from_text(text="Hello")],
),
]
@@ -214,20 +209,20 @@ async def test_orphaned_tool_result_sanitization() -> None:
async def test_deduplicate_messages_empty_tool_results() -> None:
"""Test deduplicate_messages prefers non-empty tool results."""
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent
from agent_framework import ChatMessage
messages = [
ChatMessage(
role="assistant",
contents=[FunctionCallContent(name="test_tool", call_id="call_789", arguments="{}")],
contents=[Content.from_function_call(name="test_tool", call_id="call_789", arguments="{}")],
),
ChatMessage(
role="tool",
contents=[FunctionResultContent(call_id="call_789", result="")],
contents=[Content.from_function_result(call_id="call_789", result="")],
),
ChatMessage(
role="tool",
contents=[FunctionResultContent(call_id="call_789", result="real data")],
contents=[Content.from_function_result(call_id="call_789", result="real data")],
),
]
@@ -259,20 +254,20 @@ async def test_deduplicate_messages_empty_tool_results() -> None:
async def test_deduplicate_messages_duplicate_assistant_tool_calls() -> None:
"""Test deduplicate_messages removes duplicate assistant tool call messages."""
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent
from agent_framework import ChatMessage
messages = [
ChatMessage(
role="assistant",
contents=[FunctionCallContent(name="test_tool", call_id="call_abc", arguments="{}")],
contents=[Content.from_function_call(name="test_tool", call_id="call_abc", arguments="{}")],
),
ChatMessage(
role="assistant",
contents=[FunctionCallContent(name="test_tool", call_id="call_abc", arguments="{}")],
contents=[Content.from_function_call(name="test_tool", call_id="call_abc", arguments="{}")],
),
ChatMessage(
role="tool",
contents=[FunctionResultContent(call_id="call_abc", result="result")],
contents=[Content.from_function_result(call_id="call_abc", result="result")],
),
]
@@ -303,20 +298,20 @@ async def test_deduplicate_messages_duplicate_assistant_tool_calls() -> None:
async def test_deduplicate_messages_duplicate_system_messages() -> None:
"""Test that deduplication logic is invoked for system messages."""
from agent_framework import ChatMessage, TextContent
from agent_framework import ChatMessage
messages = [
ChatMessage(
role="system",
contents=[TextContent(text="You are a helpful assistant.")],
contents=[Content.from_text(text="You are a helpful assistant.")],
),
ChatMessage(
role="system",
contents=[TextContent(text="You are a helpful assistant.")],
contents=[Content.from_text(text="You are a helpful assistant.")],
),
ChatMessage(
role="user",
contents=[TextContent(text="Hello")],
contents=[Content.from_text(text="Hello")],
),
]
@@ -387,20 +382,20 @@ async def test_state_context_injection() -> None:
async def test_state_context_injection_with_tool_calls_and_input_state() -> None:
"""Test state context is injected when state is provided, even with tool calls."""
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent, TextContent
from agent_framework import ChatMessage
messages = [
ChatMessage(
role="assistant",
contents=[FunctionCallContent(name="get_weather", call_id="call_xyz", arguments="{}")],
contents=[Content.from_function_call(name="get_weather", call_id="call_xyz", arguments="{}")],
),
ChatMessage(
role="tool",
contents=[FunctionResultContent(call_id="call_xyz", result="sunny")],
contents=[Content.from_function_result(call_id="call_xyz", result="sunny")],
),
ChatMessage(
role="user",
contents=[TextContent(text="Thanks")],
contents=[Content.from_text(text="Thanks")],
),
]
@@ -452,7 +447,7 @@ async def test_structured_output_processing() -> None:
default_options=DEFAULT_OPTIONS,
updates=[
AgentResponseUpdate(
contents=[TextContent(text='{"ingredients": ["tomato"], "message": "Added tomato"}')],
contents=[Content.from_text(text='{"ingredients": ["tomato"], "message": "Added tomato"}')],
role="assistant",
)
],
@@ -641,13 +636,13 @@ async def test_all_messages_filtered_handling() -> None:
async def test_confirm_changes_with_invalid_json_fallback() -> None:
"""Test confirm_changes with invalid JSON falls back to normal processing."""
from agent_framework import ChatMessage, FunctionCallContent, TextContent
from agent_framework import ChatMessage
messages = [
ChatMessage(
role="assistant",
contents=[
FunctionCallContent(
Content.from_function_call(
name="confirm_changes",
call_id="call_confirm_invalid",
arguments='{"changes": "test"}',
@@ -656,7 +651,7 @@ async def test_confirm_changes_with_invalid_json_fallback() -> None:
),
ChatMessage(
role="user",
contents=[TextContent(text="invalid json {")],
contents=[Content.from_text(text="invalid json {")],
),
]
@@ -688,19 +683,18 @@ async def test_confirm_changes_with_invalid_json_fallback() -> None:
async def test_confirm_changes_closes_active_message_before_finish() -> None:
"""Confirm-changes flow closes any active text message before run finishes."""
from ag_ui.core import TextMessageEndEvent, TextMessageStartEvent
from agent_framework import FunctionCallContent, FunctionResultContent
updates = [
AgentResponseUpdate(
contents=[
FunctionCallContent(
Content.from_function_call(
name="write_document_local",
call_id="call_1",
arguments='{"document": "Draft"}',
)
]
),
AgentResponseUpdate(contents=[FunctionResultContent(call_id="call_1", result="Done")]),
AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_1", result="Done")]),
]
orchestrator = DefaultOrchestrator()
@@ -735,16 +729,16 @@ async def test_confirm_changes_closes_active_message_before_finish() -> None:
async def test_tool_result_kept_when_call_id_matches() -> None:
"""Test tool result is kept when call_id matches pending tool calls."""
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent
from agent_framework import ChatMessage
messages = [
ChatMessage(
role="assistant",
contents=[FunctionCallContent(name="get_data", call_id="call_match", arguments="{}")],
contents=[Content.from_function_call(name="get_data", call_id="call_match", arguments="{}")],
),
ChatMessage(
role="tool",
contents=[FunctionResultContent(call_id="call_match", result="data")],
contents=[Content.from_function_result(call_id="call_match", result="data")],
),
]
@@ -794,11 +788,11 @@ async def test_agent_protocol_fallback_paths() -> None:
**kwargs: Any,
) -> AsyncGenerator[AgentResponseUpdate, None]:
self.messages_received = messages
yield AgentResponseUpdate(contents=[TextContent(text="response")], role="assistant")
yield AgentResponseUpdate(contents=[Content.from_text(text="response")], role="assistant")
from agent_framework import ChatMessage, TextContent
from agent_framework import ChatMessage
messages = [ChatMessage(role="user", contents=[TextContent(text="Hello")])]
messages = [ChatMessage(role="user", contents=[Content.from_text(text="Hello")])]
orchestrator = DefaultOrchestrator()
input_data: dict[str, Any] = {"messages": []}
@@ -820,9 +814,9 @@ async def test_agent_protocol_fallback_paths() -> None:
async def test_initial_state_snapshot_with_array_schema() -> None:
"""Test state initialization with array type schema."""
from agent_framework import ChatMessage, TextContent
from agent_framework import ChatMessage
messages = [ChatMessage(role="user", contents=[TextContent(text="Hello")])]
messages = [ChatMessage(role="user", contents=[Content.from_text(text="Hello")])]
orchestrator = DefaultOrchestrator()
input_data: dict[str, Any] = {"messages": [], "state": {}}
@@ -851,9 +845,9 @@ async def test_response_format_skip_text_content() -> None:
class OutputModel(BaseModel):
result: str
from agent_framework import ChatMessage, TextContent
from agent_framework import ChatMessage
messages = [ChatMessage(role="user", contents=[TextContent(text="Hello")])]
messages = [ChatMessage(role="user", contents=[Content.from_text(text="Hello")])]
orchestrator = DefaultOrchestrator()
input_data: dict[str, Any] = {"messages": []}
@@ -7,7 +7,7 @@ from pathlib import Path
from typing import Any
from ag_ui.core import RunFinishedEvent, RunStartedEvent
from agent_framework import TextContent
from agent_framework import Content
from agent_framework._types import AgentResponseUpdate, ChatResponseUpdate
sys.path.insert(0, str(Path(__file__).parent))
@@ -20,10 +20,10 @@ async def test_service_thread_id_when_there_are_updates():
updates: list[AgentResponseUpdate] = [
AgentResponseUpdate(
contents=[TextContent(text="Hello, user!")],
contents=[Content.from_text(text="Hello, user!")],
response_id="resp_67890",
raw_representation=ChatResponseUpdate(
contents=[TextContent(text="Hello, user!")],
contents=[Content.from_text(text="Hello, user!")],
conversation_id="conv_12345",
response_id="resp_67890",
),
@@ -8,7 +8,7 @@ from typing import Any
import pytest
from ag_ui.core import StateSnapshotEvent
from agent_framework import ChatAgent, ChatResponseUpdate, TextContent
from agent_framework import ChatAgent, ChatResponseUpdate, Content
from agent_framework_ag_ui._agent import AgentFrameworkAgent
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
@@ -20,7 +20,7 @@ from utils_test_ag_ui import StreamingChatClientStub, stream_from_updates
@pytest.fixture
def mock_agent() -> ChatAgent:
"""Create a mock agent for testing."""
updates = [ChatResponseUpdate(contents=[TextContent(text="Hello!")])]
updates = [ChatResponseUpdate(contents=[Content.from_text(text="Hello!")])]
chat_client = StreamingChatClientStub(stream_from_updates(updates))
return ChatAgent(name="test_agent", instructions="Test agent", chat_client=chat_client)
@@ -1,7 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
from ag_ui.core import CustomEvent, EventType
from agent_framework import ChatMessage, TextContent
from agent_framework import ChatMessage
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
from agent_framework_ag_ui._orchestration._state_manager import StateManager
@@ -47,5 +47,5 @@ def test_state_context_only_when_new_user_turn() -> None:
message = state_manager.state_context_message(is_new_user_turn=True, conversation_has_tool_calls=False)
assert isinstance(message, ChatMessage)
assert isinstance(message.contents[0], TextContent)
assert message.contents[0].type == "text"
assert "Current state of the application" in message.contents[0].text
@@ -8,7 +8,7 @@ from collections.abc import AsyncIterator, MutableSequence
from pathlib import Path
from typing import Any
from agent_framework import ChatAgent, ChatMessage, ChatOptions, ChatResponseUpdate, TextContent
from agent_framework import ChatAgent, ChatMessage, ChatOptions, ChatResponseUpdate, Content
from pydantic import BaseModel
sys.path.insert(0, str(Path(__file__).parent))
@@ -43,7 +43,7 @@ async def test_structured_output_with_recipe():
messages: MutableSequence[ChatMessage], options: ChatOptions, **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(
contents=[TextContent(text='{"recipe": {"name": "Pasta"}, "message": "Here is your recipe"}')]
contents=[Content.from_text(text='{"recipe": {"name": "Pasta"}, "message": "Here is your recipe"}')]
)
agent = ChatAgent(name="test", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
@@ -86,7 +86,7 @@ async def test_structured_output_with_steps():
{"id": "2", "description": "Step 2", "status": "pending"},
]
}
yield ChatResponseUpdate(contents=[TextContent(text=json.dumps(steps_data))])
yield ChatResponseUpdate(contents=[Content.from_text(text=json.dumps(steps_data))])
agent = ChatAgent(name="test", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
agent.default_options = ChatOptions(response_format=StepsOutput)
@@ -118,7 +118,7 @@ async def test_structured_output_with_no_schema_match():
from agent_framework.ag_ui import AgentFrameworkAgent
updates = [
ChatResponseUpdate(contents=[TextContent(text='{"data": {"key": "value"}}')]),
ChatResponseUpdate(contents=[Content.from_text(text='{"data": {"key": "value"}}')]),
]
agent = ChatAgent(
@@ -156,7 +156,7 @@ async def test_structured_output_without_schema():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: ChatOptions, **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text='{"data": {"key": "value"}, "info": "processed"}')])
yield ChatResponseUpdate(contents=[Content.from_text(text='{"data": {"key": "value"}, "info": "processed"}')])
agent = ChatAgent(name="test", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
agent.default_options = ChatOptions(response_format=DataOutput)
@@ -185,7 +185,7 @@ async def test_no_structured_output_when_no_response_format():
"""Test that structured output path is skipped when no response_format."""
from agent_framework.ag_ui import AgentFrameworkAgent
updates = [ChatResponseUpdate(contents=[TextContent(text="Regular text")])]
updates = [ChatResponseUpdate(contents=[Content.from_text(text="Regular text")])]
agent = ChatAgent(
name="test",
@@ -216,7 +216,7 @@ async def test_structured_output_with_message_field():
messages: MutableSequence[ChatMessage], options: ChatOptions, **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
output_data = {"recipe": {"name": "Salad"}, "message": "Fresh salad recipe ready"}
yield ChatResponseUpdate(contents=[TextContent(text=json.dumps(output_data))])
yield ChatResponseUpdate(contents=[Content.from_text(text=json.dumps(output_data))])
agent = ChatAgent(name="test", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
agent.default_options = ChatOptions(response_format=RecipeOutput)
@@ -16,7 +16,7 @@ from agent_framework import (
ChatMessage,
ChatResponse,
ChatResponseUpdate,
TextContent,
Content,
)
from agent_framework._clients import TOptions_co
@@ -91,7 +91,7 @@ class StubAgent(AgentProtocol):
self.id = agent_id
self.name = agent_name
self.description = "stub agent"
self.updates = updates or [AgentResponseUpdate(contents=[TextContent(text="response")], role="assistant")]
self.updates = updates or [AgentResponseUpdate(contents=[Content.from_text(text="response")], role="assistant")]
self.default_options: dict[str, Any] = (
default_options if isinstance(default_options, dict) else {"tools": None, "response_format": None}
)
@@ -7,31 +7,19 @@ from typing import Any, ClassVar, Final, Generic, Literal, TypedDict
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
AIFunction,
Annotations,
Annotation,
BaseChatClient,
ChatMessage,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
CitationAnnotation,
CodeInterpreterToolCallContent,
CodeInterpreterToolResultContent,
Contents,
ErrorContent,
Content,
FinishReason,
FunctionCallContent,
FunctionResultContent,
HostedCodeInterpreterTool,
HostedFileContent,
HostedMCPTool,
HostedWebSearchTool,
MCPServerToolCallContent,
MCPServerToolResultContent,
Role,
TextContent,
TextReasoningContent,
TextSpanRegion,
UsageContent,
UsageDetails,
get_logger,
prepare_function_call_results,
@@ -486,7 +474,7 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
a_content.append({
"type": "image",
"source": {
"data": content.get_data_bytes_as_str(),
"data": content.get_data_bytes_as_str(), # type: ignore[attr-defined]
"media_type": content.media_type,
"type": "base64",
},
@@ -653,9 +641,9 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
"""
match event.type:
case "message_start":
usage_details: list[UsageContent] = []
usage_details: list[Content] = []
if event.message.usage and (details := self._parse_usage_from_anthropic(event.message.usage)):
usage_details.append(UsageContent(details=details))
usage_details.append(Content.from_usage(usage_details=details))
return ChatResponseUpdate(
response_id=event.message.id,
@@ -672,7 +660,7 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
case "message_delta":
usage = self._parse_usage_from_anthropic(event.usage)
return ChatResponseUpdate(
contents=[UsageContent(details=usage, raw_representation=event.usage)] if usage else [],
contents=[Content.from_usage(usage_details=usage, raw_representation=event.usage)] if usage else [],
finish_reason=FINISH_REASON_MAP.get(event.delta.stop_reason) if event.delta.stop_reason else None,
raw_representation=event,
)
@@ -702,24 +690,24 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
return None
usage_details = UsageDetails(output_token_count=usage.output_tokens)
if usage.input_tokens is not None:
usage_details.input_token_count = usage.input_tokens
usage_details["input_token_count"] = usage.input_tokens
if usage.cache_creation_input_tokens is not None:
usage_details.additional_counts["anthropic.cache_creation_input_tokens"] = usage.cache_creation_input_tokens
usage_details["anthropic.cache_creation_input_tokens"] = usage.cache_creation_input_tokens # type: ignore[typeddict-unknown-key]
if usage.cache_read_input_tokens is not None:
usage_details.additional_counts["anthropic.cache_read_input_tokens"] = usage.cache_read_input_tokens
usage_details["anthropic.cache_read_input_tokens"] = usage.cache_read_input_tokens # type: ignore[typeddict-unknown-key]
return usage_details
def _parse_contents_from_anthropic(
self,
content: Sequence[BetaContentBlock | BetaRawContentBlockDelta | BetaTextBlock],
) -> list[Contents]:
) -> list[Content]:
"""Parse contents from the Anthropic message."""
contents: list[Contents] = []
contents: list[Content] = []
for content_block in content:
match content_block.type:
case "text" | "text_delta":
contents.append(
TextContent(
Content.from_text(
text=content_block.text,
raw_representation=content_block,
annotations=self._parse_citations_from_anthropic(content_block),
@@ -729,7 +717,7 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
self._last_call_id_name = (content_block.id, content_block.name)
if content_block.type == "mcp_tool_use":
contents.append(
MCPServerToolCallContent(
Content.from_mcp_server_tool_call(
call_id=content_block.id,
tool_name=content_block.name,
server_name=None,
@@ -739,10 +727,10 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
)
elif "code_execution" in (content_block.name or ""):
contents.append(
CodeInterpreterToolCallContent(
Content.from_code_interpreter_tool_call(
call_id=content_block.id,
inputs=[
TextContent(
Content.from_text(
text=str(content_block.input),
raw_representation=content_block,
)
@@ -752,7 +740,7 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
)
else:
contents.append(
FunctionCallContent(
Content.from_function_call(
call_id=content_block.id,
name=content_block.name,
arguments=content_block.input,
@@ -760,14 +748,14 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
)
)
case "mcp_tool_result":
call_id, name = self._last_call_id_name or (None, None)
parsed_output: list[Contents] | None = None
call_id, _ = self._last_call_id_name or (None, None)
parsed_output: list[Content] | None = None
if content_block.content:
if isinstance(content_block.content, list):
parsed_output = self._parse_contents_from_anthropic(content_block.content)
elif isinstance(content_block.content, (str, bytes)):
parsed_output = [
TextContent(
Content.from_text(
text=str(content_block.content),
raw_representation=content_block,
)
@@ -775,28 +763,27 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
else:
parsed_output = self._parse_contents_from_anthropic([content_block.content])
contents.append(
MCPServerToolResultContent(
Content.from_mcp_server_tool_result(
call_id=content_block.tool_use_id,
output=parsed_output,
raw_representation=content_block,
)
)
case "web_search_tool_result" | "web_fetch_tool_result":
call_id, name = self._last_call_id_name or (None, None)
call_id, _ = self._last_call_id_name or (None, None)
contents.append(
FunctionResultContent(
Content.from_function_result(
call_id=content_block.tool_use_id,
name=name if name and call_id == content_block.tool_use_id else "web_tool",
result=content_block.content,
raw_representation=content_block,
)
)
case "code_execution_tool_result":
code_outputs: list[Contents] = []
code_outputs: list[Content] = []
if content_block.content:
if isinstance(content_block.content, BetaCodeExecutionToolResultError):
code_outputs.append(
ErrorContent(
Content.from_error(
message=content_block.content.error_code,
raw_representation=content_block.content,
)
@@ -804,41 +791,41 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
else:
if content_block.content.stdout:
code_outputs.append(
TextContent(
Content.from_text(
text=content_block.content.stdout,
raw_representation=content_block.content,
)
)
if content_block.content.stderr:
code_outputs.append(
ErrorContent(
Content.from_error(
message=content_block.content.stderr,
raw_representation=content_block.content,
)
)
for code_file_content in content_block.content.content:
code_outputs.append(
HostedFileContent(
Content.from_hosted_file(
file_id=code_file_content.file_id,
raw_representation=code_file_content,
)
)
contents.append(
CodeInterpreterToolResultContent(
Content.from_code_interpreter_tool_result(
call_id=content_block.tool_use_id,
raw_representation=content_block,
outputs=code_outputs,
)
)
case "bash_code_execution_tool_result":
bash_outputs: list[Contents] = []
bash_outputs: list[Content] = []
if content_block.content:
if isinstance(
content_block.content,
BetaBashCodeExecutionToolResultError,
):
bash_outputs.append(
ErrorContent(
Content.from_error(
message=content_block.content.error_code,
raw_representation=content_block.content,
)
@@ -846,39 +833,38 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
else:
if content_block.content.stdout:
bash_outputs.append(
TextContent(
Content.from_text(
text=content_block.content.stdout,
raw_representation=content_block.content,
)
)
if content_block.content.stderr:
bash_outputs.append(
ErrorContent(
Content.from_error(
message=content_block.content.stderr,
raw_representation=content_block.content,
)
)
for bash_file_content in content_block.content.content:
contents.append(
HostedFileContent(
Content.from_hosted_file(
file_id=bash_file_content.file_id,
raw_representation=bash_file_content,
)
)
contents.append(
FunctionResultContent(
Content.from_function_result(
call_id=content_block.tool_use_id,
name=content_block.type,
result=bash_outputs,
raw_representation=content_block,
)
)
case "text_editor_code_execution_tool_result":
text_editor_outputs: list[Contents] = []
text_editor_outputs: list[Content] = []
match content_block.content.type:
case "text_editor_code_execution_tool_result_error":
text_editor_outputs.append(
ErrorContent(
Content.from_error(
message=content_block.content.error_code
and getattr(content_block.content, "error_message", ""),
raw_representation=content_block.content,
@@ -887,10 +873,12 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
case "text_editor_code_execution_view_result":
annotations = (
[
CitationAnnotation(
Annotation(
type="citation",
raw_representation=content_block.content,
annotated_regions=[
TextSpanRegion(
type="text_span",
start_index=content_block.content.start_line,
end_index=content_block.content.start_line
+ (content_block.content.num_lines or 0),
@@ -903,7 +891,7 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
else None
)
text_editor_outputs.append(
TextContent(
Content.from_text(
text=content_block.content.content,
annotations=annotations,
raw_representation=content_block.content,
@@ -911,10 +899,12 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
)
case "text_editor_code_execution_str_replace_result":
old_annotation = (
CitationAnnotation(
Annotation(
type="citation",
raw_representation=content_block.content,
annotated_regions=[
TextSpanRegion(
type="text_span",
start_index=content_block.content.old_start or 0,
end_index=(
(content_block.content.old_start or 0)
@@ -928,13 +918,15 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
else None
)
new_annotation = (
CitationAnnotation(
Annotation(
type="citation",
raw_representation=content_block.content,
snippet="\n".join(content_block.content.lines)
snippet="\n".join(content_block.content.lines) # type: ignore[typeddict-item]
if content_block.content.lines
else None,
annotated_regions=[
TextSpanRegion(
type="text_span",
start_index=content_block.content.new_start or 0,
end_index=(
(content_block.content.new_start or 0)
@@ -950,7 +942,7 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
annotations = [ann for ann in [old_annotation, new_annotation] if ann is not None]
text_editor_outputs.append(
TextContent(
Content.from_text(
text=(
"\n".join(content_block.content.lines) if content_block.content.lines else ""
),
@@ -960,15 +952,14 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
)
case "text_editor_code_execution_create_result":
text_editor_outputs.append(
TextContent(
Content.from_text(
text=f"File update: {content_block.content.is_file_update}",
raw_representation=content_block.content,
)
)
contents.append(
FunctionResultContent(
Content.from_function_result(
call_id=content_block.tool_use_id,
name=content_block.type,
result=text_editor_outputs,
raw_representation=content_block,
)
@@ -981,7 +972,7 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
# This matches OpenAI's behavior where streaming chunks have name="".
call_id, _ = self._last_call_id_name if self._last_call_id_name else ("", "")
contents.append(
FunctionCallContent(
Content.from_function_call(
call_id=call_id,
name="",
arguments=content_block.partial_json,
@@ -990,7 +981,7 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
)
case "thinking" | "thinking_delta":
contents.append(
TextReasoningContent(
Content.from_text_reasoning(
text=content_block.thinking,
raw_representation=content_block,
)
@@ -1001,65 +992,65 @@ class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptio
def _parse_citations_from_anthropic(
self, content_block: BetaContentBlock | BetaRawContentBlockDelta | BetaTextBlock
) -> list[Annotations] | None:
content_citations = getattr(content_block, "citations", None)
if not content_citations:
) -> list[Annotation] | None:
content_blocks = getattr(content_block, "citations", None)
if not content_blocks:
return None
annotations: list[Annotations] = []
for citation in content_citations:
cit = CitationAnnotation(raw_representation=citation)
annotations: list[Annotation] = []
for citation in content_blocks:
cit = Annotation(type="citation", raw_representation=citation)
match citation.type:
case "char_location":
cit.title = citation.title
cit.snippet = citation.cited_text
cit["title"] = citation.title
cit["snippet"] = citation.cited_text
if citation.file_id:
cit.file_id = citation.file_id
if not cit.annotated_regions:
cit.annotated_regions = []
cit.annotated_regions.append(
cit["file_id"] = citation.file_id
cit.setdefault("annotated_regions", [])
cit["annotated_regions"].append( # type: ignore[attr-defined]
TextSpanRegion(
type="text_span",
start_index=citation.start_char_index,
end_index=citation.end_char_index,
)
)
case "page_location":
cit.title = citation.document_title
cit.snippet = citation.cited_text
cit["title"] = citation.document_title
cit["snippet"] = citation.cited_text
if citation.file_id:
cit.file_id = citation.file_id
if not cit.annotated_regions:
cit.annotated_regions = []
cit.annotated_regions.append(
cit["file_id"] = citation.file_id
cit.setdefault("annotated_regions", [])
cit["annotated_regions"].append( # type: ignore[attr-defined]
TextSpanRegion(
type="text_span",
start_index=citation.start_page_number,
end_index=citation.end_page_number,
)
)
case "content_block_location":
cit.title = citation.document_title
cit.snippet = citation.cited_text
cit["title"] = citation.document_title
cit["snippet"] = citation.cited_text
if citation.file_id:
cit.file_id = citation.file_id
if not cit.annotated_regions:
cit.annotated_regions = []
cit.annotated_regions.append(
cit["file_id"] = citation.file_id
cit.setdefault("annotated_regions", [])
cit["annotated_regions"].append( # type: ignore[attr-defined]
TextSpanRegion(
type="text_span",
start_index=citation.start_block_index,
end_index=citation.end_block_index,
)
)
case "web_search_result_location":
cit.title = citation.title
cit.snippet = citation.cited_text
cit.url = citation.url
cit["title"] = citation.title
cit["snippet"] = citation.cited_text
cit["url"] = citation.url
case "search_result_location":
cit.title = citation.title
cit.snippet = citation.cited_text
cit.url = citation.source
if not cit.annotated_regions:
cit.annotated_regions = []
cit.annotated_regions.append(
cit["title"] = citation.title
cit["snippet"] = citation.cited_text
cit["url"] = citation.source
cit.setdefault("annotated_regions", [])
cit["annotated_regions"].append( # type: ignore[attr-defined]
TextSpanRegion(
type="text_span",
start_index=citation.start_block_index,
end_index=citation.end_block_index,
)
@@ -10,16 +10,12 @@ from agent_framework import (
ChatMessage,
ChatOptions,
ChatResponseUpdate,
DataContent,
Content,
FinishReason,
FunctionCallContent,
FunctionResultContent,
HostedCodeInterpreterTool,
HostedMCPTool,
HostedWebSearchTool,
Role,
TextContent,
TextReasoningContent,
ai_function,
)
from agent_framework.exceptions import ServiceInitializationError
@@ -170,7 +166,7 @@ def test_prepare_message_for_anthropic_function_call(mock_anthropic_client: Magi
message = ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="call_123",
name="get_weather",
arguments={"location": "San Francisco"},
@@ -194,9 +190,8 @@ def test_prepare_message_for_anthropic_function_result(mock_anthropic_client: Ma
message = ChatMessage(
role=Role.TOOL,
contents=[
FunctionResultContent(
Content.from_function_result(
call_id="call_123",
name="get_weather",
result="Sunny, 72°F",
)
],
@@ -219,7 +214,7 @@ def test_prepare_message_for_anthropic_text_reasoning(mock_anthropic_client: Mag
chat_client = create_test_anthropic_client(mock_anthropic_client)
message = ChatMessage(
role=Role.ASSISTANT,
contents=[TextReasoningContent(text="Let me think about this...")],
contents=[Content.from_text_reasoning(text="Let me think about this...")],
)
result = chat_client._prepare_message_for_anthropic(message)
@@ -507,12 +502,12 @@ def test_process_message_basic(mock_anthropic_client: MagicMock) -> None:
assert len(response.messages) == 1
assert response.messages[0].role == Role.ASSISTANT
assert len(response.messages[0].contents) == 1
assert isinstance(response.messages[0].contents[0], TextContent)
assert response.messages[0].contents[0].type == "text"
assert response.messages[0].contents[0].text == "Hello there!"
assert response.finish_reason == FinishReason.STOP
assert response.usage_details is not None
assert response.usage_details.input_token_count == 10
assert response.usage_details.output_token_count == 5
assert response.usage_details["input_token_count"] == 10
assert response.usage_details["output_token_count"] == 5
def test_process_message_with_tool_use(mock_anthropic_client: MagicMock) -> None:
@@ -536,7 +531,7 @@ def test_process_message_with_tool_use(mock_anthropic_client: MagicMock) -> None
response = chat_client._process_message(mock_message)
assert len(response.messages[0].contents) == 1
assert isinstance(response.messages[0].contents[0], FunctionCallContent)
assert response.messages[0].contents[0].type == "function_call"
assert response.messages[0].contents[0].call_id == "call_123"
assert response.messages[0].contents[0].name == "get_weather"
assert response.finish_reason == FinishReason.TOOL_CALLS
@@ -550,8 +545,8 @@ def test_parse_usage_from_anthropic_basic(mock_anthropic_client: MagicMock) -> N
result = chat_client._parse_usage_from_anthropic(usage)
assert result is not None
assert result.input_token_count == 10
assert result.output_token_count == 5
assert result["input_token_count"] == 10
assert result["output_token_count"] == 5
def test_parse_usage_from_anthropic_none(mock_anthropic_client: MagicMock) -> None:
@@ -571,7 +566,7 @@ def test_parse_contents_from_anthropic_text(mock_anthropic_client: MagicMock) ->
result = chat_client._parse_contents_from_anthropic(content)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].type == "text"
assert result[0].text == "Hello!"
@@ -590,7 +585,7 @@ def test_parse_contents_from_anthropic_tool_use(mock_anthropic_client: MagicMock
result = chat_client._parse_contents_from_anthropic(content)
assert len(result) == 1
assert isinstance(result[0], FunctionCallContent)
assert result[0].type == "function_call"
assert result[0].call_id == "call_123"
assert result[0].name == "get_weather"
@@ -613,7 +608,7 @@ def test_parse_contents_from_anthropic_input_json_delta_no_duplicate_name(mock_a
result = chat_client._parse_contents_from_anthropic([tool_use_content])
assert len(result) == 1
assert isinstance(result[0], FunctionCallContent)
assert result[0].type == "function_call"
assert result[0].call_id == "call_123"
assert result[0].name == "get_weather" # Initial event has name
@@ -624,7 +619,7 @@ def test_parse_contents_from_anthropic_input_json_delta_no_duplicate_name(mock_a
result = chat_client._parse_contents_from_anthropic([delta_content_1])
assert len(result) == 1
assert isinstance(result[0], FunctionCallContent)
assert result[0].type == "function_call"
assert result[0].call_id == "call_123"
assert result[0].name == "" # Delta events should have empty name
assert result[0].arguments == '{"location":'
@@ -636,7 +631,7 @@ def test_parse_contents_from_anthropic_input_json_delta_no_duplicate_name(mock_a
result = chat_client._parse_contents_from_anthropic([delta_content_2])
assert len(result) == 1
assert isinstance(result[0], FunctionCallContent)
assert result[0].type == "function_call"
assert result[0].call_id == "call_123"
assert result[0].name == "" # Still empty name for subsequent deltas
assert result[0].arguments == '"San Francisco"}'
@@ -771,9 +766,7 @@ async def test_anthropic_client_integration_function_calling() -> None:
assert response is not None
# Should contain function call
has_function_call = any(
isinstance(content, FunctionCallContent) for msg in response.messages for content in msg.contents
)
has_function_call = any(content.type == "function_call" for msg in response.messages for content in msg.contents)
assert has_function_call
@@ -872,8 +865,8 @@ async def test_anthropic_client_integration_images() -> None:
ChatMessage(
role=Role.USER,
contents=[
TextContent(text="Describe this image"),
DataContent(media_type="image/jpeg", data=image_bytes),
Content.from_text(text="Describe this image"),
Content.from_data(media_type="image/jpeg", data=image_bytes),
],
),
]
@@ -2,6 +2,7 @@
import ast
import json
import os
import re
import sys
from collections.abc import AsyncIterable, Callable, Mapping, MutableMapping, MutableSequence, Sequence
@@ -9,6 +10,8 @@ from typing import Any, ClassVar, Generic, TypedDict
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
AIFunction,
Annotation,
BaseChatClient,
ChatAgent,
ChatMessage,
@@ -16,23 +19,16 @@ from agent_framework import (
ChatOptions,
ChatResponse,
ChatResponseUpdate,
CitationAnnotation,
Contents,
Content,
ContextProvider,
DataContent,
FunctionApprovalRequestContent,
FunctionApprovalResponseContent,
FunctionCallContent,
FunctionResultContent,
HostedFileContent,
HostedCodeInterpreterTool,
HostedFileSearchTool,
HostedMCPTool,
HostedWebSearchTool,
Middleware,
Role,
TextContent,
TextSpanRegion,
ToolProtocol,
UriContent,
UsageContent,
UsageDetails,
get_logger,
prepare_function_call_results,
@@ -50,9 +46,14 @@ from azure.ai.agents.models import (
AgentStreamEvent,
AsyncAgentEventHandler,
AsyncAgentRunStream,
BingCustomSearchTool,
BingGroundingTool,
CodeInterpreterToolDefinition,
FileSearchTool,
FunctionName,
FunctionToolDefinition,
ListSortOrder,
McpTool,
MessageDeltaChunk,
MessageDeltaTextContent,
MessageDeltaTextFileCitationAnnotation,
@@ -422,7 +423,7 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
self,
agent_id: str,
run_options: dict[str, Any],
required_action_results: list[FunctionResultContent | FunctionApprovalResponseContent] | None,
required_action_results: list[Content] | None,
) -> tuple[AsyncAgentRunStream[AsyncAgentEventHandler[Any]] | AsyncAgentEventHandler[Any], str]:
"""Create the agent stream for processing.
@@ -506,9 +507,9 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
def _extract_url_citations(
self, message_delta_chunk: MessageDeltaChunk, azure_search_tool_calls: list[dict[str, Any]]
) -> list[CitationAnnotation]:
) -> list[Annotation]:
"""Extract URL citations from MessageDeltaChunk."""
url_citations: list[CitationAnnotation] = []
url_citations: list[Annotation] = []
# Process each content item in the delta to find citations
for content in message_delta_chunk.delta.content:
@@ -520,6 +521,7 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
if annotation.start_index and annotation.end_index:
annotated_regions = [
TextSpanRegion(
type="text_span",
start_index=annotation.start_index,
end_index=annotation.end_index,
)
@@ -530,11 +532,12 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
annotation.url_citation.url, azure_search_tool_calls
)
# Create CitationAnnotation with real URL
citation = CitationAnnotation(
title=getattr(annotation.url_citation, "title", None),
# Create Annotation with real URL
citation = Annotation(
type="citation",
title=annotation.url_citation.title, # type: ignore[typeddict-item]
url=real_url,
snippet=None,
snippet=None, # type: ignore[typeddict-item]
annotated_regions=annotated_regions,
raw_representation=annotation,
)
@@ -542,7 +545,7 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
return url_citations
def _extract_file_path_contents(self, message_delta_chunk: MessageDeltaChunk) -> list[HostedFileContent]:
def _extract_file_path_contents(self, message_delta_chunk: MessageDeltaChunk) -> list[Content]:
"""Extract file references from MessageDeltaChunk annotations.
Code interpreter generates files that are referenced via file path or file citation
@@ -559,7 +562,7 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
Returns:
List of HostedFileContent objects for any files referenced in annotations
"""
file_contents: list[HostedFileContent] = []
file_contents: list[Content] = []
for content in message_delta_chunk.delta.content:
if isinstance(content, MessageDeltaTextContent) and content.text and content.text.annotations:
@@ -570,14 +573,14 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
if file_path is not None:
file_id = getattr(file_path, "file_id", None)
if file_id:
file_contents.append(HostedFileContent(file_id=file_id))
file_contents.append(Content.from_hosted_file(file_id=file_id))
elif isinstance(annotation, MessageDeltaTextFileCitationAnnotation):
# Extract file_id from the file_citation annotation
file_citation = getattr(annotation, "file_citation", None)
if file_citation is not None:
file_id = getattr(file_citation, "file_id", None)
if file_id:
file_contents.append(HostedFileContent(file_id=file_id))
file_contents.append(Content.from_hosted_file(file_id=file_id))
return file_contents
@@ -644,9 +647,9 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
file_contents = self._extract_file_path_contents(event_data)
# Create contents with citations if any exist
citation_content: list[Contents] = []
citation_content: list[Content] = []
if event_data.text or url_citations:
text_content_obj = TextContent(text=event_data.text or "")
text_content_obj = Content.from_text(text=event_data.text or "")
if url_citations:
text_content_obj.annotations = url_citations
citation_content.append(text_content_obj)
@@ -722,7 +725,7 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
self._capture_azure_search_tool_calls(event_data, azure_search_tool_calls)
if event_data.usage:
usage_content = UsageContent(
usage_content = Content.from_usage(
UsageDetails(
input_token_count=event_data.usage.prompt_tokens,
output_token_count=event_data.usage.completion_tokens,
@@ -757,19 +760,21 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
tool_call.code_interpreter,
RunStepDeltaCodeInterpreterDetailItemObject,
):
code_contents: list[Contents] = []
code_contents: list[Content] = []
if tool_call.code_interpreter.input is not None:
logger.debug(f"Code Interpreter Input: {tool_call.code_interpreter.input}")
if tool_call.code_interpreter.outputs is not None:
for output in tool_call.code_interpreter.outputs:
if isinstance(output, RunStepDeltaCodeInterpreterLogOutput) and output.logs:
code_contents.append(TextContent(text=output.logs))
code_contents.append(Content.from_text(text=output.logs))
if (
isinstance(output, RunStepDeltaCodeInterpreterImageOutput)
and output.image is not None
and output.image.file_id is not None
):
code_contents.append(HostedFileContent(file_id=output.image.file_id))
code_contents.append(
Content.from_hosted_file(file_id=output.image.file_id)
)
yield ChatResponseUpdate(
role=Role.ASSISTANT,
contents=code_contents,
@@ -822,12 +827,12 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
except Exception as ex:
logger.debug(f"Failed to capture Azure AI Search tool call: {ex}")
def _parse_function_calls_from_azure_ai(self, event_data: ThreadRun, response_id: str | None) -> list[Contents]:
def _parse_function_calls_from_azure_ai(self, event_data: ThreadRun, response_id: str | None) -> list[Content]:
"""Parse function call contents from an Azure AI tool action event."""
if isinstance(event_data, ThreadRun) and event_data.required_action is not None:
if isinstance(event_data.required_action, SubmitToolOutputsAction):
return [
FunctionCallContent(
Content.from_function_call(
call_id=f'["{response_id}", "{tool.id}"]',
name=tool.function.name,
arguments=tool.function.arguments,
@@ -837,9 +842,9 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
]
if isinstance(event_data.required_action, SubmitToolApprovalAction):
return [
FunctionApprovalRequestContent(
Content.from_function_approval_request(
id=f'["{response_id}", "{tool.id}"]',
function_call=FunctionCallContent(
function_call=Content.from_function_call(
call_id=f'["{response_id}", "{tool.id}"]',
name=tool.name,
arguments=tool.arguments,
@@ -875,7 +880,7 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
messages: MutableSequence[ChatMessage],
options: Mapping[str, Any],
**kwargs: Any,
) -> tuple[dict[str, Any], list[FunctionResultContent | FunctionApprovalResponseContent] | None]:
) -> tuple[dict[str, Any], list[Content] | None]:
agent_definition = await self._load_agent_definition_if_needed()
# Build run_options from options dict, excluding specific keys
@@ -1052,7 +1057,7 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
) -> tuple[
list[ThreadMessageOptions] | None,
list[str],
list[FunctionResultContent | FunctionApprovalResponseContent] | None,
list[Content] | None,
]:
"""Prepare messages for Azure AI Agents API.
@@ -1064,28 +1069,34 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
Tuple of (additional_messages, instructions, required_action_results)
"""
instructions: list[str] = []
required_action_results: list[FunctionResultContent | FunctionApprovalResponseContent] | None = None
required_action_results: list[Content] | None = None
additional_messages: list[ThreadMessageOptions] | None = None
for chat_message in messages:
if chat_message.role.value in ["system", "developer"]:
for text_content in [content for content in chat_message.contents if isinstance(content, TextContent)]:
instructions.append(text_content.text)
for text_content in [content for content in chat_message.contents if content.type == "text"]:
instructions.append(text_content.text) # type: ignore[arg-type]
continue
message_contents: list[MessageInputContentBlock] = []
for content in chat_message.contents:
if isinstance(content, TextContent):
message_contents.append(MessageInputTextBlock(text=content.text))
elif isinstance(content, (DataContent, UriContent)) and content.has_top_level_media_type("image"):
message_contents.append(MessageInputImageUrlBlock(image_url=MessageImageUrlParam(url=content.uri)))
elif isinstance(content, (FunctionResultContent, FunctionApprovalResponseContent)):
if required_action_results is None:
required_action_results = []
required_action_results.append(content)
elif isinstance(content.raw_representation, MessageInputContentBlock):
message_contents.append(content.raw_representation)
match content.type:
case "text":
message_contents.append(MessageInputTextBlock(text=content.text)) # type: ignore[arg-type]
case "data" | "uri":
if content.has_top_level_media_type("image"):
message_contents.append(
MessageInputImageUrlBlock(image_url=MessageImageUrlParam(url=content.uri)) # type: ignore[arg-type]
)
# Only images are supported. Other media types are ignored.
case "function_result" | "function_approval_response":
if required_action_results is None:
required_action_results = []
required_action_results.append(content)
case _:
if isinstance(content.raw_representation, MessageInputContentBlock):
message_contents.append(content.raw_representation)
if message_contents:
if additional_messages is None:
@@ -1099,9 +1110,85 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
return additional_messages, instructions, required_action_results
async def _prepare_tools_for_azure_ai(
self, tools: Sequence["ToolProtocol | MutableMapping[str, Any]"], run_options: dict[str, Any] | None = None
) -> list[ToolDefinition | dict[str, Any]]:
"""Prepare tool definitions for the Azure AI Agents API."""
tool_definitions: list[ToolDefinition | dict[str, Any]] = []
for tool in tools:
match tool:
case AIFunction():
tool_definitions.append(tool.to_json_schema_spec()) # type: ignore[reportUnknownArgumentType]
case HostedWebSearchTool():
additional_props = tool.additional_properties or {}
config_args: dict[str, Any] = {}
if count := additional_props.get("count"):
config_args["count"] = count
if freshness := additional_props.get("freshness"):
config_args["freshness"] = freshness
if market := additional_props.get("market"):
config_args["market"] = market
if set_lang := additional_props.get("set_lang"):
config_args["set_lang"] = set_lang
# Bing Grounding
connection_id = additional_props.get("connection_id") or os.getenv("BING_CONNECTION_ID")
# Custom Bing Search
custom_connection_id = additional_props.get("custom_connection_id") or os.getenv(
"BING_CUSTOM_CONNECTION_ID"
)
custom_instance_name = additional_props.get("custom_instance_name") or os.getenv(
"BING_CUSTOM_INSTANCE_NAME"
)
bing_search: BingGroundingTool | BingCustomSearchTool | None = None
if (connection_id) and not custom_connection_id and not custom_instance_name:
if connection_id:
conn_id = connection_id
else:
raise ServiceInitializationError("Parameter connection_id is not provided.")
bing_search = BingGroundingTool(connection_id=conn_id, **config_args)
if custom_connection_id and custom_instance_name:
bing_search = BingCustomSearchTool(
connection_id=custom_connection_id,
instance_name=custom_instance_name,
**config_args,
)
if not bing_search:
raise ServiceInitializationError(
"Bing search tool requires either 'connection_id' for Bing Grounding "
"or both 'custom_connection_id' and 'custom_instance_name' for Custom Bing Search. "
"These can be provided via additional_properties or environment variables: "
"'BING_CONNECTION_ID', 'BING_CUSTOM_CONNECTION_ID', "
"'BING_CUSTOM_INSTANCE_NAME'"
)
tool_definitions.extend(bing_search.definitions)
case HostedCodeInterpreterTool():
tool_definitions.append(CodeInterpreterToolDefinition())
case HostedMCPTool():
mcp_tool = McpTool(
server_label=tool.name.replace(" ", "_"),
server_url=str(tool.url),
allowed_tools=list(tool.allowed_tools) if tool.allowed_tools else [],
)
tool_definitions.extend(mcp_tool.definitions)
case HostedFileSearchTool():
vector_stores = [inp for inp in tool.inputs or [] if inp.type == "hosted_vector_store"]
if vector_stores:
file_search = FileSearchTool(vector_store_ids=[vs.vector_store_id for vs in vector_stores]) # type: ignore[misc]
tool_definitions.extend(file_search.definitions)
# Set tool_resources for file search to work properly with Azure AI
if run_options is not None and "tool_resources" not in run_options:
run_options["tool_resources"] = file_search.resources
case ToolDefinition():
tool_definitions.append(tool)
case dict():
tool_definitions.append(tool)
case _:
raise ServiceInitializationError(f"Unsupported tool type: {type(tool)}")
return tool_definitions
def _prepare_tool_outputs_for_azure_ai(
self,
required_action_results: list[FunctionResultContent | FunctionApprovalResponseContent] | None,
required_action_results: list[Content] | None,
) -> tuple[str | None, list[ToolOutput] | None, list[ToolApproval] | None]:
"""Prepare function results and approvals for submission to the Azure AI API."""
run_id: str | None = None
@@ -1115,9 +1202,7 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
# We need to extract the run ID and ensure that the Output/Approval we send back to Azure
# is only the call ID.
run_and_call_ids: list[str] = (
json.loads(content.call_id)
if isinstance(content, FunctionResultContent)
else json.loads(content.id)
json.loads(content.call_id) if content.type == "function_result" else json.loads(content.id) # type: ignore[arg-type]
)
if (
@@ -1132,16 +1217,16 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
run_id = run_and_call_ids[0]
call_id = run_and_call_ids[1]
if isinstance(content, FunctionResultContent):
if content.type == "function_result":
if tool_outputs is None:
tool_outputs = []
tool_outputs.append(
ToolOutput(tool_call_id=call_id, output=prepare_function_call_results(content.result))
)
elif isinstance(content, FunctionApprovalResponseContent):
elif content.type == "function_approval_response":
if tool_approvals is None:
tool_approvals = []
tool_approvals.append(ToolApproval(tool_call_id=call_id, approve=content.approved))
tool_approvals.append(ToolApproval(tool_call_id=call_id, approve=content.approved)) # type: ignore[arg-type]
return run_id, tool_outputs, tool_approvals
@@ -12,7 +12,6 @@ from agent_framework import (
ContextProvider,
HostedMCPTool,
Middleware,
TextContent,
ToolProtocol,
get_logger,
use_chat_middleware,
@@ -477,8 +476,8 @@ class AzureAIClient(OpenAIBaseResponsesClient[TAzureAIClientOptions], Generic[TA
# System/developer messages are turned into instructions, since there is no such message roles in Azure AI.
for message in messages:
if message.role.value in ["system", "developer"]:
for text_content in [content for content in message.contents if isinstance(content, TextContent)]:
instructions_list.append(text_content.text)
for text_content in [content for content in message.contents if content.type == "text"]:
instructions_list.append(text_content.text) # type: ignore[arg-type]
else:
result.append(message)
@@ -6,12 +6,10 @@ from typing import Any, ClassVar, Literal, cast
from agent_framework import (
AIFunction,
Contents,
Content,
HostedCodeInterpreterTool,
HostedFileContent,
HostedFileSearchTool,
HostedMCPTool,
HostedVectorStoreContent,
HostedWebSearchTool,
ToolProtocol,
get_logger,
@@ -189,9 +187,9 @@ def to_azure_ai_agent_tools(
)
tool_definitions.extend(mcp_tool.definitions)
case HostedFileSearchTool():
vector_stores = [inp for inp in tool.inputs or [] if isinstance(inp, HostedVectorStoreContent)]
vector_stores = [inp for inp in tool.inputs or [] if inp.type == "hosted_vector_store"]
if vector_stores:
file_search = AgentsFileSearchTool(vector_store_ids=[vs.vector_store_id for vs in vector_stores])
file_search = AgentsFileSearchTool(vector_store_ids=[vs.vector_store_id for vs in vector_stores]) # type: ignore[misc]
tool_definitions.extend(file_search.definitions)
# Set tool_resources for file search to work properly with Azure AI
if run_options is not None and "tool_resources" not in run_options:
@@ -247,7 +245,7 @@ def _convert_dict_tool(tool: dict[str, Any]) -> ToolProtocol | dict[str, Any] |
if tool_type == "file_search":
file_search_config = tool.get("file_search", {})
vector_store_ids = file_search_config.get("vector_store_ids", [])
inputs = [HostedVectorStoreContent(vector_store_id=vs_id) for vs_id in vector_store_ids]
inputs = [Content.from_hosted_vector_store(vector_store_id=vs_id) for vs_id in vector_store_ids]
return HostedFileSearchTool(inputs=inputs if inputs else None) # type: ignore
if tool_type == "bing_grounding":
@@ -287,7 +285,7 @@ def _convert_sdk_tool(tool: ToolDefinition) -> ToolProtocol | dict[str, Any] | N
if tool_type == "file_search":
file_search_config = getattr(tool, "file_search", None)
vector_store_ids = getattr(file_search_config, "vector_store_ids", []) if file_search_config else []
inputs = [HostedVectorStoreContent(vector_store_id=vs_id) for vs_id in vector_store_ids]
inputs = [Content.from_hosted_vector_store(vector_store_id=vs_id) for vs_id in vector_store_ids]
return HostedFileSearchTool(inputs=inputs if inputs else None) # type: ignore
if tool_type == "bing_grounding":
@@ -372,18 +370,18 @@ def from_azure_ai_tools(tools: Sequence[Tool | dict[str, Any]] | None) -> list[T
elif tool_type == "code_interpreter":
ci_tool = cast(CodeInterpreterTool, tool_dict)
container = ci_tool.get("container", {})
ci_inputs: list[Contents] = []
ci_inputs: list[Content] = []
if "file_ids" in container:
for file_id in container["file_ids"]:
ci_inputs.append(HostedFileContent(file_id=file_id))
ci_inputs.append(Content.from_hosted_file(file_id=file_id))
agent_tools.append(HostedCodeInterpreterTool(inputs=ci_inputs if ci_inputs else None)) # type: ignore
elif tool_type == "file_search":
fs_tool = cast(ProjectsFileSearchTool, tool_dict)
fs_inputs: list[Contents] = []
fs_inputs: list[Content] = []
if "vector_store_ids" in fs_tool:
for vs_id in fs_tool["vector_store_ids"]:
fs_inputs.append(HostedVectorStoreContent(vector_store_id=vs_id))
fs_inputs.append(Content.from_hosted_vector_store(vector_store_id=vs_id))
agent_tools.append(
HostedFileSearchTool(
@@ -433,8 +431,8 @@ def to_azure_ai_tools(
file_ids: list[str] = []
if tool.inputs:
for tool_input in tool.inputs:
if isinstance(tool_input, HostedFileContent):
file_ids.append(tool_input.file_id)
if tool_input.type == "hosted_file":
file_ids.append(tool_input.file_id) # type: ignore[misc, arg-type]
container = CodeInterpreterToolAuto(file_ids=file_ids if file_ids else None)
ci_tool: CodeInterpreterTool = CodeInterpreterTool(container=container)
azure_tools.append(ci_tool)
@@ -453,11 +451,14 @@ def to_azure_ai_tools(
if not tool.inputs:
raise ValueError("HostedFileSearchTool requires inputs to be specified.")
vector_store_ids: list[str] = [
inp.vector_store_id for inp in tool.inputs if isinstance(inp, HostedVectorStoreContent)
inp.vector_store_id # type: ignore[misc]
for inp in tool.inputs
if inp.type == "hosted_vector_store"
]
if not vector_store_ids:
raise ValueError(
"HostedFileSearchTool requires inputs to be of type `HostedVectorStoreContent`."
"HostedFileSearchTool requires inputs to be of type `Content` with "
"type 'hosted_vector_store'."
)
fs_tool: ProjectsFileSearchTool = ProjectsFileSearchTool(vector_store_ids=vector_store_ids)
if tool.max_results:
@@ -7,10 +7,10 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent_framework import (
ChatAgent,
Content,
HostedCodeInterpreterTool,
HostedFileSearchTool,
HostedMCPTool,
HostedVectorStoreContent,
HostedWebSearchTool,
ai_function,
)
@@ -509,7 +509,7 @@ def test_to_azure_ai_agent_tools_code_interpreter() -> None:
def test_to_azure_ai_agent_tools_file_search() -> None:
"""Test converting HostedFileSearchTool with vector stores."""
tool = HostedFileSearchTool(inputs=[HostedVectorStoreContent(vector_store_id="vs-123")])
tool = HostedFileSearchTool(inputs=[Content.from_hosted_vector_store(vector_store_id="vs-123")])
run_options: dict[str, Any] = {}
result = to_azure_ai_agent_tools([tool], run_options)
@@ -17,19 +17,12 @@ from agent_framework import (
ChatOptions,
ChatResponse,
ChatResponseUpdate,
CitationAnnotation,
FunctionApprovalRequestContent,
FunctionApprovalResponseContent,
FunctionCallContent,
FunctionResultContent,
Content,
HostedCodeInterpreterTool,
HostedFileContent,
HostedFileSearchTool,
HostedMCPTool,
HostedVectorStoreContent,
HostedWebSearchTool,
Role,
TextContent,
UriContent,
)
from agent_framework._serialization import SerializationMixin
from agent_framework.exceptions import ServiceInitializationError
@@ -368,7 +361,7 @@ async def test_azure_ai_chat_client_prepare_options_with_image_content(mock_agen
# Mock get_agent
mock_agents_client.get_agent = AsyncMock(return_value=None)
image_content = UriContent(uri="https://example.com/image.jpg", media_type="image/jpeg")
image_content = Content.from_uri(uri="https://example.com/image.jpg", media_type="image/jpeg")
messages = [ChatMessage(role=Role.USER, contents=[image_content])]
run_options, _ = await chat_client._prepare_options(messages, {}) # type: ignore
@@ -551,7 +544,7 @@ def test_azure_ai_chat_client_parse_function_calls_from_azure_ai_basic(mock_agen
result = chat_client._parse_function_calls_from_azure_ai(mock_event_data, "response_123") # type: ignore
assert len(result) == 1
assert isinstance(result[0], FunctionCallContent)
assert result[0].type == "function_call"
assert result[0].name == "get_weather"
assert result[0].call_id == '["response_123", "call_123"]'
@@ -728,6 +721,121 @@ async def test_azure_ai_chat_client_prepare_options_mcp_with_headers(mock_agents
assert mcp_resource["headers"] == headers
async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_web_search_bing_grounding(
mock_agents_client: MagicMock,
) -> None:
"""Test _prepare_tools_for_azure_ai with HostedWebSearchTool using Bing Grounding."""
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
web_search_tool = HostedWebSearchTool(
additional_properties={
"connection_id": "test-connection-id",
"count": 5,
"freshness": "Day",
"market": "en-US",
"set_lang": "en",
}
)
# Mock BingGroundingTool
with patch("agent_framework_azure_ai._chat_client.BingGroundingTool") as mock_bing_grounding:
mock_bing_tool = MagicMock()
mock_bing_tool.definitions = [{"type": "bing_grounding"}]
mock_bing_grounding.return_value = mock_bing_tool
result = await chat_client._prepare_tools_for_azure_ai([web_search_tool]) # type: ignore
assert len(result) == 1
assert result[0] == {"type": "bing_grounding"}
call_args = mock_bing_grounding.call_args[1]
assert call_args["count"] == 5
assert call_args["freshness"] == "Day"
assert call_args["market"] == "en-US"
assert call_args["set_lang"] == "en"
assert "connection_id" in call_args
async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_web_search_bing_grounding_with_connection_id(
mock_agents_client: MagicMock,
) -> None:
"""Test _prepare_tools_... with HostedWebSearchTool using Bing Grounding with connection_id (no HTTP call)."""
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
web_search_tool = HostedWebSearchTool(
additional_properties={
"connection_id": "direct-connection-id",
"count": 3,
}
)
# Mock BingGroundingTool
with patch("agent_framework_azure_ai._chat_client.BingGroundingTool") as mock_bing_grounding:
mock_bing_tool = MagicMock()
mock_bing_tool.definitions = [{"type": "bing_grounding"}]
mock_bing_grounding.return_value = mock_bing_tool
result = await chat_client._prepare_tools_for_azure_ai([web_search_tool]) # type: ignore
assert len(result) == 1
assert result[0] == {"type": "bing_grounding"}
mock_bing_grounding.assert_called_once_with(connection_id="direct-connection-id", count=3)
async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_web_search_custom_bing(
mock_agents_client: MagicMock,
) -> None:
"""Test _prepare_tools_for_azure_ai with HostedWebSearchTool using Custom Bing Search."""
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
web_search_tool = HostedWebSearchTool(
additional_properties={
"custom_connection_id": "custom-connection-id",
"custom_instance_name": "custom-instance",
"count": 10,
}
)
# Mock BingCustomSearchTool
with patch("agent_framework_azure_ai._chat_client.BingCustomSearchTool") as mock_custom_bing:
mock_custom_tool = MagicMock()
mock_custom_tool.definitions = [{"type": "bing_custom_search"}]
mock_custom_bing.return_value = mock_custom_tool
result = await chat_client._prepare_tools_for_azure_ai([web_search_tool]) # type: ignore
assert len(result) == 1
assert result[0] == {"type": "bing_custom_search"}
async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_file_search_with_vector_stores(
mock_agents_client: MagicMock,
) -> None:
"""Test _prepare_tools_for_azure_ai with HostedFileSearchTool using vector stores."""
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
vector_store_input = Content.from_hosted_vector_store(vector_store_id="vs-123")
file_search_tool = HostedFileSearchTool(inputs=[vector_store_input])
# Mock FileSearchTool
with patch("agent_framework_azure_ai._chat_client.FileSearchTool") as mock_file_search:
mock_file_tool = MagicMock()
mock_file_tool.definitions = [{"type": "file_search"}]
mock_file_tool.resources = {"vector_store_ids": ["vs-123"]}
mock_file_search.return_value = mock_file_tool
run_options = {}
result = await chat_client._prepare_tools_for_azure_ai([file_search_tool], run_options) # type: ignore
assert len(result) == 1
assert result[0] == {"type": "file_search"}
assert run_options["tool_resources"] == {"vector_store_ids": ["vs-123"]}
mock_file_search.assert_called_once_with(vector_store_ids=["vs-123"])
async def test_azure_ai_chat_client_create_agent_stream_submit_tool_approvals(
mock_agents_client: MagicMock,
) -> None:
@@ -741,9 +849,9 @@ async def test_azure_ai_chat_client_create_agent_stream_submit_tool_approvals(
chat_client._get_active_thread_run = AsyncMock(return_value=mock_thread_run) # type: ignore
# Mock required action results with approval response that matches run ID
approval_response = FunctionApprovalResponseContent(
approval_response = Content.from_function_approval_response(
id='["test-run-id", "test-call-id"]',
function_call=FunctionCallContent(
function_call=Content.from_function_call(
call_id='["test-run-id", "test-call-id"]', name="test_function", arguments="{}"
),
approved=True,
@@ -839,7 +947,7 @@ async def test_azure_ai_chat_client_prepare_tool_outputs_for_azure_ai_function_r
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
# Test with simple result
function_result = FunctionResultContent(call_id='["run_123", "call_456"]', result="Simple result")
function_result = Content.from_function_result(call_id='["run_123", "call_456"]', result="Simple result")
run_id, tool_outputs, tool_approvals = chat_client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore
@@ -857,7 +965,7 @@ async def test_azure_ai_chat_client_convert_required_action_invalid_call_id(mock
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
# Invalid call_id format - should raise JSONDecodeError
function_result = FunctionResultContent(call_id="invalid_json", result="result")
function_result = Content.from_function_result(call_id="invalid_json", result="result")
with pytest.raises(json.JSONDecodeError):
chat_client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore
@@ -870,7 +978,7 @@ async def test_azure_ai_chat_client_convert_required_action_invalid_structure(
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
# Valid JSON but invalid structure (missing second element)
function_result = FunctionResultContent(call_id='["run_123"]', result="result")
function_result = Content.from_function_result(call_id='["run_123"]', result="result")
run_id, tool_outputs, tool_approvals = chat_client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore
@@ -894,7 +1002,7 @@ async def test_azure_ai_chat_client_convert_required_action_serde_model_results(
# Test with BaseModel result
mock_result = MockResult(name="test", value=42)
function_result = FunctionResultContent(call_id='["run_123", "call_456"]', result=mock_result)
function_result = Content.from_function_result(call_id='["run_123", "call_456"]', result=mock_result)
run_id, tool_outputs, tool_approvals = chat_client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore
@@ -922,7 +1030,7 @@ async def test_azure_ai_chat_client_convert_required_action_multiple_results(
# Test with multiple results - mix of BaseModel and regular objects
mock_basemodel = MockResult(data="model_data")
results_list = [mock_basemodel, {"key": "value"}, "string_result"]
function_result = FunctionResultContent(call_id='["run_123", "call_456"]', result=results_list)
function_result = Content.from_function_result(call_id='["run_123", "call_456"]', result=results_list)
run_id, tool_outputs, tool_approvals = chat_client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore
@@ -948,9 +1056,11 @@ async def test_azure_ai_chat_client_convert_required_action_approval_response(
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
# Test with approval response - need to provide required fields
approval_response = FunctionApprovalResponseContent(
approval_response = Content.from_function_approval_response(
id='["run_123", "call_456"]',
function_call=FunctionCallContent(call_id='["run_123", "call_456"]', name="test_function", arguments="{}"),
function_call=Content.from_function_call(
call_id='["run_123", "call_456"]', name="test_function", arguments="{}"
),
approved=True,
)
@@ -985,7 +1095,7 @@ async def test_azure_ai_chat_client_parse_function_calls_from_azure_ai_approval_
result = chat_client._parse_function_calls_from_azure_ai(mock_event_data, "response_123") # type: ignore
assert len(result) == 1
assert isinstance(result[0], FunctionApprovalRequestContent)
assert result[0].type == "function_approval_request"
assert result[0].id == '["response_123", "approval_call_123"]'
assert result[0].function_call.name == "approve_action"
assert result[0].function_call.call_id == '["response_123", "approval_call_123"]'
@@ -1064,7 +1174,7 @@ async def test_azure_ai_chat_client_create_agent_stream_submit_tool_outputs(
chat_client._get_active_thread_run = AsyncMock(return_value=mock_thread_run) # type: ignore
# Mock required action results with matching run ID
function_result = FunctionResultContent(call_id='["test-run-id", "test-call-id"]', result="test result")
function_result = Content.from_function_result(call_id='["test-run-id", "test-call-id"]', result="test result")
# Mock submit_tool_outputs_stream
mock_handler = MagicMock()
@@ -1115,14 +1225,13 @@ def test_azure_ai_chat_client_extract_url_citations_with_citations(mock_agents_c
# Verify results
assert len(citations) == 1
citation = citations[0]
assert isinstance(citation, CitationAnnotation)
assert citation.url == "https://example.com/test"
assert citation.title == "Test Title"
assert citation.snippet is None
assert citation.annotated_regions is not None
assert len(citation.annotated_regions) == 1
assert citation.annotated_regions[0].start_index == 10
assert citation.annotated_regions[0].end_index == 20
assert citation["url"] == "https://example.com/test"
assert citation["title"] == "Test Title"
assert citation["snippet"] is None
assert citation["annotated_regions"] is not None
assert len(citation["annotated_regions"]) == 1
assert citation["annotated_regions"][0]["start_index"] == 10
assert citation["annotated_regions"][0]["end_index"] == 20
def test_azure_ai_chat_client_extract_file_path_contents_with_file_path_annotation(
@@ -1158,7 +1267,7 @@ def test_azure_ai_chat_client_extract_file_path_contents_with_file_path_annotati
# Verify results
assert len(file_contents) == 1
assert isinstance(file_contents[0], HostedFileContent)
assert file_contents[0].type == "hosted_file"
assert file_contents[0].file_id == "assistant-test-file-123"
@@ -1195,7 +1304,7 @@ def test_azure_ai_chat_client_extract_file_path_contents_with_file_citation_anno
# Verify results
assert len(file_contents) == 1
assert isinstance(file_contents[0], HostedFileContent)
assert file_contents[0].type == "hosted_file"
assert file_contents[0].file_id == "cfile_test-citation-456"
@@ -1305,7 +1414,7 @@ async def test_azure_ai_chat_client_streaming() -> None:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
if content.type == "text" and content.text:
full_message += content.text
assert any(word in full_message.lower() for word in ["sunny", "25"])
@@ -1331,7 +1440,7 @@ async def test_azure_ai_chat_client_streaming_tools() -> None:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
if content.type == "text" and content.text:
full_message += content.text
assert any(word in full_message.lower() for word in ["sunny", "25"])
@@ -1476,7 +1585,9 @@ async def test_azure_ai_chat_client_agent_file_search():
)
# 2. Create file search tool with uploaded resources
file_search_tool = HostedFileSearchTool(inputs=[HostedVectorStoreContent(vector_store_id=vector_store.id)])
file_search_tool = HostedFileSearchTool(
inputs=[Content.from_hosted_vector_store(vector_store_id=vector_store.id)]
)
async with ChatAgent(
chat_client=client,
@@ -1795,7 +1906,7 @@ def test_azure_ai_chat_client_extract_url_citations_with_azure_search_enhanced_u
# Verify real URL was used
assert len(citations) == 1
citation = citations[0]
assert citation.url == "https://real-example.com/doc2" # doc_1 maps to index 1
assert citation["url"] == "https://real-example.com/doc2" # doc_1 maps to index 1
def test_azure_ai_chat_client_init_with_auto_created_agents_client(
@@ -16,14 +16,12 @@ from agent_framework import (
ChatMessage,
ChatOptions,
ChatResponse,
Content,
HostedCodeInterpreterTool,
HostedFileContent,
HostedFileSearchTool,
HostedMCPTool,
HostedVectorStoreContent,
HostedWebSearchTool,
Role,
TextContent,
)
from agent_framework.exceptions import ServiceInitializationError
from azure.ai.projects.aio import AIProjectClient
@@ -298,9 +296,9 @@ async def test_prepare_messages_for_azure_ai_with_system_messages(
client = create_test_azure_ai_client(mock_project_client)
messages = [
ChatMessage(role=Role.SYSTEM, contents=[TextContent(text="You are a helpful assistant.")]),
ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")]),
ChatMessage(role=Role.ASSISTANT, contents=[TextContent(text="System response")]),
ChatMessage(role=Role.SYSTEM, contents=[Content.from_text(text="You are a helpful assistant.")]),
ChatMessage(role=Role.USER, contents=[Content.from_text(text="Hello")]),
ChatMessage(role=Role.ASSISTANT, contents=[Content.from_text(text="System response")]),
]
result_messages, instructions = client._prepare_messages_for_azure_ai(messages) # type: ignore
@@ -318,8 +316,8 @@ async def test_prepare_messages_for_azure_ai_no_system_messages(
client = create_test_azure_ai_client(mock_project_client)
messages = [
ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")]),
ChatMessage(role=Role.ASSISTANT, contents=[TextContent(text="Hi there!")]),
ChatMessage(role=Role.USER, contents=[Content.from_text(text="Hello")]),
ChatMessage(role=Role.ASSISTANT, contents=[Content.from_text(text="Hi there!")]),
]
result_messages, instructions = client._prepare_messages_for_azure_ai(messages) # type: ignore
@@ -419,7 +417,7 @@ async def test_prepare_options_basic(mock_project_client: MagicMock) -> None:
"""Test prepare_options basic functionality."""
client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0")
messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])]
messages = [ChatMessage(role=Role.USER, contents=[Content.from_text(text="Hello")])]
with (
patch.object(client.__class__.__bases__[0], "_prepare_options", return_value={"model": "test-model"}),
@@ -453,7 +451,7 @@ async def test_prepare_options_with_application_endpoint(
agent_version="1",
)
messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])]
messages = [ChatMessage(role=Role.USER, contents=[Content.from_text(text="Hello")])]
with (
patch.object(client.__class__.__bases__[0], "_prepare_options", return_value={"model": "test-model"}),
@@ -492,7 +490,7 @@ async def test_prepare_options_with_application_project_client(
agent_version="1",
)
messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])]
messages = [ChatMessage(role=Role.USER, contents=[Content.from_text(text="Hello")])]
with (
patch.object(client.__class__.__bases__[0], "_prepare_options", return_value={"model": "test-model"}),
@@ -848,7 +846,7 @@ async def test_prepare_options_excludes_response_format(
"""Test that prepare_options excludes response_format, text, and text_format from final run options."""
client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0")
messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])]
messages = [ChatMessage(role=Role.USER, contents=[Content.from_text(text="Hello")])]
chat_options: ChatOptions = {}
with (
@@ -992,7 +990,7 @@ def test_from_azure_ai_tools() -> None:
tool_input = parsed_tools[0].inputs[0]
assert tool_input and isinstance(tool_input, HostedFileContent) and tool_input.file_id == "file-1"
assert tool_input and tool_input.type == "hosted_file" and tool_input.file_id == "file-1"
# Test File Search tool
fs_tool = FileSearchTool(vector_store_ids=["vs-1"], max_num_results=5)
@@ -1004,7 +1002,7 @@ def test_from_azure_ai_tools() -> None:
tool_input = parsed_tools[0].inputs[0]
assert tool_input and isinstance(tool_input, HostedVectorStoreContent) and tool_input.vector_store_id == "vs-1"
assert tool_input and tool_input.type == "hosted_vector_store" and tool_input.vector_store_id == "vs-1"
assert parsed_tools[0].max_results == 5
# Test Web Search tool
@@ -36,18 +36,8 @@ from typing import Any, cast
from agent_framework import (
AgentResponse,
BaseContent,
ChatMessage,
DataContent,
ErrorContent,
FunctionCallContent,
FunctionResultContent,
HostedFileContent,
HostedVectorStoreContent,
TextContent,
TextReasoningContent,
UriContent,
UsageContent,
Content,
UsageDetails,
get_logger,
)
@@ -290,25 +280,25 @@ class DurableAgentStateContent:
The corresponding DurableAgentStateContent subclass instance
"""
# Map AI content type to appropriate DurableAgentStateContent subclass
if isinstance(content, DataContent):
if isinstance(content, Content) and content.type == "data":
return DurableAgentStateDataContent.from_data_content(content)
if isinstance(content, ErrorContent):
if isinstance(content, Content) and content.type == "error":
return DurableAgentStateErrorContent.from_error_content(content)
if isinstance(content, FunctionCallContent):
if isinstance(content, Content) and content.type == "function_call":
return DurableAgentStateFunctionCallContent.from_function_call_content(content)
if isinstance(content, FunctionResultContent):
if isinstance(content, Content) and content.type == "function_result":
return DurableAgentStateFunctionResultContent.from_function_result_content(content)
if isinstance(content, HostedFileContent):
if isinstance(content, Content) and content.type == "hosted_file":
return DurableAgentStateHostedFileContent.from_hosted_file_content(content)
if isinstance(content, HostedVectorStoreContent):
if isinstance(content, Content) and content.type == "hosted_vector_store":
return DurableAgentStateHostedVectorStoreContent.from_hosted_vector_store_content(content)
if isinstance(content, TextContent):
if isinstance(content, Content) and content.type == "text":
return DurableAgentStateTextContent.from_text_content(content)
if isinstance(content, TextReasoningContent):
if isinstance(content, Content) and content.type == "text_reasoning":
return DurableAgentStateTextReasoningContent.from_text_reasoning_content(content)
if isinstance(content, UriContent):
if isinstance(content, Content) and content.type == "uri":
return DurableAgentStateUriContent.from_uri_content(content)
if isinstance(content, UsageContent):
if isinstance(content, Content) and content.type == "usage":
return DurableAgentStateUsageContent.from_usage_content(content)
return DurableAgentStateUnknownContent.from_unknown_content(content)
@@ -699,7 +689,7 @@ class DurableAgentStateResponse(DurableAgentStateEntry):
correlation_id=correlation_id,
created_at=_parse_created_at(response.created_at),
messages=[DurableAgentStateMessage.from_chat_message(m) for m in response.messages],
usage=DurableAgentStateUsage.from_usage(response.usage_details),
usage=DurableAgentStateUsage.from_usage(response.usage_details), # type: ignore[arg-type]
)
@@ -868,11 +858,11 @@ class DurableAgentStateDataContent(DurableAgentStateContent):
}
@staticmethod
def from_data_content(content: DataContent) -> DurableAgentStateDataContent:
return DurableAgentStateDataContent(uri=content.uri, media_type=content.media_type)
def from_data_content(content: Content) -> DurableAgentStateDataContent:
return DurableAgentStateDataContent(uri=content.uri, media_type=content.media_type) # type: ignore[arg-type]
def to_ai_content(self) -> DataContent:
return DataContent(uri=self.uri, media_type=self.media_type)
def to_ai_content(self) -> Content:
return Content.from_uri(uri=self.uri, media_type=self.media_type)
class DurableAgentStateErrorContent(DurableAgentStateContent):
@@ -907,13 +897,13 @@ class DurableAgentStateErrorContent(DurableAgentStateContent):
}
@staticmethod
def from_error_content(content: ErrorContent) -> DurableAgentStateErrorContent:
def from_error_content(content: Content) -> DurableAgentStateErrorContent:
return DurableAgentStateErrorContent(
message=content.message, error_code=content.error_code, details=content.details
message=content.message, error_code=content.error_code, details=content.error_details
)
def to_ai_content(self) -> ErrorContent:
return ErrorContent(message=self.message, error_code=self.error_code, details=self.details)
def to_ai_content(self) -> Content:
return Content.from_error(message=self.message, error_code=self.error_code, error_details=self.details)
class DurableAgentStateFunctionCallContent(DurableAgentStateContent):
@@ -949,7 +939,7 @@ class DurableAgentStateFunctionCallContent(DurableAgentStateContent):
}
@staticmethod
def from_function_call_content(content: FunctionCallContent) -> DurableAgentStateFunctionCallContent:
def from_function_call_content(content: Content) -> DurableAgentStateFunctionCallContent:
# Ensure arguments is a dict; parse string if needed
arguments: dict[str, Any] = {}
if content.arguments:
@@ -962,10 +952,10 @@ class DurableAgentStateFunctionCallContent(DurableAgentStateContent):
except json.JSONDecodeError:
arguments = {}
return DurableAgentStateFunctionCallContent(call_id=content.call_id, name=content.name, arguments=arguments)
return DurableAgentStateFunctionCallContent(call_id=content.call_id, name=content.name, arguments=arguments) # type: ignore[arg-type]
def to_ai_content(self) -> FunctionCallContent:
return FunctionCallContent(call_id=self.call_id, name=self.name, arguments=self.arguments)
def to_ai_content(self) -> Content:
return Content.from_function_call(call_id=self.call_id, name=self.name, arguments=self.arguments)
class DurableAgentStateFunctionResultContent(DurableAgentStateContent):
@@ -997,11 +987,11 @@ class DurableAgentStateFunctionResultContent(DurableAgentStateContent):
}
@staticmethod
def from_function_result_content(content: FunctionResultContent) -> DurableAgentStateFunctionResultContent:
return DurableAgentStateFunctionResultContent(call_id=content.call_id, result=content.result)
def from_function_result_content(content: Content) -> DurableAgentStateFunctionResultContent:
return DurableAgentStateFunctionResultContent(call_id=content.call_id, result=content.result) # type: ignore[arg-type]
def to_ai_content(self) -> FunctionResultContent:
return FunctionResultContent(call_id=self.call_id, result=self.result)
def to_ai_content(self) -> Content:
return Content.from_function_result(call_id=self.call_id, result=self.result)
class DurableAgentStateHostedFileContent(DurableAgentStateContent):
@@ -1025,11 +1015,11 @@ class DurableAgentStateHostedFileContent(DurableAgentStateContent):
return {DurableStateFields.TYPE_DISCRIMINATOR: self.type, DurableStateFields.FILE_ID: self.file_id}
@staticmethod
def from_hosted_file_content(content: HostedFileContent) -> DurableAgentStateHostedFileContent:
return DurableAgentStateHostedFileContent(file_id=content.file_id)
def from_hosted_file_content(content: Content) -> DurableAgentStateHostedFileContent:
return DurableAgentStateHostedFileContent(file_id=content.file_id) # type: ignore[arg-type]
def to_ai_content(self) -> HostedFileContent:
return HostedFileContent(file_id=self.file_id)
def to_ai_content(self) -> Content:
return Content.from_hosted_file(file_id=self.file_id)
class DurableAgentStateHostedVectorStoreContent(DurableAgentStateContent):
@@ -1058,12 +1048,12 @@ class DurableAgentStateHostedVectorStoreContent(DurableAgentStateContent):
@staticmethod
def from_hosted_vector_store_content(
content: HostedVectorStoreContent,
content: Content,
) -> DurableAgentStateHostedVectorStoreContent:
return DurableAgentStateHostedVectorStoreContent(vector_store_id=content.vector_store_id)
return DurableAgentStateHostedVectorStoreContent(vector_store_id=content.vector_store_id) # type: ignore[arg-type]
def to_ai_content(self) -> HostedVectorStoreContent:
return HostedVectorStoreContent(vector_store_id=self.vector_store_id)
def to_ai_content(self) -> Content:
return Content.from_hosted_vector_store(vector_store_id=self.vector_store_id)
class DurableAgentStateTextContent(DurableAgentStateContent):
@@ -1085,11 +1075,11 @@ class DurableAgentStateTextContent(DurableAgentStateContent):
return {DurableStateFields.TYPE_DISCRIMINATOR: self.type, DurableStateFields.TEXT: self.text}
@staticmethod
def from_text_content(content: TextContent) -> DurableAgentStateTextContent:
def from_text_content(content: Content) -> DurableAgentStateTextContent:
return DurableAgentStateTextContent(text=content.text)
def to_ai_content(self) -> TextContent:
return TextContent(text=self.text or "")
def to_ai_content(self) -> Content:
return Content.from_text(text=self.text or "")
class DurableAgentStateTextReasoningContent(DurableAgentStateContent):
@@ -1111,11 +1101,11 @@ class DurableAgentStateTextReasoningContent(DurableAgentStateContent):
return {DurableStateFields.TYPE_DISCRIMINATOR: self.type, DurableStateFields.TEXT: self.text}
@staticmethod
def from_text_reasoning_content(content: TextReasoningContent) -> DurableAgentStateTextReasoningContent:
def from_text_reasoning_content(content: Content) -> DurableAgentStateTextReasoningContent:
return DurableAgentStateTextReasoningContent(text=content.text)
def to_ai_content(self) -> TextReasoningContent:
return TextReasoningContent(text=self.text or "")
def to_ai_content(self) -> Content:
return Content.from_text_reasoning(text=self.text)
class DurableAgentStateUriContent(DurableAgentStateContent):
@@ -1146,11 +1136,11 @@ class DurableAgentStateUriContent(DurableAgentStateContent):
}
@staticmethod
def from_uri_content(content: UriContent) -> DurableAgentStateUriContent:
return DurableAgentStateUriContent(uri=content.uri, media_type=content.media_type)
def from_uri_content(content: Content) -> DurableAgentStateUriContent:
return DurableAgentStateUriContent(uri=content.uri, media_type=content.media_type) # type: ignore[arg-type]
def to_ai_content(self) -> UriContent:
return UriContent(uri=self.uri, media_type=self.media_type)
def to_ai_content(self) -> Content:
return Content.from_uri(uri=self.uri, media_type=self.media_type)
class DurableAgentStateUsage:
@@ -1204,22 +1194,22 @@ class DurableAgentStateUsage:
)
@staticmethod
def from_usage(usage: UsageDetails | None) -> DurableAgentStateUsage | None:
def from_usage(usage: UsageDetails | dict[str, int] | None) -> DurableAgentStateUsage | None:
if usage is None:
return None
return DurableAgentStateUsage(
input_token_count=usage.input_token_count,
output_token_count=usage.output_token_count,
total_token_count=usage.total_token_count,
input_token_count=usage.get("input_token_count"),
output_token_count=usage.get("output_token_count"),
total_token_count=usage.get("total_token_count"),
)
def to_usage_details(self) -> UsageDetails:
# Convert back to AI SDK UsageDetails
return UsageDetails(
input_token_count=self.input_token_count,
output_token_count=self.output_token_count,
total_token_count=self.total_token_count,
)
return {
"input_token_count": self.input_token_count,
"output_token_count": self.output_token_count,
"total_token_count": self.total_token_count,
}
class DurableAgentStateUsageContent(DurableAgentStateContent):
@@ -1247,11 +1237,11 @@ class DurableAgentStateUsageContent(DurableAgentStateContent):
}
@staticmethod
def from_usage_content(content: UsageContent) -> DurableAgentStateUsageContent:
return DurableAgentStateUsageContent(usage=DurableAgentStateUsage.from_usage(content.details))
def from_usage_content(content: Content) -> DurableAgentStateUsageContent:
return DurableAgentStateUsageContent(usage=DurableAgentStateUsage.from_usage(content.usage_details))
def to_ai_content(self) -> UsageContent:
return UsageContent(details=self.usage.to_usage_details())
def to_ai_content(self) -> Content:
return Content.from_usage(usage_details=self.usage.to_usage_details())
class DurableAgentStateUnknownContent(DurableAgentStateContent):
@@ -1279,7 +1269,7 @@ class DurableAgentStateUnknownContent(DurableAgentStateContent):
def from_unknown_content(content: Any) -> DurableAgentStateUnknownContent:
return DurableAgentStateUnknownContent(content=content)
def to_ai_content(self) -> BaseContent:
def to_ai_content(self) -> Content:
if not self.content:
raise Exception("The content is missing and cannot be converted to valid AI content.")
return BaseContent(content=self.content)
return Content(type=self.type, additional_properties={"content": self.content}) # type: ignore
@@ -18,7 +18,7 @@ from agent_framework import (
AgentResponse,
AgentResponseUpdate,
ChatMessage,
ErrorContent,
Content,
Role,
get_logger,
)
@@ -193,7 +193,7 @@ class AgentEntity:
# Create error message
error_message = ChatMessage(
role=Role.ASSISTANT, contents=[ErrorContent(message=str(exc), error_code=type(exc).__name__)]
role=Role.ASSISTANT, contents=[Content.from_error(message=str(exc), error_code=type(exc).__name__)]
)
error_response = AgentResponse(messages=[error_message])
@@ -10,7 +10,7 @@ from unittest.mock import ANY, AsyncMock, Mock, patch
import azure.durable_functions as df
import azure.functions as func
import pytest
from agent_framework import AgentResponse, ChatMessage, ErrorContent
from agent_framework import AgentResponse, ChatMessage
from agent_framework_azurefunctions import AgentFunctionApp
from agent_framework_azurefunctions._app import WAIT_FOR_RESPONSE_FIELD, WAIT_FOR_RESPONSE_HEADER
@@ -622,7 +622,7 @@ class TestErrorHandling:
assert isinstance(result, AgentResponse)
assert len(result.messages) == 1
content = result.messages[0].contents[0]
assert isinstance(content, ErrorContent)
assert content.type == "error"
assert "Agent error" in (content.message or "")
assert content.error_code == "Exception"
@@ -12,7 +12,7 @@ from typing import Any, TypeVar
from unittest.mock import AsyncMock, Mock, patch
import pytest
from agent_framework import AgentResponse, AgentResponseUpdate, ChatMessage, ErrorContent, Role
from agent_framework import AgentResponse, AgentResponseUpdate, ChatMessage, Role
from pydantic import BaseModel
from agent_framework_azurefunctions._durable_agent_state import (
@@ -608,7 +608,7 @@ class TestErrorHandling:
assert isinstance(result, AgentResponse)
assert len(result.messages) == 1
content = result.messages[0].contents[0]
assert isinstance(content, ErrorContent)
assert content.type == "error"
assert "Agent failed" in (content.message or "")
assert content.error_code == "Exception"
@@ -627,7 +627,7 @@ class TestErrorHandling:
assert isinstance(result, AgentResponse)
assert len(result.messages) == 1
content = result.messages[0].contents[0]
assert isinstance(content, ErrorContent)
assert content.type == "error"
assert content.error_code == "ValueError"
assert "Invalid input" in str(content.message)
@@ -646,7 +646,7 @@ class TestErrorHandling:
assert isinstance(result, AgentResponse)
assert len(result.messages) == 1
content = result.messages[0].contents[0]
assert isinstance(content, ErrorContent)
assert content.type == "error"
assert content.error_code == "TimeoutError"
def test_entity_function_handles_exception_in_operation(self) -> None:
@@ -685,7 +685,7 @@ class TestErrorHandling:
assert isinstance(result, AgentResponse)
assert len(result.messages) == 1
content = result.messages[0].contents[0]
assert isinstance(content, ErrorContent)
assert content.type == "error"
class TestConversationHistory:
@@ -16,14 +16,10 @@ from agent_framework import (
ChatOptions,
ChatResponse,
ChatResponseUpdate,
Contents,
Content,
FinishReason,
FunctionCallContent,
FunctionResultContent,
Role,
TextContent,
ToolProtocol,
UsageContent,
UsageDetails,
get_logger,
prepare_function_call_results,
@@ -328,7 +324,7 @@ class BedrockChatClient(BaseChatClient[TBedrockChatOptions], Generic[TBedrockCha
response = await self._inner_get_response(messages=messages, options=options, **kwargs)
contents = list(response.messages[0].contents if response.messages else [])
if response.usage_details:
contents.append(UsageContent(details=response.usage_details))
contents.append(Content.from_usage(usage_details=response.usage_details)) # type: ignore[arg-type]
yield ChatResponseUpdate(
response_id=response.response_id,
contents=contents,
@@ -472,37 +468,41 @@ class BedrockChatClient(BaseChatClient[TBedrockChatOptions], Generic[TBedrockCha
blocks.append(block)
return blocks
def _convert_content_to_bedrock_block(self, content: Contents) -> dict[str, Any] | None:
if isinstance(content, TextContent):
return {"text": content.text}
if isinstance(content, FunctionCallContent):
arguments = content.parse_arguments() or {}
return {
"toolUse": {
"toolUseId": content.call_id or self._generate_tool_call_id(),
"name": content.name,
"input": arguments,
def _convert_content_to_bedrock_block(self, content: Content) -> dict[str, Any] | None:
match content.type:
case "text":
return {"text": content.text}
case "function_call":
arguments = content.parse_arguments() or {}
return {
"toolUse": {
"toolUseId": content.call_id or self._generate_tool_call_id(),
"name": content.name,
"input": arguments,
}
}
}
if isinstance(content, FunctionResultContent):
tool_result_block = {
"toolResult": {
"toolUseId": content.call_id,
"content": self._convert_tool_result_to_blocks(content.result),
"status": "error" if content.exception else "success",
case "function_result":
tool_result_block = {
"toolResult": {
"toolUseId": content.call_id,
"content": self._convert_tool_result_to_blocks(content.result),
"status": "error" if content.exception else "success",
}
}
}
if content.exception:
tool_result = tool_result_block["toolResult"]
existing_content = tool_result.get("content")
content_list: list[dict[str, Any]]
if isinstance(existing_content, list):
content_list = existing_content
else:
content_list = []
tool_result["content"] = content_list
content_list.append({"text": str(content.exception)})
return tool_result_block
if content.exception:
tool_result = tool_result_block["toolResult"]
existing_content = tool_result.get("content")
content_list: list[dict[str, Any]]
if isinstance(existing_content, list):
content_list = existing_content
else:
content_list = []
tool_result["content"] = content_list
content_list.append({"text": str(content.exception)})
return tool_result_block
case _:
# Bedrock does not support other content types at this time
pass
return None
def _convert_tool_result_to_blocks(self, result: Any) -> list[dict[str, Any]]:
@@ -531,7 +531,7 @@ class BedrockChatClient(BaseChatClient[TBedrockChatOptions], Generic[TBedrockCha
return {"text": value}
if isinstance(value, (int, float, bool)) or value is None:
return {"json": value}
if isinstance(value, TextContent) and getattr(value, "text", None):
if isinstance(value, Content) and value.type == "text":
return {"text": value.text}
if hasattr(value, "to_dict"):
try:
@@ -586,23 +586,23 @@ class BedrockChatClient(BaseChatClient[TBedrockChatOptions], Generic[TBedrockCha
def _parse_usage(self, usage: dict[str, Any] | None) -> UsageDetails | None:
if not usage:
return None
details = UsageDetails()
details: UsageDetails = {}
if (input_tokens := usage.get("inputTokens")) is not None:
details.input_token_count = input_tokens
details["input_token_count"] = input_tokens
if (output_tokens := usage.get("outputTokens")) is not None:
details.output_token_count = output_tokens
details["output_token_count"] = output_tokens
if (total_tokens := usage.get("totalTokens")) is not None:
details.additional_counts["bedrock.total_tokens"] = total_tokens
details["total_token_count"] = total_tokens
return details
def _parse_message_contents(self, content_blocks: Sequence[MutableMapping[str, Any]]) -> list[Any]:
contents: list[Any] = []
for block in content_blocks:
if text_value := block.get("text"):
contents.append(TextContent(text=text_value, raw_representation=block))
contents.append(Content.from_text(text=text_value, raw_representation=block))
continue
if (json_value := block.get("json")) is not None:
contents.append(TextContent(text=json.dumps(json_value), raw_representation=block))
contents.append(Content.from_text(text=json.dumps(json_value), raw_representation=block))
continue
tool_use = block.get("toolUse")
if isinstance(tool_use, MutableMapping):
@@ -610,7 +610,7 @@ class BedrockChatClient(BaseChatClient[TBedrockChatOptions], Generic[TBedrockCha
if not tool_name:
raise ServiceInvalidResponseError("Bedrock response missing required tool name in toolUse block.")
contents.append(
FunctionCallContent(
Content.from_function_call(
call_id=tool_use.get("toolUseId") or self._generate_tool_call_id(),
name=tool_name,
arguments=tool_use.get("input"),
@@ -626,10 +626,10 @@ class BedrockChatClient(BaseChatClient[TBedrockChatOptions], Generic[TBedrockCha
exception = RuntimeError(f"Bedrock tool result status: {status}")
result_value = self._convert_bedrock_tool_result_to_value(tool_result.get("content"))
contents.append(
FunctionResultContent(
Content.from_function_result(
call_id=tool_result.get("toolUseId") or self._generate_tool_call_id(),
result=result_value,
exception=exception,
exception=str(exception) if exception else None, # type: ignore[arg-type]
raw_representation=block,
)
)
@@ -6,7 +6,7 @@ import asyncio
from typing import Any
import pytest
from agent_framework import ChatMessage, Role, TextContent
from agent_framework import ChatMessage, Content, Role
from agent_framework.exceptions import ServiceInitializationError
from agent_framework_bedrock import BedrockChatClient
@@ -42,8 +42,8 @@ def test_get_response_invokes_bedrock_runtime() -> None:
)
messages = [
ChatMessage(role=Role.SYSTEM, contents=[TextContent(text="You are concise.")]),
ChatMessage(role=Role.USER, contents=[TextContent(text="hello")]),
ChatMessage(role=Role.SYSTEM, contents=[Content.from_text(text="You are concise.")]),
ChatMessage(role=Role.USER, contents=[Content.from_text(text="hello")]),
]
response = asyncio.run(client.get_response(messages=messages, options={"max_tokens": 32}))
@@ -53,7 +53,7 @@ def test_get_response_invokes_bedrock_runtime() -> None:
assert payload["modelId"] == "amazon.titan-text"
assert payload["messages"][0]["content"][0]["text"] == "hello"
assert response.messages[0].contents[0].text == "Bedrock says hi"
assert response.usage_details and response.usage_details.input_token_count == 10
assert response.usage_details and response.usage_details["input_token_count"] == 10
def test_build_request_requires_non_system_messages() -> None:
@@ -63,7 +63,7 @@ def test_build_request_requires_non_system_messages() -> None:
client=_StubBedrockRuntime(),
)
messages = [ChatMessage(role=Role.SYSTEM, contents=[TextContent(text="Only system text")])]
messages = [ChatMessage(role=Role.SYSTEM, contents=[Content.from_text(text="Only system text")])]
with pytest.raises(ServiceInitializationError):
client._prepare_options(messages, {})
@@ -9,10 +9,8 @@ from agent_framework import (
AIFunction,
ChatMessage,
ChatOptions,
FunctionCallContent,
FunctionResultContent,
Content,
Role,
TextContent,
)
from pydantic import BaseModel
@@ -49,7 +47,7 @@ def test_build_request_includes_tool_config() -> None:
"tools": [tool],
"tool_choice": {"mode": "required", "required_function_name": "get_weather"},
}
messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="hi")])]
messages = [ChatMessage(role=Role.USER, contents=[Content.from_text(text="hi")])]
request = client._prepare_options(messages, options)
@@ -61,14 +59,16 @@ def test_build_request_serializes_tool_history() -> None:
client = _build_client()
options: ChatOptions = {}
messages = [
ChatMessage(role=Role.USER, contents=[TextContent(text="how's weather?")]),
ChatMessage(role=Role.USER, contents=[Content.from_text(text="how's weather?")]),
ChatMessage(
role=Role.ASSISTANT,
contents=[FunctionCallContent(call_id="call-1", name="get_weather", arguments='{"location": "SEA"}')],
contents=[
Content.from_function_call(call_id="call-1", name="get_weather", arguments='{"location": "SEA"}')
],
),
ChatMessage(
role=Role.TOOL,
contents=[FunctionResultContent(call_id="call-1", result={"answer": "72F"})],
contents=[Content.from_function_result(call_id="call-1", result={"answer": "72F"})],
),
]
@@ -101,9 +101,9 @@ def test_process_response_parses_tool_use_and_result() -> None:
chat_response = client._process_converse_response(response)
contents = chat_response.messages[0].contents
assert isinstance(contents[0], FunctionCallContent)
assert contents[0].type == "function_call"
assert contents[0].name == "get_weather"
assert isinstance(contents[1], TextContent)
assert contents[1].type == "text"
assert chat_response.finish_reason == client._map_finish_reason("tool_use")
@@ -131,5 +131,5 @@ def test_process_response_parses_tool_result() -> None:
chat_response = client._process_converse_response(response)
contents = chat_response.messages[0].contents
assert isinstance(contents[0], FunctionResultContent)
assert contents[0].type == "function_result"
assert contents[0].result == {"answer": 42}
@@ -8,12 +8,8 @@ from collections.abc import Awaitable, Callable, Sequence
from agent_framework import (
ChatMessage,
DataContent,
FunctionCallContent,
FunctionResultContent,
Content,
Role,
TextContent,
UriContent,
)
from chatkit.types import (
AssistantMessageItem,
@@ -91,8 +87,8 @@ class ThreadItemConverter:
if isinstance(content_part, UserMessageTextContent):
text_content += content_part.text
# Convert attachments to DataContent or UriContent
data_contents: list[DataContent | UriContent] = []
# Convert attachments to Content
data_contents: list[Content] = []
if item.attachments:
for attachment in item.attachments:
content = await self.attachment_to_message_content(attachment)
@@ -108,9 +104,9 @@ class ThreadItemConverter:
user_message = ChatMessage(role=Role.USER, text=text_content.strip())
else:
# Build contents list with both text and attachments
contents: list[TextContent | DataContent | UriContent] = []
contents: list[Content] = []
if text_content.strip():
contents.append(TextContent(text=text_content.strip()))
contents.append(Content.from_text(text=text_content.strip()))
contents.extend(data_contents)
user_message = ChatMessage(role=Role.USER, contents=contents)
@@ -126,7 +122,7 @@ class ThreadItemConverter:
return messages
async def attachment_to_message_content(self, attachment: Attachment) -> DataContent | UriContent | None:
async def attachment_to_message_content(self, attachment: Attachment) -> Content | None:
"""Convert a ChatKit attachment to Agent Framework content.
This method is called internally by `user_message_to_input()` to handle attachments.
@@ -169,14 +165,14 @@ class ThreadItemConverter:
if self.attachment_data_fetcher is not None:
try:
data = await self.attachment_data_fetcher(attachment.id)
return DataContent(data=data, media_type=attachment.mime_type)
return Content.from_data(data=data, media_type=attachment.mime_type)
except Exception as e:
# If fetch fails, fall through to URL-based approach
logger.debug(f"Failed to fetch attachment data for {attachment.id}: {e}")
# For ImageAttachment, try to use preview_url
if isinstance(attachment, ImageAttachment) and attachment.preview_url:
return UriContent(uri=str(attachment.preview_url), media_type=attachment.mime_type)
return Content.from_uri(uri=str(attachment.preview_url), media_type=attachment.mime_type)
# For FileAttachment without data fetcher, skip the attachment
# Subclasses can override this method to provide custom handling
@@ -220,7 +216,7 @@ class ThreadItemConverter:
"""
return ChatMessage(role=Role.SYSTEM, text=f"<HIDDEN_CONTEXT>{item.content}</HIDDEN_CONTEXT>")
def tag_to_message_content(self, tag: UserMessageTagContent) -> TextContent:
def tag_to_message_content(self, tag: UserMessageTagContent) -> Content:
"""Convert a ChatKit tag (@-mention) to Agent Framework content.
This method is called internally by `user_message_to_input()` to handle tags.
@@ -248,10 +244,10 @@ class ThreadItemConverter:
type="input_tag", id="tag_1", text="john", data={"name": "John Doe"}, interactive=False
)
content = converter.tag_to_message_content(tag)
# Returns: TextContent(text="<TAG>Name:John Doe</TAG>")
# Returns: Content.from_text(text="<TAG>Name:John Doe</TAG>")
"""
name = getattr(tag.data, "name", tag.text if hasattr(tag, "text") else "unknown")
return TextContent(text=f"<TAG>Name:{name}</TAG>")
return Content.from_text(text=f"<TAG>Name:{name}</TAG>")
def task_to_input(self, item: TaskItem) -> ChatMessage | list[ChatMessage] | None:
"""Convert a ChatKit TaskItem to Agent Framework ChatMessage(s).
@@ -448,7 +444,7 @@ class ThreadItemConverter:
function_call_msg = ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
Content.from_function_call(
call_id=item.call_id,
name=item.name,
arguments=json.dumps(item.arguments),
@@ -460,7 +456,7 @@ class ThreadItemConverter:
function_result_msg = ChatMessage(
role=Role.TOOL,
contents=[
FunctionResultContent(
Content.from_function_result(
call_id=item.call_id,
result=json.dumps(item.output) if item.output is not None else "",
)
@@ -6,7 +6,7 @@ import uuid
from collections.abc import AsyncIterable, AsyncIterator, Callable
from datetime import datetime
from agent_framework import AgentResponseUpdate, TextContent
from agent_framework import AgentResponseUpdate
from chatkit.types import (
AssistantMessageContent,
AssistantMessageContentPartTextDelta,
@@ -77,7 +77,7 @@ async def stream_agent_response(
if update.contents:
for content in update.contents:
# Handle text content - only TextContent has a text attribute
if isinstance(content, TextContent) and content.text is not None:
if content.type == "text" and content.text is not None:
# Yield incremental text delta for streaming display
yield ThreadItemUpdated(
type="thread.item.updated",
@@ -5,7 +5,7 @@
from unittest.mock import Mock
import pytest
from agent_framework import ChatMessage, Role, TextContent
from agent_framework import ChatMessage, Role
from chatkit.types import UserMessageTextContent
from agent_framework_chatkit import ThreadItemConverter, simple_to_agent_input
@@ -133,7 +133,7 @@ class TestThreadItemConverter:
)
result = converter.tag_to_message_content(tag)
assert isinstance(result, TextContent)
assert result.type == "text"
# Since data is a dict, getattr won't work, so it will fall back to text
assert result.text == "<TAG>Name:john</TAG>"
@@ -150,7 +150,7 @@ class TestThreadItemConverter:
)
result = converter.tag_to_message_content(tag)
assert isinstance(result, TextContent)
assert result.type == "text"
assert result.text == "<TAG>Name:jane</TAG>"
async def test_attachment_to_message_content_file_without_fetcher(self, converter):
@@ -169,7 +169,6 @@ class TestThreadItemConverter:
async def test_attachment_to_message_content_image_with_preview_url(self, converter):
"""Test that ImageAttachment with preview_url creates UriContent."""
from agent_framework import UriContent
from chatkit.types import ImageAttachment
attachment = ImageAttachment(
@@ -181,13 +180,12 @@ class TestThreadItemConverter:
)
result = await converter.attachment_to_message_content(attachment)
assert isinstance(result, UriContent)
assert result.type == "uri"
assert result.uri == "https://example.com/photo.jpg"
assert result.media_type == "image/jpeg"
async def test_attachment_to_message_content_with_data_fetcher(self):
"""Test attachment conversion with data fetcher."""
from agent_framework import DataContent
from chatkit.types import FileAttachment
# Mock data fetcher
@@ -204,14 +202,13 @@ class TestThreadItemConverter:
)
result = await converter.attachment_to_message_content(attachment)
assert isinstance(result, DataContent)
assert result.type == "data"
assert result.media_type == "application/pdf"
async def test_to_agent_input_with_image_attachment(self):
"""Test converting user message with text and image attachment."""
from datetime import datetime
from agent_framework import UriContent
from chatkit.types import ImageAttachment, UserMessageItem
attachment = ImageAttachment(
@@ -241,11 +238,11 @@ class TestThreadItemConverter:
assert len(message.contents) == 2
# First content should be text
assert isinstance(message.contents[0], TextContent)
assert message.contents[0].type == "text"
assert message.contents[0].text == "Check out this photo!"
# Second content should be UriContent for the image
assert isinstance(message.contents[1], UriContent)
assert message.contents[1].type == "uri"
assert message.contents[1].uri == "https://example.com/photo.jpg"
assert message.contents[1].media_type == "image/jpeg"
@@ -253,7 +250,6 @@ class TestThreadItemConverter:
"""Test converting user message with file attachment using data fetcher."""
from datetime import datetime
from agent_framework import DataContent
from chatkit.types import FileAttachment, UserMessageItem
attachment = FileAttachment(
@@ -285,10 +281,10 @@ class TestThreadItemConverter:
assert len(message.contents) == 2
# First content should be text
assert isinstance(message.contents[0], TextContent)
assert message.contents[0].type == "text"
# Second content should be DataContent for the file
assert isinstance(message.contents[1], DataContent)
assert message.contents[1].type == "data"
assert message.contents[1].media_type == "application/pdf"
def test_task_to_input(self, converter):
@@ -4,7 +4,7 @@
from unittest.mock import Mock
from agent_framework import AgentResponseUpdate, Role, TextContent
from agent_framework import AgentResponseUpdate, Content, Role
from chatkit.types import (
ThreadItemAddedEvent,
ThreadItemDoneEvent,
@@ -34,7 +34,7 @@ class TestStreamAgentResponse:
"""Test streaming single text update."""
async def single_update_stream():
yield AgentResponseUpdate(role=Role.ASSISTANT, contents=[TextContent(text="Hello world")])
yield AgentResponseUpdate(role=Role.ASSISTANT, contents=[Content.from_text(text="Hello world")])
events = []
async for event in stream_agent_response(single_update_stream(), thread_id="test_thread"):
@@ -59,8 +59,8 @@ class TestStreamAgentResponse:
"""Test streaming multiple text updates."""
async def multiple_updates_stream():
yield AgentResponseUpdate(role=Role.ASSISTANT, contents=[TextContent(text="Hello ")])
yield AgentResponseUpdate(role=Role.ASSISTANT, contents=[TextContent(text="world!")])
yield AgentResponseUpdate(role=Role.ASSISTANT, contents=[Content.from_text(text="Hello ")])
yield AgentResponseUpdate(role=Role.ASSISTANT, contents=[Content.from_text(text="world!")])
events = []
async for event in stream_agent_response(multiple_updates_stream(), thread_id="test_thread"):
@@ -91,7 +91,7 @@ class TestStreamAgentResponse:
return f"custom_{item_type}_123"
async def single_update_stream():
yield AgentResponseUpdate(role=Role.ASSISTANT, contents=[TextContent(text="Test")])
yield AgentResponseUpdate(role=Role.ASSISTANT, contents=[Content.from_text(text="Test")])
events = []
async for event in stream_agent_response(
@@ -125,9 +125,10 @@ class TestStreamAgentResponse:
async def test_stream_non_text_content(self):
"""Test streaming updates with non-text content."""
# Mock a content object without text attribute
non_text_content = Mock()
non_text_content = Mock(spec=Content)
non_text_content.type = "image"
# Don't set text attribute
del non_text_content.text
non_text_content.text = None
async def non_text_stream():
yield AgentResponseUpdate(role=Role.ASSISTANT, contents=[non_text_content])
@@ -10,9 +10,9 @@ from agent_framework import (
AgentThread,
BaseAgent,
ChatMessage,
Content,
ContextProvider,
Role,
TextContent,
normalize_messages,
)
from agent_framework._pydantic import AFBaseSettings
@@ -332,7 +332,7 @@ class CopilotStudioAgent(BaseAgent):
):
yield ChatMessage(
role=Role.ASSISTANT,
contents=[TextContent(activity.text)],
contents=[Content.from_text(activity.text)],
author_name=activity.from_property.name if activity.from_property else None,
message_id=activity.id,
raw_representation=activity,
@@ -4,14 +4,7 @@ from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from agent_framework import (
AgentResponse,
AgentResponseUpdate,
AgentThread,
ChatMessage,
Role,
TextContent,
)
from agent_framework import AgentResponse, AgentResponseUpdate, AgentThread, ChatMessage, Content, Role
from agent_framework.exceptions import ServiceException, ServiceInitializationError
from microsoft_agents.copilotstudio.client import CopilotClient
@@ -136,7 +129,7 @@ class TestCopilotStudioAgent:
assert isinstance(response, AgentResponse)
assert len(response.messages) == 1
content = response.messages[0].contents[0]
assert isinstance(content, TextContent)
assert content.type == "text"
assert content.text == "Test response"
assert response.messages[0].role == Role.ASSISTANT
@@ -150,13 +143,13 @@ 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])
chat_message = ChatMessage(role=Role.USER, contents=[TextContent("test message")])
chat_message = ChatMessage(role=Role.USER, contents=[Content.from_text("test message")])
response = await agent.run(chat_message)
assert isinstance(response, AgentResponse)
assert len(response.messages) == 1
content = response.messages[0].contents[0]
assert isinstance(content, TextContent)
assert content.type == "text"
assert content.text == "Test response"
assert response.messages[0].role == Role.ASSISTANT
@@ -206,7 +199,7 @@ class TestCopilotStudioAgent:
async for response in agent.run_stream("test message"):
assert isinstance(response, AgentResponseUpdate)
content = response.contents[0]
assert isinstance(content, TextContent)
assert content.type == "text"
assert content.text == "Streaming response"
response_count += 1
@@ -233,7 +226,7 @@ class TestCopilotStudioAgent:
async for response in agent.run_stream("test message", thread=thread):
assert isinstance(response, AgentResponseUpdate)
content = response.contents[0]
assert isinstance(content, TextContent)
assert content.type == "text"
assert content.text == "Streaming response"
response_count += 1
@@ -1140,9 +1140,9 @@ class ChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc]
# Convert result to MCP content
if isinstance(result, str):
return [types.TextContent(type="text", text=result)]
return [types.TextContent(type="text", text=result)] # type: ignore[attr-defined]
return [types.TextContent(type="text", text=str(result))]
return [types.TextContent(type="text", text=str(result))] # type: ignore[attr-defined]
@server.set_logging_level() # type: ignore
async def _set_logging_level(level: types.LoggingLevel) -> None: # type: ignore
+49 -55
View File
@@ -1,5 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.
import base64
import logging
import re
import sys
@@ -29,13 +30,8 @@ from ._tools import (
)
from ._types import (
ChatMessage,
Contents,
DataContent,
FunctionCallContent,
FunctionResultContent,
Content,
Role,
TextContent,
UriContent,
)
from .exceptions import ToolException, ToolExecutionException
@@ -82,7 +78,7 @@ def _parse_message_from_mcp(
def _parse_contents_from_mcp_tool_result(
mcp_type: types.CallToolResult,
) -> list[Contents]:
) -> list[Content]:
"""Parse an MCP CallToolResult into Agent Framework content types.
This function extracts the complete _meta field from CallToolResult objects
@@ -147,25 +143,27 @@ def _parse_content_from_mcp(
| types.ToolUseContent
| types.ToolResultContent
],
) -> list[Contents]:
) -> list[Content]:
"""Parse an MCP type into an Agent Framework type."""
mcp_types = mcp_type if isinstance(mcp_type, Sequence) else [mcp_type]
return_types: list[Contents] = []
return_types: list[Content] = []
for mcp_type in mcp_types:
match mcp_type:
case types.TextContent():
return_types.append(TextContent(text=mcp_type.text, raw_representation=mcp_type))
return_types.append(Content.from_text(text=mcp_type.text, raw_representation=mcp_type))
case types.ImageContent() | types.AudioContent():
# MCP protocol uses base64-encoded strings, convert to bytes
data_bytes = base64.b64decode(mcp_type.data) if isinstance(mcp_type.data, str) else mcp_type.data
return_types.append(
DataContent(
data=mcp_type.data,
Content.from_data(
data=data_bytes,
media_type=mcp_type.mimeType,
raw_representation=mcp_type,
)
)
case types.ResourceLink():
return_types.append(
UriContent(
Content.from_uri(
uri=str(mcp_type.uri),
media_type=mcp_type.mimeType or "application/json",
raw_representation=mcp_type,
@@ -173,7 +171,7 @@ def _parse_content_from_mcp(
)
case types.ToolUseContent():
return_types.append(
FunctionCallContent(
Content.from_function_call(
call_id=mcp_type.id,
name=mcp_type.name,
arguments=mcp_type.input,
@@ -182,12 +180,12 @@ def _parse_content_from_mcp(
)
case types.ToolResultContent():
return_types.append(
FunctionResultContent(
Content.from_function_result(
call_id=mcp_type.toolUseId,
result=_parse_content_from_mcp(mcp_type.content)
if mcp_type.content
else mcp_type.structuredContent,
exception=Exception() if mcp_type.isError else None,
exception=str(Exception()) if mcp_type.isError else None, # type: ignore[arg-type]
raw_representation=mcp_type,
)
)
@@ -195,7 +193,7 @@ def _parse_content_from_mcp(
match mcp_type.resource:
case types.TextResourceContents():
return_types.append(
TextContent(
Content.from_text(
text=mcp_type.resource.text,
raw_representation=mcp_type,
additional_properties=(
@@ -205,7 +203,7 @@ def _parse_content_from_mcp(
)
case types.BlobResourceContents():
return_types.append(
DataContent(
Content.from_uri(
uri=mcp_type.resource.blob,
media_type=mcp_type.resource.mimeType,
raw_representation=mcp_type,
@@ -218,45 +216,41 @@ def _parse_content_from_mcp(
def _prepare_content_for_mcp(
content: Contents,
content: Content,
) -> types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource | types.ResourceLink | None:
"""Prepare an Agent Framework content type for MCP."""
match content:
case TextContent():
return types.TextContent(type="text", text=content.text)
case DataContent():
if content.media_type and content.media_type.startswith("image/"):
return types.ImageContent(type="image", data=content.uri, mimeType=content.media_type)
if content.media_type and content.media_type.startswith("audio/"):
return types.AudioContent(type="audio", data=content.uri, mimeType=content.media_type)
if content.media_type and content.media_type.startswith("application/"):
return types.EmbeddedResource(
type="resource",
resource=types.BlobResourceContents(
blob=content.uri,
mimeType=content.media_type,
# uri's are not limited in MCP but they have to be set.
# the uri of data content, contains the data uri, which
# is not the uri meant here, UriContent would match this.
uri=(
content.additional_properties.get("uri", "af://binary")
if content.additional_properties
else "af://binary"
), # type: ignore[reportArgumentType]
),
)
return None
case UriContent():
return types.ResourceLink(
type="resource_link",
uri=content.uri, # type: ignore[reportArgumentType]
mimeType=content.media_type,
name=(
content.additional_properties.get("name", "Unknown") if content.additional_properties else "Unknown"
if content.type == "text":
return types.TextContent(type="text", text=content.text) # type: ignore[attr-defined]
if content.type == "data":
if content.media_type and content.media_type.startswith("image/"): # type: ignore[attr-defined]
return types.ImageContent(type="image", data=content.uri, mimeType=content.media_type) # type: ignore[attr-defined]
if content.media_type and content.media_type.startswith("audio/"): # type: ignore[attr-defined]
return types.AudioContent(type="audio", data=content.uri, mimeType=content.media_type) # type: ignore[attr-defined]
if content.media_type and content.media_type.startswith("application/"): # type: ignore[attr-defined]
return types.EmbeddedResource(
type="resource",
resource=types.BlobResourceContents(
blob=content.uri, # type: ignore[attr-defined]
mimeType=content.media_type, # type: ignore[attr-defined]
# uri's are not limited in MCP but they have to be set.
# the uri of data content, contains the data uri, which
# is not the uri meant here, UriContent would match this.
uri=(
content.additional_properties.get("uri", "af://binary")
if content.additional_properties
else "af://binary"
), # type: ignore[reportArgumentType]
),
)
case _:
return None
return None
if content.type == "uri":
return types.ResourceLink(
type="resource_link",
uri=content.uri, # type: ignore[reportArgumentType,attr-defined]
mimeType=content.media_type, # type: ignore[attr-defined]
name=(content.additional_properties.get("name", "Unknown") if content.additional_properties else "Unknown"),
)
return None
def _prepare_message_for_mcp(
@@ -650,7 +644,7 @@ class MCPTool:
input_model = _get_input_model_from_mcp_tool(tool)
approval_mode = self._determine_approval_mode(local_name)
# Create AIFunctions out of each tool
func: AIFunction[BaseModel, list[Contents] | Any | types.CallToolResult] = AIFunction(
func: AIFunction[BaseModel, list[Content] | Any | types.CallToolResult] = AIFunction(
func=partial(self.call_tool, tool.name),
name=local_name,
description=tool.description or "",
@@ -704,7 +698,7 @@ class MCPTool:
inner_exception=ex,
) from ex
async def call_tool(self, tool_name: str, **kwargs: Any) -> list[Contents] | Any | types.CallToolResult:
async def call_tool(self, tool_name: str, **kwargs: Any) -> list[Content] | Any | types.CallToolResult:
"""Call a tool with the given arguments.
Args:
+104 -116
View File
@@ -54,9 +54,7 @@ if TYPE_CHECKING:
ChatMessage,
ChatResponse,
ChatResponseUpdate,
Contents,
FunctionApprovalResponseContent,
FunctionCallContent,
Content,
)
from typing import overload
@@ -104,15 +102,15 @@ _NOOP_HISTOGRAM = _NoOpHistogram()
def _parse_inputs(
inputs: "Contents | dict[str, Any] | str | list[Contents | dict[str, Any] | str] | None",
) -> list["Contents"]:
"""Parse the inputs for a tool, ensuring they are of type Contents.
inputs: "Content | dict[str, Any] | str | list[Content | dict[str, Any] | str] | None",
) -> list["Content"]:
"""Parse the inputs for a tool, ensuring they are of type Content.
Args:
inputs: The inputs to parse. Can be a single item or list of Contents, dicts, or strings.
inputs: The inputs to parse. Can be a single item or list of Content, dicts, or strings.
Returns:
A list of Contents objects.
A list of Content objects.
Raises:
ValueError: If an unsupported input type is encountered.
@@ -122,43 +120,39 @@ def _parse_inputs(
return []
from ._types import (
BaseContent,
DataContent,
HostedFileContent,
HostedVectorStoreContent,
UriContent,
Content,
)
parsed_inputs: list["Contents"] = []
parsed_inputs: list["Content"] = []
if not isinstance(inputs, list):
inputs = [inputs]
for input_item in inputs:
if isinstance(input_item, str):
# If it's a string, we assume it's a URI or similar identifier.
# Convert it to a UriContent or similar type as needed.
parsed_inputs.append(UriContent(uri=input_item, media_type="text/plain"))
parsed_inputs.append(Content.from_uri(uri=input_item, media_type="text/plain"))
elif isinstance(input_item, dict):
# If it's a dict, we assume it contains properties for a specific content type.
# we check if the required keys are present to determine the type.
# for instance, if it has "uri" and "media_type", we treat it as UriContent.
# if is only has uri, then we treat it as DataContent.
# if it only has uri and media_type without a specific type indicator, we treat it as DataContent.
# etc.
if "uri" in input_item:
parsed_inputs.append(
UriContent(**input_item) if "media_type" in input_item else DataContent(**input_item)
)
# Use Content.from_uri for proper URI content, DataContent for backwards compatibility
parsed_inputs.append(Content.from_uri(**input_item))
elif "file_id" in input_item:
parsed_inputs.append(HostedFileContent(**input_item))
parsed_inputs.append(Content.from_hosted_file(**input_item))
elif "vector_store_id" in input_item:
parsed_inputs.append(HostedVectorStoreContent(**input_item))
parsed_inputs.append(Content.from_hosted_vector_store(**input_item))
elif "data" in input_item:
parsed_inputs.append(DataContent(**input_item))
# DataContent helper handles both uri and data parameters
parsed_inputs.append(Content.from_data(**input_item))
else:
raise ValueError(f"Unsupported input type: {input_item}")
elif isinstance(input_item, BaseContent):
elif isinstance(input_item, Content):
parsed_inputs.append(input_item)
else:
raise TypeError(f"Unsupported input type: {type(input_item).__name__}. Expected Contents or dict.")
raise TypeError(f"Unsupported input type: {type(input_item).__name__}. Expected Content or dict.")
return parsed_inputs
@@ -254,7 +248,7 @@ class HostedCodeInterpreterTool(BaseTool):
def __init__(
self,
*,
inputs: "Contents | dict[str, Any] | str | list[Contents | dict[str, Any] | str] | None" = None,
inputs: "Content | dict[str, Any] | str | list[Content | dict[str, Any] | str] | None" = None,
description: str | None = None,
additional_properties: dict[str, Any] | None = None,
**kwargs: Any,
@@ -266,8 +260,8 @@ class HostedCodeInterpreterTool(BaseTool):
This should mostly be HostedFileContent or HostedVectorStoreContent.
Can also be DataContent, depending on the service used.
When supplying a list, it can contain:
- Contents instances
- dicts with properties for Contents (e.g., {"uri": "http://example.com", "media_type": "text/html"})
- Content instances
- dicts with properties for Content (e.g., {"uri": "http://example.com", "media_type": "text/html"})
- strings (which will be converted to UriContent with media_type "text/plain").
If None, defaults to an empty list.
description: A description of the tool.
@@ -503,7 +497,7 @@ class HostedFileSearchTool(BaseTool):
def __init__(
self,
*,
inputs: "Contents | dict[str, Any] | str | list[Contents | dict[str, Any] | str] | None" = None,
inputs: "Content | dict[str, Any] | str | list[Content | dict[str, Any] | str] | None" = None,
max_results: int | None = None,
description: str | None = None,
additional_properties: dict[str, Any] | None = None,
@@ -515,8 +509,8 @@ class HostedFileSearchTool(BaseTool):
inputs: A list of contents that the tool can accept as input. Defaults to None.
This should be one or more HostedVectorStoreContents.
When supplying a list, it can contain:
- Contents instances
- dicts with properties for Contents (e.g., {"uri": "http://example.com", "media_type": "text/html"})
- Content instances
- dicts with properties for Content (e.g., {"uri": "http://example.com", "media_type": "text/html"})
- strings (which will be converted to UriContent with media_type "text/plain").
If None, defaults to an empty list.
max_results: The maximum number of results to return from the file search.
@@ -1480,7 +1474,7 @@ class FunctionExecutionResult:
__slots__ = ("content", "terminate")
def __init__(self, content: "Contents", terminate: bool = False) -> None:
def __init__(self, content: "Content", terminate: bool = False) -> None:
"""Initialize FunctionExecutionResult.
Args:
@@ -1492,7 +1486,7 @@ class FunctionExecutionResult:
async def _auto_invoke_function(
function_call_content: "FunctionCallContent | FunctionApprovalResponseContent",
function_call_content: "Content",
custom_args: dict[str, Any] | None = None,
*,
config: FunctionInvocationConfiguration,
@@ -1500,7 +1494,7 @@ async def _auto_invoke_function(
sequence_index: int | None = None,
request_index: int | None = None,
middleware_pipeline: Any = None, # Optional MiddlewarePipeline
) -> "FunctionExecutionResult | Contents":
) -> "FunctionExecutionResult | Content":
"""Invoke a function call requested by the agent, applying middleware that is defined.
Args:
@@ -1516,41 +1510,42 @@ async def _auto_invoke_function(
Returns:
A FunctionExecutionResult wrapping the content and terminate signal,
or a Contents object for approval/hosted tool scenarios.
or a Content object for approval/hosted tool scenarios.
Raises:
KeyError: If the requested function is not found in the tool map.
"""
from ._types import Content
# Note: The scenarios for approval_mode="always_require", declaration_only, and
# terminate_on_unknown_calls are all handled in _try_execute_function_calls before
# this function is called. This function only handles the actual execution of approved,
# non-declaration-only functions.
from ._types import FunctionCallContent, FunctionResultContent
tool: AIFunction[BaseModel, Any] | None = None
if function_call_content.type == "function_call":
tool = tool_map.get(function_call_content.name)
tool = tool_map.get(function_call_content.name) # type: ignore[arg-type]
# Tool should exist because _try_execute_function_calls validates this
if tool is None:
exc = KeyError(f'Function "{function_call_content.name}" not found.')
return FunctionExecutionResult(
content=FunctionResultContent(
call_id=function_call_content.call_id,
content=Content.from_function_result(
call_id=function_call_content.call_id, # type: ignore[arg-type]
result=f'Error: Requested function "{function_call_content.name}" not found.',
exception=exc,
exception=str(exc), # type: ignore[arg-type]
)
)
else:
# Note: Unapproved tools (approved=False) are handled in _replace_approval_contents_with_results
# and never reach this function, so we only handle approved=True cases here.
inner_call = function_call_content.function_call
if not isinstance(inner_call, FunctionCallContent):
inner_call = function_call_content.function_call # type: ignore[attr-defined]
if inner_call.type != "function_call": # type: ignore[union-attr]
return function_call_content
tool = tool_map.get(inner_call.name)
tool = tool_map.get(inner_call.name) # type: ignore[attr-defined, union-attr, arg-type]
if tool is None:
# we assume it is a hosted tool
return function_call_content
function_call_content = inner_call
function_call_content = inner_call # type: ignore[assignment]
parsed_args: dict[str, Any] = dict(function_call_content.parse_arguments() or {})
@@ -1567,7 +1562,11 @@ async def _auto_invoke_function(
if config.include_detailed_errors:
message = f"{message} Exception: {exc}"
return FunctionExecutionResult(
content=FunctionResultContent(call_id=function_call_content.call_id, result=message, exception=exc)
content=Content.from_function_result(
call_id=function_call_content.call_id, # type: ignore[arg-type]
result=message,
exception=str(exc), # type: ignore[arg-type]
)
)
if not middleware_pipeline or (
@@ -1581,8 +1580,8 @@ async def _auto_invoke_function(
**runtime_kwargs if getattr(tool, "_forward_runtime_kwargs", False) else {},
)
return FunctionExecutionResult(
content=FunctionResultContent(
call_id=function_call_content.call_id,
content=Content.from_function_result(
call_id=function_call_content.call_id, # type: ignore[arg-type]
result=function_result,
)
)
@@ -1591,7 +1590,11 @@ async def _auto_invoke_function(
if config.include_detailed_errors:
message = f"{message} Exception: {exc}"
return FunctionExecutionResult(
content=FunctionResultContent(call_id=function_call_content.call_id, result=message, exception=exc)
content=Content.from_function_result(
call_id=function_call_content.call_id, # type: ignore[arg-type]
result=message,
exception=str(exc),
)
)
# Execute through middleware pipeline if available
from ._middleware import FunctionInvocationContext
@@ -1617,8 +1620,8 @@ async def _auto_invoke_function(
final_handler=final_function_handler,
)
return FunctionExecutionResult(
content=FunctionResultContent(
call_id=function_call_content.call_id,
content=Content.from_function_result(
call_id=function_call_content.call_id, # type: ignore[arg-type]
result=function_result,
),
terminate=middleware_context.terminate,
@@ -1628,7 +1631,11 @@ async def _auto_invoke_function(
if config.include_detailed_errors:
message = f"{message} Exception: {exc}"
return FunctionExecutionResult(
content=FunctionResultContent(call_id=function_call_content.call_id, result=message, exception=exc)
content=Content.from_function_result(
call_id=function_call_content.call_id, # type: ignore[arg-type]
result=message,
exception=str(exc), # type: ignore[arg-type]
)
)
@@ -1653,14 +1660,14 @@ def _get_tool_map(
async def _try_execute_function_calls(
custom_args: dict[str, Any],
attempt_idx: int,
function_calls: Sequence["FunctionCallContent"] | Sequence["FunctionApprovalResponseContent"],
function_calls: Sequence["Content"],
tools: "ToolProtocol \
| Callable[..., Any] \
| MutableMapping[str, Any] \
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]",
config: FunctionInvocationConfiguration,
middleware_pipeline: Any = None, # Optional MiddlewarePipeline to avoid circular imports
) -> tuple[Sequence["Contents"], bool]:
) -> tuple[Sequence["Content"], bool]:
"""Execute multiple function calls concurrently.
Args:
@@ -1673,12 +1680,12 @@ async def _try_execute_function_calls(
Returns:
A tuple of:
- A list of Contents containing the results of each function call,
- A list of Content containing the results of each function call,
or the approval requests if any function requires approval,
or the original function calls if any are declaration only.
- A boolean indicating whether to terminate the function calling loop.
"""
from ._types import FunctionApprovalRequestContent, FunctionCallContent
from ._types import Content
tool_map = _get_tool_map(tools)
approval_tools = [tool_name for tool_name, tool in tool_map.items() if tool.approval_mode == "always_require"]
@@ -1689,27 +1696,27 @@ async def _try_execute_function_calls(
approval_needed = False
declaration_only_flag = False
for fcc in function_calls:
if isinstance(fcc, FunctionCallContent) and fcc.name in approval_tools:
if fcc.type == "function_call" and fcc.name in approval_tools: # type: ignore[attr-defined]
approval_needed = True
break
if isinstance(fcc, FunctionCallContent) and (fcc.name in declaration_only or fcc.name in additional_tool_names):
if fcc.type == "function_call" and (fcc.name in declaration_only or fcc.name in additional_tool_names): # type: ignore[attr-defined]
declaration_only_flag = True
break
if config.terminate_on_unknown_calls and isinstance(fcc, FunctionCallContent) and fcc.name not in tool_map:
raise KeyError(f'Error: Requested function "{fcc.name}" not found.')
if config.terminate_on_unknown_calls and fcc.type == "function_call" and fcc.name not in tool_map: # type: ignore[attr-defined]
raise KeyError(f'Error: Requested function "{fcc.name}" not found.') # type: ignore[attr-defined]
if approval_needed:
# approval can only be needed for Function Call Contents, not Approval Responses.
# approval can only be needed for Function Call Content, not Approval Responses.
return (
[
FunctionApprovalRequestContent(id=fcc.call_id, function_call=fcc)
Content.from_function_approval_request(id=fcc.call_id, function_call=fcc) # type: ignore[attr-defined, arg-type]
for fcc in function_calls
if isinstance(fcc, FunctionCallContent)
if fcc.type == "function_call"
],
False,
)
if declaration_only_flag:
# return the declaration only tools to the user, since we cannot execute them.
return ([fcc for fcc in function_calls if isinstance(fcc, FunctionCallContent)], False)
return ([fcc for fcc in function_calls if fcc.type == "function_call"], False)
# Run all function calls concurrently
execution_results = await asyncio.gather(*[
@@ -1726,7 +1733,7 @@ async def _try_execute_function_calls(
])
# Unpack FunctionExecutionResult wrappers and check for terminate signal
contents: list[Contents] = []
contents: list[Content] = []
should_terminate = False
for result in execution_results:
if isinstance(result, FunctionExecutionResult):
@@ -1734,7 +1741,7 @@ async def _try_execute_function_calls(
if result.terminate:
should_terminate = True
else:
# Direct Contents (e.g., from hosted tools)
# Direct Content (e.g., from hosted tools)
contents.append(result)
return (contents, should_terminate)
@@ -1772,30 +1779,27 @@ def _extract_tools(options: dict[str, Any] | None) -> Any:
def _collect_approval_responses(
messages: "list[ChatMessage]",
) -> dict[str, "FunctionApprovalResponseContent"]:
) -> dict[str, "Content"]:
"""Collect approval responses (both approved and rejected) from messages."""
from ._types import ChatMessage, FunctionApprovalResponseContent
from ._types import ChatMessage, Content
fcc_todo: dict[str, FunctionApprovalResponseContent] = {}
fcc_todo: dict[str, Content] = {}
for msg in messages:
for content in msg.contents if isinstance(msg, ChatMessage) else []:
# Collect BOTH approved and rejected responses
if isinstance(content, FunctionApprovalResponseContent):
fcc_todo[content.id] = content
if content.type == "function_approval_response":
fcc_todo[content.id] = content # type: ignore[attr-defined, index]
return fcc_todo
def _replace_approval_contents_with_results(
messages: "list[ChatMessage]",
fcc_todo: dict[str, "FunctionApprovalResponseContent"],
approved_function_results: "list[Contents]",
fcc_todo: dict[str, "Content"],
approved_function_results: "list[Content]",
) -> None:
"""Replace approval request/response contents with function call/result contents in-place."""
from ._types import (
FunctionApprovalRequestContent,
FunctionApprovalResponseContent,
FunctionCallContent,
FunctionResultContent,
Content,
Role,
)
@@ -1803,23 +1807,25 @@ def _replace_approval_contents_with_results(
for msg in messages:
# First pass - collect existing function call IDs to avoid duplicates
existing_call_ids = {
content.call_id for content in msg.contents if isinstance(content, FunctionCallContent) and content.call_id
content.call_id # type: ignore[union-attr, operator]
for content in msg.contents
if content.type == "function_call" and content.call_id # type: ignore[attr-defined]
}
# Track approval requests that should be removed (duplicates)
contents_to_remove = []
for content_idx, content in enumerate(msg.contents):
if isinstance(content, FunctionApprovalRequestContent):
if content.type == "function_approval_request":
# Don't add the function call if it already exists (would create duplicate)
if content.function_call.call_id in existing_call_ids:
if content.function_call.call_id in existing_call_ids: # type: ignore[attr-defined, union-attr, operator]
# Just mark for removal - the function call already exists
contents_to_remove.append(content_idx)
else:
# Put back the function call content only if it doesn't exist
msg.contents[content_idx] = content.function_call
elif isinstance(content, FunctionApprovalResponseContent):
if content.approved and content.id in fcc_todo:
msg.contents[content_idx] = content.function_call # type: ignore[attr-defined, assignment]
elif content.type == "function_approval_response":
if content.approved and content.id in fcc_todo: # type: ignore[attr-defined]
# Replace with the corresponding result
if result_idx < len(approved_function_results):
msg.contents[content_idx] = approved_function_results[result_idx]
@@ -1828,8 +1834,8 @@ def _replace_approval_contents_with_results(
else:
# Create a "not approved" result for rejected calls
# Use function_call.call_id (the function's ID), not content.id (approval's ID)
msg.contents[content_idx] = FunctionResultContent(
call_id=content.function_call.call_id,
msg.contents[content_idx] = Content.from_function_result(
call_id=content.function_call.call_id, # type: ignore[union-attr, arg-type]
result="Error: Tool call invocation was rejected by user.",
)
msg.role = Role.TOOL
@@ -1867,9 +1873,6 @@ def _handle_function_calls_response(
from ._middleware import extract_and_merge_function_middleware
from ._types import (
ChatMessage,
FunctionApprovalRequestContent,
FunctionCallContent,
FunctionResultContent,
prepare_messages,
)
@@ -1893,7 +1896,7 @@ def _handle_function_calls_response(
tools = _extract_tools(options)
# Only execute APPROVED function calls, not rejected ones
approved_responses = [resp for resp in fcc_todo.values() if resp.approved]
approved_function_results: list[Contents] = []
approved_function_results: list[Content] = []
if approved_responses:
results, _ = await _try_execute_function_calls(
custom_args=kwargs,
@@ -1907,7 +1910,7 @@ def _handle_function_calls_response(
if any(
fcr.exception is not None
for fcr in approved_function_results
if isinstance(fcr, FunctionResultContent)
if fcr.type == "function_result"
):
errors_in_a_row += 1
# no need to reset the counter here, since this is the start of a new attempt.
@@ -1926,13 +1929,11 @@ def _handle_function_calls_response(
filtered_kwargs = {k: v for k, v in kwargs.items() if k not in ("thread", "tools", "tool_choice")}
response = await func(self, messages=prepped_messages, options=options, **filtered_kwargs)
# if there are function calls, we will handle them first
function_results = {
it.call_id for it in response.messages[0].contents if isinstance(it, FunctionResultContent)
}
function_results = {it.call_id for it in response.messages[0].contents if it.type == "function_result"}
function_calls = [
it
for it in response.messages[0].contents
if isinstance(it, FunctionCallContent) and it.call_id not in function_results
if it.type == "function_call" and it.call_id not in function_results
]
if response.conversation_id is not None:
@@ -1953,7 +1954,7 @@ def _handle_function_calls_response(
config=config,
)
# Check if we have approval requests or function calls (not results) in the results
if any(isinstance(fccr, FunctionApprovalRequestContent) for fccr in function_call_results):
if any(fccr.type == "function_approval_request" for fccr in function_call_results):
# Add approval requests to the existing assistant message (with tool_calls)
# instead of creating a separate tool message
from ._types import Role
@@ -1965,7 +1966,7 @@ def _handle_function_calls_response(
result_message = ChatMessage(role="assistant", contents=function_call_results)
response.messages.append(result_message)
return response
if any(isinstance(fccr, FunctionCallContent) for fccr in function_call_results):
if any(fccr.type == "function_call" for fccr in function_call_results):
# the function calls are already in the response, so we just continue
return response
@@ -1980,11 +1981,7 @@ def _handle_function_calls_response(
response.messages.insert(0, msg)
return response
if any(
fcr.exception is not None
for fcr in function_call_results
if isinstance(fcr, FunctionResultContent)
):
if any(fcr.exception is not None for fcr in function_call_results if fcr.type == "function_result"):
errors_in_a_row += 1
if errors_in_a_row >= config.max_consecutive_errors_per_request:
logger.warning(
@@ -2071,8 +2068,6 @@ def _handle_function_calls_streaming_response(
ChatMessage,
ChatResponse,
ChatResponseUpdate,
FunctionCallContent,
FunctionResultContent,
prepare_messages,
)
@@ -2094,7 +2089,7 @@ def _handle_function_calls_streaming_response(
tools = _extract_tools(options)
# Only execute APPROVED function calls, not rejected ones
approved_responses = [resp for resp in fcc_todo.values() if resp.approved]
approved_function_results: list[Contents] = []
approved_function_results: list[Content] = []
if approved_responses:
results, _ = await _try_execute_function_calls(
custom_args=kwargs,
@@ -2108,7 +2103,7 @@ def _handle_function_calls_streaming_response(
if any(
fcr.exception is not None
for fcr in approved_function_results
if isinstance(fcr, FunctionResultContent)
if fcr.type == "function_result"
):
errors_in_a_row += 1
# no need to reset the counter here, since this is the start of a new attempt.
@@ -2124,10 +2119,9 @@ def _handle_function_calls_streaming_response(
# efficient check for FunctionCallContent in the updates
# if there is at least one, this stops and continuous
# if there are no FCC's then it returns
from ._types import FunctionApprovalRequestContent
if not any(
isinstance(item, (FunctionCallContent, FunctionApprovalRequestContent))
item.type in ("function_call", "function_approval_request")
for upd in all_updates
for item in upd.contents
):
@@ -2139,13 +2133,11 @@ def _handle_function_calls_streaming_response(
response: "ChatResponse" = ChatResponse.from_chat_response_updates(all_updates)
# get the function calls (excluding ones that already have results)
function_results = {
it.call_id for it in response.messages[0].contents if isinstance(it, FunctionResultContent)
}
function_results = {it.call_id for it in response.messages[0].contents if it.type == "function_result"}
function_calls = [
it
for it in response.messages[0].contents
if isinstance(it, FunctionCallContent) and it.call_id not in function_results
if it.type == "function_call" and it.call_id not in function_results
]
# When conversation id is present, it means that messages are hosted on the server.
@@ -2169,7 +2161,7 @@ def _handle_function_calls_streaming_response(
)
# Check if we have approval requests or function calls (not results) in the results
if any(isinstance(fccr, FunctionApprovalRequestContent) for fccr in function_call_results):
if any(fccr.type == "function_approval_request" for fccr in function_call_results):
# Add approval requests to the existing assistant message (with tool_calls)
# instead of creating a separate tool message
from ._types import Role
@@ -2184,7 +2176,7 @@ def _handle_function_calls_streaming_response(
yield ChatResponseUpdate(contents=function_call_results, role="assistant")
response.messages.append(result_message)
return
if any(isinstance(fccr, FunctionCallContent) for fccr in function_call_results):
if any(fccr.type == "function_call" for fccr in function_call_results):
# the function calls were already yielded.
return
@@ -2195,11 +2187,7 @@ def _handle_function_calls_streaming_response(
yield ChatResponseUpdate(contents=function_call_results, role="tool")
return
if any(
fcr.exception is not None
for fcr in function_call_results
if isinstance(fcr, FunctionResultContent)
):
if any(fcr.exception is not None for fcr in function_call_results if fcr.type == "function_result"):
errors_in_a_row += 1
if errors_in_a_row >= config.max_consecutive_errors_per_request:
logger.warning(
File diff suppressed because it is too large Load Diff
@@ -13,18 +13,13 @@ from agent_framework import (
AgentResponseUpdate,
AgentThread,
BaseAgent,
BaseContent,
ChatMessage,
Contents,
FunctionApprovalRequestContent,
FunctionApprovalResponseContent,
FunctionCallContent,
FunctionResultContent,
Content,
Role,
TextContent,
UsageDetails,
)
from .._types import add_usage_details
from ..exceptions import AgentExecutionException
from ._agent_executor import AgentExecutor
from ._checkpoint import CheckpointStorage
@@ -357,12 +352,12 @@ class WorkflowAgent(BaseAgent):
args = self.RequestInfoFunctionArgs(request_id=request_id, data=event.data).to_dict()
function_call = FunctionCallContent(
function_call = Content.from_function_call(
call_id=request_id,
name=self.REQUEST_INFO_FUNCTION_NAME,
arguments=args,
)
approval_request = FunctionApprovalRequestContent(
approval_request = Content.from_function_approval_request(
id=request_id,
function_call=function_call,
additional_properties={"request_id": request_id},
@@ -385,9 +380,9 @@ class WorkflowAgent(BaseAgent):
function_responses: dict[str, Any] = {}
for message in input_messages:
for content in message.contents:
if isinstance(content, FunctionApprovalResponseContent):
if content.type == "function_approval_response":
# Parse the function arguments to recover request payload
arguments_payload = content.function_call.arguments
arguments_payload = content.function_call.arguments # type: ignore[attr-defined, union-attr]
if isinstance(arguments_payload, str):
try:
parsed_args = self.RequestInfoFunctionArgs.from_json(arguments_payload)
@@ -402,8 +397,8 @@ class WorkflowAgent(BaseAgent):
"FunctionApprovalResponseContent arguments must be a mapping or JSON string."
)
request_id = parsed_args.request_id or content.id
if not content.approved:
request_id = parsed_args.request_id or content.id # type: ignore[attr-defined]
if not content.approved: # type: ignore[attr-defined]
raise AgentExecutionException(f"Request '{request_id}' was not approved by the caller.")
if request_id in self.pending_requests:
@@ -412,10 +407,10 @@ class WorkflowAgent(BaseAgent):
raise AgentExecutionException(
"Only responses for pending requests are allowed when there are outstanding approvals."
)
elif isinstance(content, FunctionResultContent):
request_id = content.call_id
elif content.type == "function_result":
request_id = content.call_id # type: ignore[attr-defined]
if request_id in self.pending_requests:
response_data = content.result if hasattr(content, "result") else str(content)
response_data = content.result if hasattr(content, "result") else str(content) # type: ignore[attr-defined]
function_responses[request_id] = response_data
elif bool(self.pending_requests):
raise AgentExecutionException(
@@ -426,17 +421,17 @@ class WorkflowAgent(BaseAgent):
raise AgentExecutionException("Unexpected content type while awaiting request info responses.")
return function_responses
def _extract_contents(self, data: Any) -> list[Contents]:
"""Recursively extract Contents from workflow output data."""
def _extract_contents(self, data: Any) -> list[Content]:
"""Recursively extract Content from workflow output data."""
if isinstance(data, ChatMessage):
return list(data.contents)
if isinstance(data, list):
return [c for item in data for c in self._extract_contents(item)]
if isinstance(data, BaseContent):
return [cast(Contents, data)]
if isinstance(data, Content):
return [data] # type: ignore[redundant-cast]
if isinstance(data, str):
return [TextContent(text=data)]
return [TextContent(text=str(data))]
return [Content.from_text(text=data)]
return [Content.from_text(text=str(data))]
class _ResponseState(TypedDict):
"""State for grouping response updates by message_id."""
@@ -468,7 +463,7 @@ class WorkflowAgent(BaseAgent):
for u in updates:
if u.response_id:
for content in u.contents:
if isinstance(content, FunctionCallContent) and content.call_id:
if content.type == "function_call" and content.call_id:
call_id_to_response_id[content.call_id] = u.response_id
# Second pass: group updates, associating FunctionResultContent with their calls
@@ -480,7 +475,7 @@ class WorkflowAgent(BaseAgent):
# If no response_id, check if this is a FunctionResultContent that matches a call
if not effective_response_id:
for content in u.contents:
if isinstance(content, FunctionResultContent) and content.call_id:
if content.type == "function_result" and content.call_id:
effective_response_id = call_id_to_response_id.get(content.call_id)
if effective_response_id:
break
@@ -508,13 +503,6 @@ class WorkflowAgent(BaseAgent):
except Exception:
return (0, v)
def _sum_usage(a: UsageDetails | None, b: UsageDetails | None) -> UsageDetails | None:
if a is None:
return b
if b is None:
return a
return a + b
def _merge_responses(current: AgentResponse | None, incoming: AgentResponse) -> AgentResponse:
if current is None:
return incoming
@@ -534,7 +522,7 @@ class WorkflowAgent(BaseAgent):
messages=(current.messages or []) + (incoming.messages or []),
response_id=current.response_id or incoming.response_id,
created_at=incoming.created_at or current.created_at,
usage_details=_sum_usage(current.usage_details, incoming.usage_details),
usage_details=add_usage_details(current.usage_details, incoming.usage_details), # type: ignore[arg-type]
raw_representation=raw_list if raw_list else None,
additional_properties=incoming.additional_properties or current.additional_properties,
)
@@ -569,7 +557,7 @@ class WorkflowAgent(BaseAgent):
if aggregated:
final_messages.extend(aggregated.messages)
if aggregated.usage_details:
merged_usage = _sum_usage(merged_usage, aggregated.usage_details)
merged_usage = add_usage_details(merged_usage, aggregated.usage_details) # type: ignore[arg-type]
if aggregated.created_at and (
not latest_created_at or _parse_dt(aggregated.created_at) > _parse_dt(latest_created_at)
):
@@ -593,7 +581,7 @@ class WorkflowAgent(BaseAgent):
flattened = AgentResponse.from_agent_run_response_updates(global_dangling)
final_messages.extend(flattened.messages)
if flattened.usage_details:
merged_usage = _sum_usage(merged_usage, flattened.usage_details)
merged_usage = add_usage_details(merged_usage, flattened.usage_details) # type: ignore[arg-type]
if flattened.created_at and (
not latest_created_at or _parse_dt(flattened.created_at) > _parse_dt(latest_created_at)
):
@@ -5,7 +5,7 @@ import sys
from dataclasses import dataclass
from typing import Any, cast
from agent_framework import FunctionApprovalRequestContent, FunctionApprovalResponseContent
from agent_framework import Content
from .._agents import AgentProtocol, ChatAgent
from .._threads import AgentThread
@@ -95,8 +95,8 @@ class AgentExecutor(Executor):
super().__init__(exec_id)
self._agent = agent
self._agent_thread = agent_thread or self._agent.get_new_thread()
self._pending_agent_requests: dict[str, FunctionApprovalRequestContent] = {}
self._pending_responses_to_agent: list[FunctionApprovalResponseContent] = []
self._pending_agent_requests: dict[str, Content] = {}
self._pending_responses_to_agent: list[Content] = []
self._output_response = output_response
# AgentExecutor maintains an internal cache of messages in between runs
@@ -179,8 +179,8 @@ class AgentExecutor(Executor):
@response_handler
async def handle_user_input_response(
self,
original_request: FunctionApprovalRequestContent,
response: FunctionApprovalResponseContent,
original_request: Content,
response: Content,
ctx: WorkflowContext[AgentExecutorResponse, AgentResponse],
) -> None:
"""Handle user input responses for function approvals during agent execution.
@@ -193,7 +193,7 @@ class AgentExecutor(Executor):
ctx: The workflow context for emitting events and outputs.
"""
self._pending_responses_to_agent.append(response)
self._pending_agent_requests.pop(original_request.id, None)
self._pending_agent_requests.pop(original_request.id, None) # type: ignore[arg-type]
if not self._pending_agent_requests:
# All pending requests have been resolved; resume agent execution
@@ -344,8 +344,8 @@ class AgentExecutor(Executor):
# Handle any user input requests
if response.user_input_requests:
for user_input_request in response.user_input_requests:
self._pending_agent_requests[user_input_request.id] = user_input_request
await ctx.request_info(user_input_request, FunctionApprovalResponseContent)
self._pending_agent_requests[user_input_request.id] = user_input_request # type: ignore[index]
await ctx.request_info(user_input_request, Content)
return None
return response
@@ -362,7 +362,7 @@ class AgentExecutor(Executor):
run_kwargs: dict[str, Any] = await ctx.get_shared_state(WORKFLOW_RUN_KWARGS_KEY)
updates: list[AgentResponseUpdate] = []
user_input_requests: list[FunctionApprovalRequestContent] = []
user_input_requests: list[Content] = []
async for update in self._agent.run_stream(
self._cache,
thread=self._agent_thread,
@@ -387,8 +387,8 @@ class AgentExecutor(Executor):
# Handle any user input requests after the streaming completes
if user_input_requests:
for user_input_request in user_input_requests:
self._pending_agent_requests[user_input_request.id] = user_input_request
await ctx.request_info(user_input_request, FunctionApprovalResponseContent)
self._pending_agent_requests[user_input_request.id] = user_input_request # type: ignore[index]
await ctx.request_info(user_input_request, Content)
return None
return response
@@ -37,8 +37,6 @@ def clean_conversation_for_handoff(conversation: list[ChatMessage]) -> list[Chat
Returns:
Cleaned conversation safe for handoff routing
"""
from agent_framework import FunctionApprovalRequestContent, FunctionCallContent
cleaned: list[ChatMessage] = []
for msg in conversation:
# Skip tool response messages entirely
@@ -49,7 +47,7 @@ def clean_conversation_for_handoff(conversation: list[ChatMessage]) -> list[Chat
has_tool_content = False
if msg.contents:
has_tool_content = any(
isinstance(content, (FunctionApprovalRequestContent, FunctionCallContent)) for content in msg.contents
content.type in ("function_approval_request", "function_call") for content in msg.contents
)
# If no tool content, keep original
@@ -13,10 +13,10 @@ from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice
from pydantic import ValidationError
from agent_framework import (
Annotation,
ChatResponse,
ChatResponseUpdate,
CitationAnnotation,
TextContent,
Content,
use_chat_middleware,
use_function_invocation,
)
@@ -267,8 +267,8 @@ class AzureOpenAIChatClient(
)
@override
def _parse_text_from_openai(self, choice: Choice | ChunkChoice) -> TextContent | None:
"""Parse the choice into a TextContent object.
def _parse_text_from_openai(self, choice: Choice | ChunkChoice) -> Content | None:
"""Parse the choice into a Content object with type='text'.
Overwritten from OpenAIBaseChatClient to deal with Azure On Your Data function.
For docs see:
@@ -279,10 +279,10 @@ class AzureOpenAIChatClient(
if message is None: # type: ignore
return None
if hasattr(message, "refusal") and message.refusal:
return TextContent(text=message.refusal, raw_representation=choice)
return Content.from_text(text=message.refusal, raw_representation=choice)
if not message.content:
return None
text_content = TextContent(text=message.content, raw_representation=choice)
text_content = Content.from_text(text=message.content, raw_representation=choice)
if not message.model_extra or "context" not in message.model_extra:
return text_content
@@ -304,7 +304,8 @@ class AzureOpenAIChatClient(
text_content.annotations = []
for citation in citations:
text_content.annotations.append(
CitationAnnotation(
Annotation(
type="citation",
title=citation.get("title", ""),
url=citation.get("url", ""),
snippet=citation.get("content", ""),
@@ -40,7 +40,7 @@ if TYPE_CHECKING: # pragma: no cover
ChatMessage,
ChatResponse,
ChatResponseUpdate,
Contents,
Content,
FinishReason,
)
@@ -1750,8 +1750,10 @@ def _to_otel_message(message: "ChatMessage") -> dict[str, Any]:
return {"role": message.role.value, "parts": [_to_otel_part(content) for content in message.contents]}
def _to_otel_part(content: "Contents") -> dict[str, Any] | None:
def _to_otel_part(content: "Content") -> dict[str, Any] | None:
"""Create a otel representation of a Content."""
from ._types import _get_data_bytes_as_str
match content.type:
case "text":
return {"type": "text", "content": content.text}
@@ -1767,7 +1769,7 @@ def _to_otel_part(content: "Contents") -> dict[str, Any] | None:
case "data":
return {
"type": "blob",
"content": content.get_data_bytes_as_str(),
"content": _get_data_bytes_as_str(content),
"mime_type": content.media_type,
"modality": content.media_type.split("/")[0] if content.media_type else None,
}
@@ -1808,10 +1810,10 @@ def _get_response_attributes(
if model_id := getattr(response, "model_id", None):
attributes[SpanAttributes.LLM_RESPONSE_MODEL] = model_id
if capture_usage and (usage := response.usage_details):
if usage.input_token_count:
attributes[OtelAttr.INPUT_TOKENS] = usage.input_token_count
if usage.output_token_count:
attributes[OtelAttr.OUTPUT_TOKENS] = usage.output_token_count
if usage.get("input_token_count"):
attributes[OtelAttr.INPUT_TOKENS] = usage["input_token_count"]
if usage.get("output_token_count"):
attributes[OtelAttr.OUTPUT_TOKENS] = usage["output_token_count"]
if duration:
attributes[Meters.LLM_OPERATION_DURATION] = duration
return attributes
@@ -47,15 +47,8 @@ from .._types import (
ChatOptions,
ChatResponse,
ChatResponseUpdate,
CodeInterpreterToolCallContent,
Contents,
FunctionCallContent,
FunctionResultContent,
MCPServerToolCallContent,
Content,
Role,
TextContent,
UriContent,
UsageContent,
UsageDetails,
prepare_function_call_results,
)
@@ -416,7 +409,7 @@ class OpenAIAssistantsClient(
thread_id: str | None,
assistant_id: str,
run_options: dict[str, Any],
tool_results: list[FunctionResultContent] | None,
tool_results: list[Content] | None,
) -> tuple[Any, str]:
"""Create the assistant stream for processing.
@@ -526,7 +519,7 @@ class OpenAIAssistantsClient(
and response.data.usage is not None
):
usage = response.data.usage
usage_content = UsageContent(
usage_content = Content.from_usage(
UsageDetails(
input_token_count=usage.prompt_tokens,
output_token_count=usage.completion_tokens,
@@ -551,9 +544,9 @@ class OpenAIAssistantsClient(
role=Role.ASSISTANT,
)
def _parse_function_calls_from_assistants(self, event_data: Run, response_id: str | None) -> list[Contents]:
def _parse_function_calls_from_assistants(self, event_data: Run, response_id: str | None) -> list[Content]:
"""Parse function call contents from an assistants tool action event."""
contents: list[Contents] = []
contents: list[Content] = []
if event_data.required_action is not None:
for tool_call in event_data.required_action.submit_tool_outputs.tool_calls:
@@ -563,10 +556,12 @@ class OpenAIAssistantsClient(
if tool_type == "code_interpreter" and getattr(tool_call_any, "code_interpreter", None):
code_input = getattr(tool_call_any.code_interpreter, "input", None)
inputs = (
[TextContent(text=code_input, raw_representation=tool_call)] if code_input is not None else None
[Content.from_text(text=code_input, raw_representation=tool_call)]
if code_input is not None
else None
)
contents.append(
CodeInterpreterToolCallContent(
Content.from_code_interpreter_tool_call(
call_id=call_id,
inputs=inputs,
raw_representation=tool_call,
@@ -574,7 +569,7 @@ class OpenAIAssistantsClient(
)
elif tool_type == "mcp":
contents.append(
MCPServerToolCallContent(
Content.from_mcp_server_tool_call(
call_id=call_id,
tool_name=getattr(tool_call, "name", "") or "",
server_name=getattr(tool_call, "server_label", None),
@@ -586,7 +581,7 @@ class OpenAIAssistantsClient(
function_name = tool_call.function.name
function_arguments = json.loads(tool_call.function.arguments)
contents.append(
FunctionCallContent(
Content.from_function_call(
call_id=call_id,
name=function_name,
arguments=function_arguments,
@@ -600,7 +595,7 @@ class OpenAIAssistantsClient(
messages: MutableSequence[ChatMessage],
options: dict[str, Any],
**kwargs: Any,
) -> tuple[dict[str, Any], list[FunctionResultContent] | None]:
) -> tuple[dict[str, Any], list[Content] | None]:
from .._types import validate_tool_mode
run_options: dict[str, Any] = {**kwargs}
@@ -672,7 +667,7 @@ class OpenAIAssistantsClient(
}
instructions: list[str] = []
tool_results: list[FunctionResultContent] | None = None
tool_results: list[Content] | None = None
additional_messages: list[AdditionalMessage] | None = None
@@ -681,21 +676,23 @@ class OpenAIAssistantsClient(
# All other messages are added 1:1.
for chat_message in messages:
if chat_message.role.value in ["system", "developer"]:
for text_content in [content for content in chat_message.contents if isinstance(content, TextContent)]:
instructions.append(text_content.text)
for text_content in [content for content in chat_message.contents if content.type == "text"]:
text = getattr(text_content, "text", None)
if text:
instructions.append(text)
continue
message_contents: list[MessageContentPartParam] = []
for content in chat_message.contents:
if isinstance(content, TextContent):
message_contents.append(TextContentBlockParam(type="text", text=content.text))
elif isinstance(content, UriContent) and content.has_top_level_media_type("image"):
if content.type == "text":
message_contents.append(TextContentBlockParam(type="text", text=content.text)) # type: ignore[attr-defined, typeddict-item]
elif content.type == "uri" and content.has_top_level_media_type("image"):
message_contents.append(
ImageURLContentBlockParam(type="image_url", image_url=ImageURLParam(url=content.uri))
ImageURLContentBlockParam(type="image_url", image_url=ImageURLParam(url=content.uri)) # type: ignore[attr-defined, typeddict-item]
)
elif isinstance(content, FunctionResultContent):
elif content.type == "function_result":
if tool_results is None:
tool_results = []
tool_results.append(content)
@@ -720,7 +717,7 @@ class OpenAIAssistantsClient(
def _prepare_tool_outputs_for_assistants(
self,
tool_results: list[FunctionResultContent] | None,
tool_results: list[Content] | None,
) -> tuple[str | None, list[ToolOutput] | None]:
"""Prepare function results for submission to the assistants API."""
run_id: str | None = None
@@ -731,7 +728,7 @@ class OpenAIAssistantsClient(
# When creating the FunctionCallContent, we created it with a CallId == [runId, callId].
# We need to extract the run ID and ensure that the ToolOutput we send back to Azure
# is only the call ID.
run_and_call_ids: list[str] = json.loads(function_result_content.call_id)
run_and_call_ids: list[str] = json.loads(function_result_content.call_id) # type: ignore[arg-type]
if (
not run_and_call_ids
@@ -25,18 +25,9 @@ from .._types import (
ChatOptions,
ChatResponse,
ChatResponseUpdate,
Contents,
DataContent,
Content,
FinishReason,
FunctionApprovalRequestContent,
FunctionApprovalResponseContent,
FunctionCallContent,
FunctionResultContent,
Role,
TextContent,
TextReasoningContent,
UriContent,
UsageContent,
UsageDetails,
prepare_function_call_results,
)
@@ -294,13 +285,13 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient[TOpenAIChatOptions], Gener
response_metadata.update(self._get_metadata_from_chat_choice(choice))
if choice.finish_reason:
finish_reason = FinishReason(value=choice.finish_reason)
contents: list[Contents] = []
contents: list[Content] = []
if text_content := self._parse_text_from_openai(choice):
contents.append(text_content)
if parsed_tool_calls := [tool for tool in self._parse_tool_calls_from_openai(choice)]:
contents.extend(parsed_tool_calls)
if reasoning_details := getattr(choice.message, "reasoning_details", None):
contents.append(TextReasoningContent(None, protected_data=json.dumps(reasoning_details)))
contents.append(Content.from_text_reasoning(protected_data=json.dumps(reasoning_details)))
messages.append(ChatMessage(role="assistant", contents=contents))
return ChatResponse(
response_id=response.id,
@@ -322,13 +313,17 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient[TOpenAIChatOptions], Gener
if chunk.usage:
return ChatResponseUpdate(
role=Role.ASSISTANT,
contents=[UsageContent(details=self._parse_usage_from_openai(chunk.usage), raw_representation=chunk)],
contents=[
Content.from_usage(
usage_details=self._parse_usage_from_openai(chunk.usage), raw_representation=chunk
)
],
model_id=chunk.model,
additional_properties=chunk_metadata,
response_id=chunk.id,
message_id=chunk.id,
)
contents: list[Contents] = []
contents: list[Content] = []
finish_reason: FinishReason | None = None
for choice in chunk.choices:
chunk_metadata.update(self._get_metadata_from_chat_choice(choice))
@@ -339,7 +334,7 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient[TOpenAIChatOptions], Gener
if text_content := self._parse_text_from_openai(choice):
contents.append(text_content)
if reasoning_details := getattr(choice.delta, "reasoning_details", None):
contents.append(TextReasoningContent(None, protected_data=json.dumps(reasoning_details)))
contents.append(Content.from_text_reasoning(protected_data=json.dumps(reasoning_details)))
return ChatResponseUpdate(
created_at=datetime.fromtimestamp(chunk.created, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
contents=contents,
@@ -360,27 +355,27 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient[TOpenAIChatOptions], Gener
)
if usage.completion_tokens_details:
if tokens := usage.completion_tokens_details.accepted_prediction_tokens:
details["completion/accepted_prediction_tokens"] = tokens
details["completion/accepted_prediction_tokens"] = tokens # type: ignore[typeddict-unknown-key]
if tokens := usage.completion_tokens_details.audio_tokens:
details["completion/audio_tokens"] = tokens
details["completion/audio_tokens"] = tokens # type: ignore[typeddict-unknown-key]
if tokens := usage.completion_tokens_details.reasoning_tokens:
details["completion/reasoning_tokens"] = tokens
details["completion/reasoning_tokens"] = tokens # type: ignore[typeddict-unknown-key]
if tokens := usage.completion_tokens_details.rejected_prediction_tokens:
details["completion/rejected_prediction_tokens"] = tokens
details["completion/rejected_prediction_tokens"] = tokens # type: ignore[typeddict-unknown-key]
if usage.prompt_tokens_details:
if tokens := usage.prompt_tokens_details.audio_tokens:
details["prompt/audio_tokens"] = tokens
details["prompt/audio_tokens"] = tokens # type: ignore[typeddict-unknown-key]
if tokens := usage.prompt_tokens_details.cached_tokens:
details["prompt/cached_tokens"] = tokens
details["prompt/cached_tokens"] = tokens # type: ignore[typeddict-unknown-key]
return details
def _parse_text_from_openai(self, choice: Choice | ChunkChoice) -> TextContent | None:
"""Parse the choice into a TextContent object."""
def _parse_text_from_openai(self, choice: Choice | ChunkChoice) -> Content | None:
"""Parse the choice into a Content object with type='text'."""
message = choice.message if isinstance(choice, Choice) else choice.delta
if message.content:
return TextContent(text=message.content, raw_representation=choice)
return Content.from_text(text=message.content, raw_representation=choice)
if hasattr(message, "refusal") and message.refusal:
return TextContent(text=message.refusal, raw_representation=choice)
return Content.from_text(text=message.refusal, raw_representation=choice)
return None
def _get_metadata_from_chat_response(self, response: ChatCompletion) -> dict[str, Any]:
@@ -401,15 +396,15 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient[TOpenAIChatOptions], Gener
"logprobs": getattr(choice, "logprobs", None),
}
def _parse_tool_calls_from_openai(self, choice: Choice | ChunkChoice) -> list[Contents]:
def _parse_tool_calls_from_openai(self, choice: Choice | ChunkChoice) -> list[Content]:
"""Parse tool calls from an OpenAI response choice."""
resp: list[Contents] = []
resp: list[Content] = []
content = choice.message if isinstance(choice, Choice) else choice.delta
if content and content.tool_calls:
for tool in content.tool_calls:
if not isinstance(tool, ChatCompletionMessageCustomToolCall) and tool.function:
# ignoring tool.custom
fcc = FunctionCallContent(
fcc = Content.from_function_call(
call_id=tool.id if tool.id else "",
name=tool.function.name if tool.function.name else "",
arguments=tool.function.arguments if tool.function.arguments else "",
@@ -455,7 +450,7 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient[TOpenAIChatOptions], Gener
all_messages: list[dict[str, Any]] = []
for content in message.contents:
# Skip approval content - it's internal framework state, not for the LLM
if isinstance(content, (FunctionApprovalRequestContent, FunctionApprovalResponseContent)):
if content.type in ("function_approval_request", "function_approval_response"):
continue
args: dict[str, Any] = {
@@ -467,21 +462,21 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient[TOpenAIChatOptions], Gener
details := message.additional_properties["reasoning_details"]
):
args["reasoning_details"] = details
match content:
case FunctionCallContent():
match content.type:
case "function_call":
if all_messages and "tool_calls" in all_messages[-1]:
# If the last message already has tool calls, append to it
all_messages[-1]["tool_calls"].append(self._prepare_content_for_openai(content))
else:
args["tool_calls"] = [self._prepare_content_for_openai(content)] # type: ignore
case FunctionResultContent():
case "function_result":
args["tool_call_id"] = content.call_id
# Always include content for tool results - API requires it even if empty
# Functions returning None should still have a tool result message
args["content"] = (
prepare_function_call_results(content.result) if content.result is not None else ""
)
case TextReasoningContent(protected_data=protected_data) if protected_data is not None:
case "text_reasoning" if (protected_data := content.protected_data) is not None:
all_messages[-1]["reasoning_details"] = json.loads(protected_data)
case _:
if "content" not in args:
@@ -492,27 +487,27 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient[TOpenAIChatOptions], Gener
all_messages.append(args)
return all_messages
def _prepare_content_for_openai(self, content: Contents) -> dict[str, Any]:
def _prepare_content_for_openai(self, content: Content) -> dict[str, Any]:
"""Prepare content for OpenAI."""
match content:
case FunctionCallContent():
match content.type:
case "function_call":
args = json.dumps(content.arguments) if isinstance(content.arguments, Mapping) else content.arguments
return {
"id": content.call_id,
"type": "function",
"function": {"name": content.name, "arguments": args},
}
case FunctionResultContent():
case "function_result":
return {
"tool_call_id": content.call_id,
"content": content.result,
}
case DataContent() | UriContent() if content.has_top_level_media_type("image"):
case "data" | "uri" if content.has_top_level_media_type("image"):
return {
"type": "image_url",
"image_url": {"url": content.uri},
}
case DataContent() | UriContent() if content.has_top_level_media_type("audio"):
case "data" | "uri" if content.has_top_level_media_type("audio"):
if content.media_type and "wav" in content.media_type:
audio_format = "wav"
elif content.media_type and "mp3" in content.media_type:
@@ -523,9 +518,9 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient[TOpenAIChatOptions], Gener
# Extract base64 data from data URI
audio_data = content.uri
if audio_data.startswith("data:"):
if audio_data.startswith("data:"): # type: ignore[union-attr]
# Extract just the base64 part after "data:audio/format;base64,"
audio_data = audio_data.split(",", 1)[-1]
audio_data = audio_data.split(",", 1)[-1] # type: ignore[union-attr]
return {
"type": "input_audio",
@@ -534,9 +529,7 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient[TOpenAIChatOptions], Gener
"format": audio_format,
},
}
case DataContent() | UriContent() if content.has_top_level_media_type(
"application"
) and content.uri.startswith("data:"):
case "data" | "uri" if content.has_top_level_media_type("application") and content.uri.startswith("data:"): # type: ignore[union-attr]
# All application/* media types should be treated as files for OpenAI
filename = getattr(content, "filename", None) or (
content.additional_properties.get("filename")
@@ -48,33 +48,16 @@ from .._tools import (
use_function_invocation,
)
from .._types import (
Annotation,
ChatMessage,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
CitationAnnotation,
CodeInterpreterToolCallContent,
CodeInterpreterToolResultContent,
Contents,
DataContent,
FunctionApprovalRequestContent,
FunctionApprovalResponseContent,
FunctionCallContent,
FunctionResultContent,
HostedFileContent,
HostedVectorStoreContent,
ImageGenerationToolCallContent,
ImageGenerationToolResultContent,
MCPServerToolCallContent,
MCPServerToolResultContent,
Content,
Role,
TextContent,
TextReasoningContent,
TextSpanRegion,
UriContent,
UsageContent,
UsageDetails,
_parse_content,
detect_media_type_from_base64,
prepare_function_call_results,
prepend_instructions_to_messages,
validate_tool_mode,
@@ -231,7 +214,6 @@ class OpenAIBaseResponsesClient(
response = await client.responses.parse(stream=False, **run_options)
else:
response = await client.responses.create(stream=False, **run_options)
return self._parse_response_from_openai(response, options=options)
except BadRequestError as ex:
if ex.code == "content_filter":
raise OpenAIContentFilterException(
@@ -247,6 +229,7 @@ class OpenAIBaseResponsesClient(
f"{type(self)} service failed to complete the prompt: {ex}",
inner_exception=ex,
) from ex
return self._parse_response_from_openai(response, options=options)
@override
async def _inner_get_streaming_response(
@@ -391,7 +374,7 @@ class OpenAIBaseResponsesClient(
if tool.inputs:
tool_args["file_ids"] = []
for tool_input in tool.inputs:
if isinstance(tool_input, HostedFileContent):
if tool_input.type == "hosted_file":
tool_args["file_ids"].append(tool_input.file_id) # type: ignore[attr-defined]
if not tool_args["file_ids"]:
tool_args.pop("file_ids")
@@ -417,7 +400,9 @@ class OpenAIBaseResponsesClient(
if not tool.inputs:
raise ValueError("HostedFileSearchTool requires inputs to be specified.")
inputs: list[str] = [
inp.vector_store_id for inp in tool.inputs if isinstance(inp, HostedVectorStoreContent)
inp.vector_store_id # type: ignore[misc]
for inp in tool.inputs
if inp.type == "hosted_vector_store" # type: ignore[attr-defined]
]
if not inputs:
raise ValueError(
@@ -629,11 +614,11 @@ class OpenAIBaseResponsesClient(
for message in chat_messages:
for content in message.contents:
if (
isinstance(content, FunctionCallContent)
content.type == "function_call"
and content.additional_properties
and "fc_id" in content.additional_properties
):
call_id_to_id[content.call_id] = content.additional_properties["fc_id"]
call_id_to_id[content.call_id] = content.additional_properties["fc_id"] # type: ignore[attr-defined, index]
list_of_list = [self._prepare_message_for_openai(message, call_id_to_id) for message in chat_messages]
# Flatten the list of lists into a single list
return list(chain.from_iterable(list_of_list))
@@ -649,18 +634,18 @@ class OpenAIBaseResponsesClient(
"role": message.role.value if isinstance(message.role, Role) else message.role,
}
for content in message.contents:
match content:
case TextReasoningContent():
match content.type:
case "text_reasoning":
# Don't send reasoning content back to model
continue
case FunctionResultContent():
case "function_result":
new_args: dict[str, Any] = {}
new_args.update(self._prepare_content_for_openai(message.role, content, call_id_to_id))
all_messages.append(new_args)
case FunctionCallContent():
case "function_call":
function_call = self._prepare_content_for_openai(message.role, content, call_id_to_id)
all_messages.append(function_call) # type: ignore
case FunctionApprovalResponseContent() | FunctionApprovalRequestContent():
case "function_approval_response" | "function_approval_request":
all_messages.append(self._prepare_content_for_openai(message.role, content, call_id_to_id)) # type: ignore
case _:
if "content" not in args:
@@ -673,17 +658,17 @@ class OpenAIBaseResponsesClient(
def _prepare_content_for_openai(
self,
role: Role,
content: Contents,
content: Content,
call_id_to_id: dict[str, str],
) -> dict[str, Any]:
"""Prepare content for the OpenAI Responses API format."""
match content:
case TextContent():
match content.type:
case "text":
return {
"type": "output_text" if role == Role.ASSISTANT else "input_text",
"text": content.text,
}
case TextReasoningContent():
case "text_reasoning":
ret: dict[str, Any] = {
"type": "reasoning",
"summary": {
@@ -703,7 +688,7 @@ class OpenAIBaseResponsesClient(
if encrypted_content := props.get("encrypted_content"):
ret["encrypted_content"] = encrypted_content
return ret
case DataContent() | UriContent():
case "data" | "uri":
if content.has_top_level_media_type("image"):
return {
"type": "input_image",
@@ -744,7 +729,7 @@ class OpenAIBaseResponsesClient(
file_obj["filename"] = filename
return file_obj
return {}
case FunctionCallContent():
case "function_call":
if not content.call_id:
logger.warning(f"FunctionCallContent missing call_id for function '{content.name}'")
return {}
@@ -761,7 +746,7 @@ class OpenAIBaseResponsesClient(
"arguments": content.arguments,
"status": None,
}
case FunctionResultContent():
case "function_result":
# call_id for the result needs to be the same as the call_id for the function call
args: dict[str, Any] = {
"call_id": content.call_id,
@@ -769,29 +754,29 @@ class OpenAIBaseResponsesClient(
"output": prepare_function_call_results(content.result),
}
return args
case FunctionApprovalRequestContent():
case "function_approval_request":
return {
"type": "mcp_approval_request",
"id": content.id,
"arguments": content.function_call.arguments,
"name": content.function_call.name,
"server_label": content.function_call.additional_properties.get("server_label")
if content.function_call.additional_properties
"id": content.id, # type: ignore[union-attr]
"arguments": content.function_call.arguments, # type: ignore[union-attr]
"name": content.function_call.name, # type: ignore[union-attr]
"server_label": content.function_call.additional_properties.get("server_label") # type: ignore[union-attr]
if content.function_call.additional_properties # type: ignore[union-attr]
else None,
}
case FunctionApprovalResponseContent():
case "function_approval_response":
return {
"type": "mcp_approval_response",
"approval_request_id": content.id,
"approve": content.approved,
}
case HostedFileContent():
case "hosted_file":
return {
"type": "input_file",
"file_id": content.file_id,
}
case _: # should catch UsageDetails and ErrorContent and HostedVectorStoreContent
logger.debug("Unsupported content type passed (type: %s)", type(content))
logger.debug("Unsupported content type passed (type: %s)", content.type)
return {}
# region Parse methods
@@ -804,7 +789,7 @@ class OpenAIBaseResponsesClient(
structured_response: BaseModel | None = response.output_parsed if isinstance(response, ParsedResponse) else None # type: ignore[reportUnknownMemberType]
metadata: dict[str, Any] = response.metadata or {}
contents: list[Contents] = []
contents: list[Content] = []
for item in response.output: # type: ignore[reportUnknownMemberType]
match item.type:
# types:
@@ -829,7 +814,7 @@ class OpenAIBaseResponsesClient(
for message_content in item.content: # type: ignore[reportMissingTypeArgument]
match message_content.type:
case "output_text":
text_content = TextContent(
text_content = Content.from_text(
text=message_content.text,
raw_representation=message_content, # type: ignore[reportUnknownArgumentType]
)
@@ -840,7 +825,8 @@ class OpenAIBaseResponsesClient(
match annotation.type:
case "file_path":
text_content.annotations.append(
CitationAnnotation(
Annotation(
type="citation",
file_id=annotation.file_id,
additional_properties={
"index": annotation.index,
@@ -850,7 +836,8 @@ class OpenAIBaseResponsesClient(
)
case "file_citation":
text_content.annotations.append(
CitationAnnotation(
Annotation(
type="citation",
url=annotation.filename,
file_id=annotation.file_id,
raw_representation=annotation,
@@ -861,11 +848,13 @@ class OpenAIBaseResponsesClient(
)
case "url_citation":
text_content.annotations.append(
CitationAnnotation(
Annotation(
type="citation",
title=annotation.title,
url=annotation.url,
annotated_regions=[
TextSpanRegion(
type="text_span",
start_index=annotation.start_index,
end_index=annotation.end_index,
)
@@ -875,7 +864,8 @@ class OpenAIBaseResponsesClient(
)
case "container_file_citation":
text_content.annotations.append(
CitationAnnotation(
Annotation(
type="citation",
file_id=annotation.file_id,
url=annotation.filename,
additional_properties={
@@ -883,6 +873,7 @@ class OpenAIBaseResponsesClient(
},
annotated_regions=[
TextSpanRegion(
type="text_span",
start_index=annotation.start_index,
end_index=annotation.end_index,
)
@@ -898,7 +889,7 @@ class OpenAIBaseResponsesClient(
contents.append(text_content)
case "refusal":
contents.append(
TextContent(
Content.from_text(
text=message_content.refusal,
raw_representation=message_content,
)
@@ -910,7 +901,7 @@ class OpenAIBaseResponsesClient(
if hasattr(item, "summary") and item.summary and index < len(item.summary):
additional_properties = {"summary": item.summary[index]}
contents.append(
TextReasoningContent(
Content.from_text_reasoning(
text=reasoning_content.text,
raw_representation=reasoning_content,
additional_properties=additional_properties,
@@ -919,23 +910,23 @@ class OpenAIBaseResponsesClient(
if hasattr(item, "summary") and item.summary:
for summary in item.summary:
contents.append(
TextReasoningContent(text=summary.text, raw_representation=summary) # type: ignore[arg-type]
Content.from_text_reasoning(text=summary.text, raw_representation=summary) # type: ignore[arg-type]
)
case "code_interpreter_call": # ResponseOutputCodeInterpreterCall
call_id = getattr(item, "call_id", None) or getattr(item, "id", None)
outputs: list["Contents"] = []
outputs: list["Content"] = []
if item_outputs := getattr(item, "outputs", None):
for code_output in item_outputs:
if getattr(code_output, "type", None) == "logs":
outputs.append(
TextContent(
Content.from_text(
text=code_output.logs,
raw_representation=code_output,
)
)
elif getattr(code_output, "type", None) == "image":
outputs.append(
UriContent(
Content.from_uri(
uri=code_output.url,
raw_representation=code_output,
media_type="image",
@@ -943,14 +934,14 @@ class OpenAIBaseResponsesClient(
)
if code := getattr(item, "code", None):
contents.append(
CodeInterpreterToolCallContent(
Content.from_code_interpreter_tool_call(
call_id=call_id,
inputs=[TextContent(text=code, raw_representation=item)],
inputs=[Content.from_text(text=code, raw_representation=item)],
raw_representation=item,
)
)
contents.append(
CodeInterpreterToolResultContent(
Content.from_code_interpreter_tool_result(
call_id=call_id,
outputs=outputs,
raw_representation=item,
@@ -958,7 +949,7 @@ class OpenAIBaseResponsesClient(
)
case "function_call": # ResponseOutputFunctionCall
contents.append(
FunctionCallContent(
Content.from_function_call(
call_id=item.call_id if hasattr(item, "call_id") and item.call_id else "",
name=item.name if hasattr(item, "name") else "",
arguments=item.arguments if hasattr(item, "arguments") else "",
@@ -968,9 +959,9 @@ class OpenAIBaseResponsesClient(
)
case "mcp_approval_request": # ResponseOutputMcpApprovalRequest
contents.append(
FunctionApprovalRequestContent(
Content.from_function_approval_request(
id=item.id,
function_call=FunctionCallContent(
function_call=Content.from_function_call(
call_id=item.id,
name=item.name,
arguments=item.arguments,
@@ -982,7 +973,7 @@ class OpenAIBaseResponsesClient(
case "mcp_call":
call_id = item.id
contents.append(
MCPServerToolCallContent(
Content.from_mcp_server_tool_call(
call_id=call_id,
tool_name=item.name,
server_name=item.server_label,
@@ -992,31 +983,31 @@ class OpenAIBaseResponsesClient(
)
if item.output is not None:
contents.append(
MCPServerToolResultContent(
Content.from_mcp_server_tool_result(
call_id=call_id,
output=[TextContent(text=item.output)],
output=[Content.from_text(text=item.output)],
raw_representation=item,
)
)
case "image_generation_call": # ResponseOutputImageGenerationCall
image_output: DataContent | None = None
if item.result:
base64_data = item.result
image_format = DataContent.detect_image_format_from_base64(base64_data)
image_output = DataContent(
data=base64_data,
media_type=f"image/{image_format}" if image_format else "image/png",
image_output: Content | None = None
if item.result is not None:
# item.result contains raw base64 string
# so we call detect_media_type_from_base64 to get the media type and fallback to image/png
image_output = Content.from_uri(
uri=f"data:{detect_media_type_from_base64(data_str=item.result) or 'image/png'}"
f";base64,{item.result}",
raw_representation=item.result,
)
image_id = item.id
contents.append(
ImageGenerationToolCallContent(
Content.from_image_generation_tool_call(
image_id=image_id,
raw_representation=item,
)
)
contents.append(
ImageGenerationToolResultContent(
Content.from_image_generation_tool_result(
image_id=image_id,
outputs=image_output,
raw_representation=item,
@@ -1056,11 +1047,10 @@ class OpenAIBaseResponsesClient(
) -> ChatResponseUpdate:
"""Parse an OpenAI Responses API streaming event into a ChatResponseUpdate."""
metadata: dict[str, Any] = {}
contents: list[Contents] = []
contents: list[Content] = []
conversation_id: str | None = None
response_id: str | None = None
model = self.model_id
# TODO(peterychang): Add support for other content types
match event.type:
# types:
# ResponseAudioDeltaEvent,
@@ -1120,26 +1110,26 @@ class OpenAIBaseResponsesClient(
event_part = event.part
match event_part.type:
case "output_text":
contents.append(TextContent(text=event_part.text, raw_representation=event))
contents.append(Content.from_text(text=event_part.text, raw_representation=event))
metadata.update(self._get_metadata_from_response(event_part))
case "refusal":
contents.append(TextContent(text=event_part.refusal, raw_representation=event))
contents.append(Content.from_text(text=event_part.refusal, raw_representation=event))
case _:
pass
case "response.output_text.delta":
contents.append(TextContent(text=event.delta, raw_representation=event))
contents.append(Content.from_text(text=event.delta, raw_representation=event))
metadata.update(self._get_metadata_from_response(event))
case "response.reasoning_text.delta":
contents.append(TextReasoningContent(text=event.delta, raw_representation=event))
contents.append(Content.from_text_reasoning(text=event.delta, raw_representation=event))
metadata.update(self._get_metadata_from_response(event))
case "response.reasoning_text.done":
contents.append(TextReasoningContent(text=event.text, raw_representation=event))
contents.append(Content.from_text_reasoning(text=event.text, raw_representation=event))
metadata.update(self._get_metadata_from_response(event))
case "response.reasoning_summary_text.delta":
contents.append(TextReasoningContent(text=event.delta, raw_representation=event))
contents.append(Content.from_text_reasoning(text=event.delta, raw_representation=event))
metadata.update(self._get_metadata_from_response(event))
case "response.reasoning_summary_text.done":
contents.append(TextReasoningContent(text=event.text, raw_representation=event))
contents.append(Content.from_text_reasoning(text=event.text, raw_representation=event))
metadata.update(self._get_metadata_from_response(event))
case "response.created":
response_id = event.response.id
@@ -1154,7 +1144,7 @@ class OpenAIBaseResponsesClient(
if event.response.usage:
usage = self._parse_usage_from_openai(event.response.usage)
if usage:
contents.append(UsageContent(details=usage, raw_representation=event))
contents.append(Content.from_usage(usage_details=usage, raw_representation=event))
case "response.output_item.added":
event_item = event.item
match event_item.type:
@@ -1179,9 +1169,9 @@ class OpenAIBaseResponsesClient(
)
case "mcp_approval_request":
contents.append(
FunctionApprovalRequestContent(
Content.from_function_approval_request(
id=event_item.id,
function_call=FunctionCallContent(
function_call=Content.from_function_call(
call_id=event_item.id,
name=event_item.name,
arguments=event_item.arguments,
@@ -1193,7 +1183,7 @@ class OpenAIBaseResponsesClient(
case "mcp_call":
call_id = getattr(event_item, "id", None) or getattr(event_item, "call_id", None) or ""
contents.append(
MCPServerToolCallContent(
Content.from_mcp_server_tool_call(
call_id=call_id,
tool_name=getattr(event_item, "name", "") or "",
server_name=getattr(event_item, "server_label", None),
@@ -1206,7 +1196,7 @@ class OpenAIBaseResponsesClient(
or getattr(event_item, "output", None)
or getattr(event_item, "outputs", None)
)
parsed_output: list[Contents] | None = None
parsed_output: list[Content] | None = None
if result_output:
normalized = (
result_output
@@ -1214,9 +1204,9 @@ class OpenAIBaseResponsesClient(
and not isinstance(result_output, (str, bytes, MutableMapping))
else [result_output]
)
parsed_output = [_parse_content(output_item) for output_item in normalized]
parsed_output = [Content.from_dict(output_item) for output_item in normalized]
contents.append(
MCPServerToolResultContent(
Content.from_mcp_server_tool_result(
call_id=call_id,
output=parsed_output,
raw_representation=event_item,
@@ -1224,19 +1214,19 @@ class OpenAIBaseResponsesClient(
)
case "code_interpreter_call": # ResponseOutputCodeInterpreterCall
call_id = getattr(event_item, "call_id", None) or getattr(event_item, "id", None)
outputs: list[Contents] = []
outputs: list[Content] = []
if hasattr(event_item, "outputs") and event_item.outputs:
for code_output in event_item.outputs:
if getattr(code_output, "type", None) == "logs":
outputs.append(
TextContent(
Content.from_text(
text=cast(Any, code_output).logs,
raw_representation=code_output,
)
)
elif getattr(code_output, "type", None) == "image":
outputs.append(
UriContent(
Content.from_uri(
uri=cast(Any, code_output).url,
raw_representation=code_output,
media_type="image",
@@ -1244,10 +1234,10 @@ class OpenAIBaseResponsesClient(
)
if hasattr(event_item, "code") and event_item.code:
contents.append(
CodeInterpreterToolCallContent(
Content.from_code_interpreter_tool_call(
call_id=call_id,
inputs=[
TextContent(
Content.from_text(
text=event_item.code,
raw_representation=event_item,
)
@@ -1256,7 +1246,7 @@ class OpenAIBaseResponsesClient(
)
)
contents.append(
CodeInterpreterToolResultContent(
Content.from_code_interpreter_tool_result(
call_id=call_id,
outputs=outputs,
raw_representation=event_item,
@@ -1273,7 +1263,7 @@ class OpenAIBaseResponsesClient(
):
additional_properties = {"summary": event_item.summary[index]}
contents.append(
TextReasoningContent(
Content.from_text_reasoning(
text=reasoning_content.text,
raw_representation=reasoning_content,
additional_properties=additional_properties,
@@ -1285,7 +1275,7 @@ class OpenAIBaseResponsesClient(
call_id, name = function_call_ids.get(event.output_index, (None, None))
if call_id and name:
contents.append(
FunctionCallContent(
Content.from_function_call(
call_id=call_id,
name=name,
arguments=event.delta,
@@ -1300,13 +1290,9 @@ class OpenAIBaseResponsesClient(
# Handle streaming partial image generation
image_base64 = event.partial_image_b64
partial_index = event.partial_image_index
# Use helper function to create data URI from base64
uri, media_type = DataContent.create_data_uri_from_base64(image_base64)
image_output = DataContent(
uri=uri,
media_type=media_type,
image_output = Content.from_uri(
uri=f"data:{detect_media_type_from_base64(data_str=image_base64) or 'image/png'}"
f";base64,{image_base64}",
additional_properties={
"partial_image_index": partial_index,
"is_partial_image": True,
@@ -1316,13 +1302,13 @@ class OpenAIBaseResponsesClient(
image_id = getattr(event, "item_id", None)
contents.append(
ImageGenerationToolCallContent(
Content.from_image_generation_tool_call(
image_id=image_id,
raw_representation=event,
)
)
contents.append(
ImageGenerationToolResultContent(
Content.from_image_generation_tool_result(
image_id=image_id,
outputs=image_output,
raw_representation=event,
@@ -1343,7 +1329,7 @@ class OpenAIBaseResponsesClient(
if ann_type == "file_path":
if ann_file_id:
contents.append(
HostedFileContent(
Content.from_hosted_file(
file_id=str(ann_file_id),
additional_properties={
"annotation_index": event.annotation_index,
@@ -1355,7 +1341,7 @@ class OpenAIBaseResponsesClient(
elif ann_type == "file_citation":
if ann_file_id:
contents.append(
HostedFileContent(
Content.from_hosted_file(
file_id=str(ann_file_id),
additional_properties={
"annotation_index": event.annotation_index,
@@ -1368,7 +1354,7 @@ class OpenAIBaseResponsesClient(
elif ann_type == "container_file_citation":
if ann_file_id:
contents.append(
HostedFileContent(
Content.from_hosted_file(
file_id=str(ann_file_id),
additional_properties={
"annotation_index": event.annotation_index,
@@ -1402,9 +1388,9 @@ class OpenAIBaseResponsesClient(
total_token_count=usage.total_tokens,
)
if usage.input_tokens_details and usage.input_tokens_details.cached_tokens:
details["openai.cached_input_tokens"] = usage.input_tokens_details.cached_tokens
details["openai.cached_input_tokens"] = usage.input_tokens_details.cached_tokens # type: ignore[typeddict-unknown-key]
if usage.output_tokens_details and usage.output_tokens_details.reasoning_tokens:
details["openai.reasoning_tokens"] = usage.output_tokens_details.reasoning_tokens
details["openai.reasoning_tokens"] = usage.output_tokens_details.reasoning_tokens # type: ignore[typeddict-unknown-key]
return details
def _get_metadata_from_response(self, output: Any) -> dict[str, Any]:
@@ -18,7 +18,6 @@ from agent_framework import (
ChatResponse,
ChatResponseUpdate,
HostedCodeInterpreterTool,
TextContent,
)
from agent_framework.azure import AzureOpenAIAssistantsClient
from agent_framework.exceptions import ServiceInitializationError
@@ -332,7 +331,7 @@ async def test_azure_assistants_client_streaming() -> None:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
if content.type == "text" and content.text:
full_message += content.text
assert any(word in full_message.lower() for word in ["sunny", "25", "weather", "seattle"])
@@ -358,7 +357,7 @@ async def test_azure_assistants_client_streaming_tools() -> None:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
if content.type == "text" and content.text:
full_message += content.text
assert any(word in full_message.lower() for word in ["sunny", "25", "weather"])
@@ -25,7 +25,6 @@ from agent_framework import (
ChatMessage,
ChatResponse,
ChatResponseUpdate,
TextContent,
ai_function,
)
from agent_framework._telemetry import USER_AGENT_KEY
@@ -304,9 +303,9 @@ async def test_azure_on_your_data(
)
assert len(content.messages) == 1
assert len(content.messages[0].contents) == 1
assert isinstance(content.messages[0].contents[0], TextContent)
assert content.messages[0].contents[0].type == "text"
assert len(content.messages[0].contents[0].annotations) == 1
assert content.messages[0].contents[0].annotations[0].title == "test title"
assert content.messages[0].contents[0].annotations[0]["title"] == "test title"
assert content.messages[0].contents[0].text == "test"
mock_create.assert_awaited_once_with(
@@ -374,9 +373,9 @@ async def test_azure_on_your_data_string(
)
assert len(content.messages) == 1
assert len(content.messages[0].contents) == 1
assert isinstance(content.messages[0].contents[0], TextContent)
assert content.messages[0].contents[0].type == "text"
assert len(content.messages[0].contents[0].annotations) == 1
assert content.messages[0].contents[0].annotations[0].title == "test title"
assert content.messages[0].contents[0].annotations[0]["title"] == "test title"
assert content.messages[0].contents[0].text == "test"
mock_create.assert_awaited_once_with(
@@ -433,7 +432,7 @@ async def test_azure_on_your_data_fail(
)
assert len(content.messages) == 1
assert len(content.messages[0].contents) == 1
assert isinstance(content.messages[0].contents[0], TextContent)
assert content.messages[0].contents[0].type == "text"
assert content.messages[0].contents[0].text == "test"
mock_create.assert_awaited_once_with(
@@ -628,9 +627,7 @@ async def test_streaming_with_none_delta(
results.append(msg)
assert len(results) > 0
assert any(
isinstance(content, TextContent) and content.text == "test" for msg in results for content in msg.contents
)
assert any(content.type == "text" and content.text == "test" for msg in results for content in msg.contents)
assert any(msg.contents for msg in results)
@@ -731,7 +728,7 @@ async def test_azure_openai_chat_client_streaming() -> None:
assert chunk.message_id is not None
assert chunk.response_id is not None
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
if content.type == "text" and content.text:
full_message += content.text
assert "Emily" in full_message or "David" in full_message
@@ -757,7 +754,7 @@ async def test_azure_openai_chat_client_streaming_tools() -> None:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
if content.type == "text" and content.text:
full_message += content.text
assert "Emily" in full_message or "David" in full_message
@@ -15,10 +15,10 @@ from agent_framework import (
ChatClientProtocol,
ChatMessage,
ChatResponse,
Content,
HostedCodeInterpreterTool,
HostedFileSearchTool,
HostedMCPTool,
HostedVectorStoreContent,
HostedWebSearchTool,
ai_function,
)
@@ -48,7 +48,7 @@ async def get_weather(location: Annotated[str, "The location as a city name"]) -
return f"The weather in {location} is sunny and 72°F."
async def create_vector_store(client: AzureOpenAIResponsesClient) -> tuple[str, HostedVectorStoreContent]:
async def create_vector_store(client: AzureOpenAIResponsesClient) -> tuple[str, Content]:
"""Create a vector store with sample documents for testing."""
file = await client.client.files.create(
file=("todays_weather.txt", b"The weather today is sunny with a high of 75F."), purpose="assistants"
@@ -61,7 +61,7 @@ async def create_vector_store(client: AzureOpenAIResponsesClient) -> tuple[str,
if result.last_error is not None:
raise Exception(f"Vector store file processing failed with status: {result.last_error.message}")
return file.id, HostedVectorStoreContent(vector_store_id=vector_store.id)
return file.id, Content.from_hosted_vector_store(vector_store_id=vector_store.id)
async def delete_vector_store(client: AzureOpenAIResponsesClient, file_id: str, vector_store_id: str) -> None:
+5 -5
View File
@@ -20,8 +20,8 @@ from agent_framework import (
ChatMessage,
ChatResponse,
ChatResponseUpdate,
Content,
Role,
TextContent,
ToolProtocol,
ai_function,
use_chat_middleware,
@@ -108,8 +108,8 @@ class MockChatClient:
for update in self.streaming_responses.pop(0):
yield update
else:
yield ChatResponseUpdate(text=TextContent(text="test streaming response "), role="assistant")
yield ChatResponseUpdate(contents=[TextContent(text="another update")], role="assistant")
yield ChatResponseUpdate(text=Content.from_text(text="test streaming response "), role="assistant")
yield ChatResponseUpdate(contents=[Content.from_text(text="another update")], role="assistant")
@use_chat_middleware
@@ -233,7 +233,7 @@ class MockAgent(AgentProtocol):
**kwargs: Any,
) -> AgentResponse:
logger.debug(f"Running mock agent, with: {messages=}, {thread=}, {kwargs=}")
return AgentResponse(messages=[ChatMessage(role=Role.ASSISTANT, contents=[TextContent("Response")])])
return AgentResponse(messages=[ChatMessage(role=Role.ASSISTANT, contents=[Content.from_text("Response")])])
async def run_stream(
self,
@@ -243,7 +243,7 @@ class MockAgent(AgentProtocol):
**kwargs: Any,
) -> AsyncIterable[AgentResponseUpdate]:
logger.debug(f"Running mock agent stream, with: {messages=}, {thread=}, {kwargs=}")
yield AgentResponseUpdate(contents=[TextContent("Response")])
yield AgentResponseUpdate(contents=[Content.from_text("Response")])
def get_new_thread(self) -> AgentThread:
return MockAgentThread()
+10 -7
View File
@@ -18,12 +18,11 @@ from agent_framework import (
ChatMessage,
ChatMessageStore,
ChatResponse,
Content,
Context,
ContextProvider,
FunctionCallContent,
HostedCodeInterpreterTool,
Role,
TextContent,
ai_function,
)
from agent_framework._mcp import MCPTool
@@ -136,7 +135,7 @@ async def test_prepare_thread_does_not_mutate_agent_chat_options(chat_client: Ch
async def test_chat_client_agent_update_thread_id(chat_client_base: ChatClientProtocol) -> None:
mock_response = ChatResponse(
messages=[ChatMessage(role=Role.ASSISTANT, contents=[TextContent("test response")])],
messages=[ChatMessage(role=Role.ASSISTANT, contents=[Content.from_text("test response")])],
conversation_id="123",
)
chat_client_base.run_responses = [mock_response]
@@ -200,7 +199,9 @@ async def test_chat_client_agent_author_name_is_used_from_response(chat_client_b
chat_client_base.run_responses = [
ChatResponse(
messages=[
ChatMessage(role=Role.ASSISTANT, contents=[TextContent("test response")], author_name="TestAuthor")
ChatMessage(
role=Role.ASSISTANT, contents=[Content.from_text("test response")], author_name="TestAuthor"
)
]
)
]
@@ -264,7 +265,7 @@ async def test_chat_agent_context_providers_thread_created(chat_client_base: Cha
mock_provider = MockContextProvider()
chat_client_base.run_responses = [
ChatResponse(
messages=[ChatMessage(role=Role.ASSISTANT, contents=[TextContent("test response")])],
messages=[ChatMessage(role=Role.ASSISTANT, contents=[Content.from_text("test response")])],
conversation_id="test-thread-id",
)
]
@@ -345,7 +346,7 @@ async def test_chat_agent_context_providers_with_thread_service_id(chat_client_b
mock_provider = MockContextProvider()
chat_client_base.run_responses = [
ChatResponse(
messages=[ChatMessage(role=Role.ASSISTANT, contents=[TextContent("test response")])],
messages=[ChatMessage(role=Role.ASSISTANT, contents=[Content.from_text("test response")])],
conversation_id="service-thread-123",
)
]
@@ -575,7 +576,9 @@ async def test_agent_tool_receives_thread_in_kwargs(chat_client_base: Any) -> No
ChatResponse(
messages=ChatMessage(
role="assistant",
contents=[FunctionCallContent(call_id="1", name="echo_thread_info", arguments='{"text": "hello"}')],
contents=[
Content.from_function_call(call_id="1", name="echo_thread_info", arguments='{"text": "hello"}')
],
)
),
ChatResponse(messages=ChatMessage(role="assistant", text="done")),
@@ -5,7 +5,7 @@
from collections.abc import Awaitable, Callable
from typing import Any
from agent_framework import ChatAgent, ChatMessage, ChatResponse, FunctionCallContent, agent_middleware
from agent_framework import ChatAgent, ChatMessage, ChatResponse, Content, agent_middleware
from agent_framework._middleware import AgentRunContext
from .conftest import MockChatClient
@@ -113,7 +113,7 @@ class TestAsToolKwargsPropagation:
ChatMessage(
role="assistant",
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="call_c_1",
name="call_c",
arguments='{"task": "Please execute agent_c"}',
@@ -170,10 +170,10 @@ class TestAsToolKwargsPropagation:
await next(context)
# Setup mock streaming responses
from agent_framework import ChatResponseUpdate, TextContent
from agent_framework import ChatResponseUpdate
chat_client.streaming_responses = [
[ChatResponseUpdate(text=TextContent(text="Streaming response"), role="assistant")],
[ChatResponseUpdate(text=Content.from_text(text="Streaming response"), role="assistant")],
]
sub_agent = ChatAgent(
File diff suppressed because it is too large Load Diff
@@ -8,8 +8,7 @@ from agent_framework import (
ChatMessage,
ChatResponse,
ChatResponseUpdate,
FunctionCallContent,
TextContent,
Content,
ai_function,
)
from agent_framework._tools import _handle_function_calls_response, _handle_function_calls_streaming_response
@@ -42,7 +41,9 @@ class TestKwargsPropagationToAIFunction:
ChatMessage(
role="assistant",
contents=[
FunctionCallContent(call_id="call_1", name="capture_kwargs_tool", arguments='{"x": 42}')
Content.from_function_call(
call_id="call_1", name="capture_kwargs_tool", arguments='{"x": 42}'
)
],
)
]
@@ -94,7 +95,9 @@ class TestKwargsPropagationToAIFunction:
messages=[
ChatMessage(
role="assistant",
contents=[FunctionCallContent(call_id="call_1", name="simple_tool", arguments='{"x": 99}')],
contents=[
Content.from_function_call(call_id="call_1", name="simple_tool", arguments='{"x": 99}')
],
)
]
)
@@ -136,10 +139,10 @@ class TestKwargsPropagationToAIFunction:
ChatMessage(
role="assistant",
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="call_1", name="tracking_tool", arguments='{"name": "first"}'
),
FunctionCallContent(
Content.from_function_call(
call_id="call_2", name="tracking_tool", arguments='{"name": "second"}'
),
],
@@ -187,7 +190,7 @@ class TestKwargsPropagationToAIFunction:
yield ChatResponseUpdate(
role="assistant",
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="stream_call_1",
name="streaming_capture_tool",
arguments='{"value": "streaming-test"}',
@@ -197,7 +200,9 @@ class TestKwargsPropagationToAIFunction:
)
else:
# Second call: return final response
yield ChatResponseUpdate(text=TextContent(text="Stream complete!"), role="assistant", is_finished=True)
yield ChatResponseUpdate(
text=Content.from_text(text="Stream complete!"), role="assistant", is_finished=True
)
wrapped = _handle_function_calls_streaming_response(mock_get_streaming_response)
+40 -41
View File
@@ -13,14 +13,12 @@ from pydantic import AnyUrl, BaseModel, ValidationError
from agent_framework import (
ChatMessage,
DataContent,
Content,
MCPStdioTool,
MCPStreamableHTTPTool,
MCPWebsocketTool,
Role,
TextContent,
ToolProtocol,
UriContent,
)
from agent_framework._mcp import (
MCPTool,
@@ -65,7 +63,7 @@ def test_mcp_prompt_message_to_ai_content():
assert isinstance(ai_content, ChatMessage)
assert ai_content.role.value == "user"
assert len(ai_content.contents) == 1
assert isinstance(ai_content.contents[0], TextContent)
assert ai_content.contents[0].type == "text"
assert ai_content.contents[0].text == "Hello, world!"
assert ai_content.raw_representation == mcp_message
@@ -75,20 +73,20 @@ def test_parse_contents_from_mcp_tool_result():
mcp_result = types.CallToolResult(
content=[
types.TextContent(type="text", text="Result text"),
types.ImageContent(type="image", data="xyz", mimeType="image/png"),
types.ImageContent(type="image", data=b"abc", mimeType="image/webp"),
types.ImageContent(type="image", data="eHl6", mimeType="image/png"), # base64 for "xyz"
types.ImageContent(type="image", data="YWJj", mimeType="image/webp"), # base64 for "abc"
]
)
ai_contents = _parse_contents_from_mcp_tool_result(mcp_result)
assert len(ai_contents) == 3
assert isinstance(ai_contents[0], TextContent)
assert ai_contents[0].type == "text"
assert ai_contents[0].text == "Result text"
assert isinstance(ai_contents[1], DataContent)
assert ai_contents[1].uri == "data:image/png;base64,xyz"
assert ai_contents[1].type == "data"
assert ai_contents[1].uri == "data:image/png;base64,eHl6"
assert ai_contents[1].media_type == "image/png"
assert isinstance(ai_contents[2], DataContent)
assert ai_contents[2].uri == "data:image/webp;base64,abc"
assert ai_contents[2].type == "data"
assert ai_contents[2].uri == "data:image/webp;base64,YWJj"
assert ai_contents[2].media_type == "image/webp"
@@ -103,7 +101,7 @@ def test_mcp_call_tool_result_with_meta_error():
ai_contents = _parse_contents_from_mcp_tool_result(mcp_result)
assert len(ai_contents) == 1
assert isinstance(ai_contents[0], TextContent)
assert ai_contents[0].type == "text"
assert ai_contents[0].text == "Error occurred"
# Check that _meta data is merged into additional_properties
@@ -134,7 +132,7 @@ def test_mcp_call_tool_result_with_meta_arbitrary_data():
ai_contents = _parse_contents_from_mcp_tool_result(mcp_result)
assert len(ai_contents) == 1
assert isinstance(ai_contents[0], TextContent)
assert ai_contents[0].type == "text"
assert ai_contents[0].text == "Success result"
# Check that _meta data is preserved in additional_properties
@@ -172,7 +170,7 @@ def test_mcp_call_tool_result_with_meta_none():
ai_contents = _parse_contents_from_mcp_tool_result(mcp_result)
assert len(ai_contents) == 1
assert isinstance(ai_contents[0], TextContent)
assert ai_contents[0].type == "text"
assert ai_contents[0].text == "No meta test"
# Should handle gracefully when no _meta field exists
@@ -187,7 +185,7 @@ def test_mcp_call_tool_result_regression_successful_workflow():
mcp_result = types.CallToolResult(
content=[
types.TextContent(type="text", text="Success message"),
types.ImageContent(type="image", data="abc123", mimeType="image/jpeg"),
types.ImageContent(type="image", data="YWJjMTIz", mimeType="image/jpeg"), # base64 for "abc123"
]
)
@@ -197,12 +195,12 @@ def test_mcp_call_tool_result_regression_successful_workflow():
assert len(ai_contents) == 2
text_content = ai_contents[0]
assert isinstance(text_content, TextContent)
assert text_content.type == "text"
assert text_content.text == "Success message"
image_content = ai_contents[1]
assert isinstance(image_content, DataContent)
assert image_content.uri == "data:image/jpeg;base64,abc123"
assert image_content.type == "data"
assert image_content.uri == "data:image/jpeg;base64,YWJjMTIz"
assert image_content.media_type == "image/jpeg"
# Should have no additional_properties when no _meta field
@@ -215,30 +213,31 @@ def test_mcp_content_types_to_ai_content_text():
mcp_content = types.TextContent(type="text", text="Sample text")
ai_content = _parse_content_from_mcp(mcp_content)[0]
assert isinstance(ai_content, TextContent)
assert ai_content.type == "text"
assert ai_content.text == "Sample text"
assert ai_content.raw_representation == mcp_content
def test_mcp_content_types_to_ai_content_image():
"""Test conversion of MCP image content to AI content."""
mcp_content = types.ImageContent(type="image", data="abc", mimeType="image/jpeg")
mcp_content = types.ImageContent(type="image", data=b"abc", mimeType="image/jpeg")
# MCP can send data as base64 string or as bytes
mcp_content = types.ImageContent(type="image", data="YWJj", mimeType="image/jpeg") # base64 for b"abc"
ai_content = _parse_content_from_mcp(mcp_content)[0]
assert isinstance(ai_content, DataContent)
assert ai_content.uri == "data:image/jpeg;base64,abc"
assert ai_content.type == "data"
assert ai_content.uri == "data:image/jpeg;base64,YWJj"
assert ai_content.media_type == "image/jpeg"
assert ai_content.raw_representation == mcp_content
def test_mcp_content_types_to_ai_content_audio():
"""Test conversion of MCP audio content to AI content."""
mcp_content = types.AudioContent(type="audio", data="def", mimeType="audio/wav")
# Use properly padded base64
mcp_content = types.AudioContent(type="audio", data="ZGVm", mimeType="audio/wav") # base64 for b"def"
ai_content = _parse_content_from_mcp(mcp_content)[0]
assert isinstance(ai_content, DataContent)
assert ai_content.uri == "data:audio/wav;base64,def"
assert ai_content.type == "data"
assert ai_content.uri == "data:audio/wav;base64,ZGVm"
assert ai_content.media_type == "audio/wav"
assert ai_content.raw_representation == mcp_content
@@ -253,7 +252,7 @@ def test_mcp_content_types_to_ai_content_resource_link():
)
ai_content = _parse_content_from_mcp(mcp_content)[0]
assert isinstance(ai_content, UriContent)
assert ai_content.type == "uri"
assert ai_content.uri == "https://example.com/resource"
assert ai_content.media_type == "application/json"
assert ai_content.raw_representation == mcp_content
@@ -269,7 +268,7 @@ def test_mcp_content_types_to_ai_content_embedded_resource_text():
mcp_content = types.EmbeddedResource(type="resource", resource=text_resource)
ai_content = _parse_content_from_mcp(mcp_content)[0]
assert isinstance(ai_content, TextContent)
assert ai_content.type == "text"
assert ai_content.text == "Embedded text content"
assert ai_content.raw_representation == mcp_content
@@ -285,7 +284,7 @@ def test_mcp_content_types_to_ai_content_embedded_resource_blob():
mcp_content = types.EmbeddedResource(type="resource", resource=blob_resource)
ai_content = _parse_content_from_mcp(mcp_content)[0]
assert isinstance(ai_content, DataContent)
assert ai_content.type == "data"
assert ai_content.uri == "data:application/octet-stream;base64,dGVzdCBkYXRh"
assert ai_content.media_type == "application/octet-stream"
assert ai_content.raw_representation == mcp_content
@@ -293,7 +292,7 @@ def test_mcp_content_types_to_ai_content_embedded_resource_blob():
def test_ai_content_to_mcp_content_types_text():
"""Test conversion of AI text content to MCP content."""
ai_content = TextContent(text="Sample text")
ai_content = Content.from_text(text="Sample text")
mcp_content = _prepare_content_for_mcp(ai_content)
assert isinstance(mcp_content, types.TextContent)
@@ -303,7 +302,7 @@ def test_ai_content_to_mcp_content_types_text():
def test_ai_content_to_mcp_content_types_data_image():
"""Test conversion of AI data content to MCP content."""
ai_content = DataContent(uri="data:image/png;base64,xyz", media_type="image/png")
ai_content = Content.from_uri(uri="data:image/png;base64,xyz", media_type="image/png")
mcp_content = _prepare_content_for_mcp(ai_content)
assert isinstance(mcp_content, types.ImageContent)
@@ -314,7 +313,7 @@ def test_ai_content_to_mcp_content_types_data_image():
def test_ai_content_to_mcp_content_types_data_audio():
"""Test conversion of AI data content to MCP content."""
ai_content = DataContent(uri="data:audio/mpeg;base64,xyz", media_type="audio/mpeg")
ai_content = Content.from_uri(uri="data:audio/mpeg;base64,xyz", media_type="audio/mpeg")
mcp_content = _prepare_content_for_mcp(ai_content)
assert isinstance(mcp_content, types.AudioContent)
@@ -325,7 +324,7 @@ def test_ai_content_to_mcp_content_types_data_audio():
def test_ai_content_to_mcp_content_types_data_binary():
"""Test conversion of AI data content to MCP content."""
ai_content = DataContent(
ai_content = Content.from_uri(
uri="data:application/octet-stream;base64,xyz",
media_type="application/octet-stream",
)
@@ -339,7 +338,7 @@ def test_ai_content_to_mcp_content_types_data_binary():
def test_ai_content_to_mcp_content_types_uri():
"""Test conversion of AI URI content to MCP content."""
ai_content = UriContent(uri="https://example.com/resource", media_type="application/json")
ai_content = Content.from_uri(uri="https://example.com/resource", media_type="application/json")
mcp_content = _prepare_content_for_mcp(ai_content)
assert isinstance(mcp_content, types.ResourceLink)
@@ -352,8 +351,8 @@ def test_prepare_message_for_mcp():
message = ChatMessage(
role="user",
contents=[
TextContent(text="test"),
DataContent(uri="data:image/png;base64,xyz", media_type="image/png"),
Content.from_text(text="test"),
Content.from_uri(uri="data:image/png;base64,xyz", media_type="image/png"),
],
)
mcp_contents = _prepare_message_for_mcp(message)
@@ -871,7 +870,7 @@ async def test_mcp_tool_call_tool_with_meta_integration():
result = await func.invoke(param="test_value")
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].type == "text"
assert result[0].text == "Tool executed with metadata"
# Verify that _meta data is present in additional_properties
@@ -920,7 +919,7 @@ async def test_local_mcp_server_function_execution():
result = await func.invoke(param="test_value")
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].type == "text"
assert result[0].text == "Tool executed successfully"
@@ -969,7 +968,7 @@ async def test_local_mcp_server_function_execution_with_nested_object():
result = await func.invoke(params={"customer_id": 251})
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].type == "text"
# Verify the session.call_tool was called with the correct nested structure
server.session.call_tool.assert_called_once()
@@ -1413,7 +1412,7 @@ async def test_mcp_tool_sampling_callback_chat_client_exception():
async def test_mcp_tool_sampling_callback_no_valid_content():
"""Test sampling callback when response has no valid content types."""
from agent_framework import ChatMessage, DataContent, Role
from agent_framework import ChatMessage, Role
tool = MCPStdioTool(name="test_tool", command="python")
@@ -1424,7 +1423,7 @@ async def test_mcp_tool_sampling_callback_no_valid_content():
ChatMessage(
role=Role.ASSISTANT,
contents=[
DataContent(
Content.from_uri(
uri="data:application/json;base64,e30K",
media_type="application/json",
)
@@ -14,8 +14,8 @@ from agent_framework import (
ChatMessage,
ChatResponse,
ChatResponseUpdate,
Content,
Role,
TextContent,
)
from agent_framework._middleware import (
AgentMiddleware,
@@ -217,8 +217,8 @@ class TestAgentMiddlewarePipeline:
context = AgentRunContext(agent=mock_agent, messages=messages)
async def final_handler(ctx: AgentRunContext) -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(contents=[TextContent(text="chunk1")])
yield AgentResponseUpdate(contents=[TextContent(text="chunk2")])
yield AgentResponseUpdate(contents=[Content.from_text(text="chunk1")])
yield AgentResponseUpdate(contents=[Content.from_text(text="chunk2")])
updates: list[AgentResponseUpdate] = []
async for update in pipeline.execute_stream(mock_agent, messages, context, final_handler):
@@ -250,8 +250,8 @@ class TestAgentMiddlewarePipeline:
async def final_handler(ctx: AgentRunContext) -> AsyncIterable[AgentResponseUpdate]:
execution_order.append("handler_start")
yield AgentResponseUpdate(contents=[TextContent(text="chunk1")])
yield AgentResponseUpdate(contents=[TextContent(text="chunk2")])
yield AgentResponseUpdate(contents=[Content.from_text(text="chunk1")])
yield AgentResponseUpdate(contents=[Content.from_text(text="chunk2")])
execution_order.append("handler_end")
updates: list[AgentResponseUpdate] = []
@@ -313,8 +313,8 @@ class TestAgentMiddlewarePipeline:
async def final_handler(ctx: AgentRunContext) -> AsyncIterable[AgentResponseUpdate]:
# Handler should not be executed when terminated before next()
execution_order.append("handler_start")
yield AgentResponseUpdate(contents=[TextContent(text="chunk1")])
yield AgentResponseUpdate(contents=[TextContent(text="chunk2")])
yield AgentResponseUpdate(contents=[Content.from_text(text="chunk1")])
yield AgentResponseUpdate(contents=[Content.from_text(text="chunk2")])
execution_order.append("handler_end")
updates: list[AgentResponseUpdate] = []
@@ -336,8 +336,8 @@ class TestAgentMiddlewarePipeline:
async def final_handler(ctx: AgentRunContext) -> AsyncIterable[AgentResponseUpdate]:
execution_order.append("handler_start")
yield AgentResponseUpdate(contents=[TextContent(text="chunk1")])
yield AgentResponseUpdate(contents=[TextContent(text="chunk2")])
yield AgentResponseUpdate(contents=[Content.from_text(text="chunk1")])
yield AgentResponseUpdate(contents=[Content.from_text(text="chunk2")])
execution_order.append("handler_end")
updates: list[AgentResponseUpdate] = []
@@ -609,8 +609,8 @@ class TestChatMiddlewarePipeline:
context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options)
async def final_handler(ctx: ChatContext) -> AsyncIterable[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="chunk1")])
yield ChatResponseUpdate(contents=[TextContent(text="chunk2")])
yield ChatResponseUpdate(contents=[Content.from_text(text="chunk1")])
yield ChatResponseUpdate(contents=[Content.from_text(text="chunk2")])
updates: list[ChatResponseUpdate] = []
async for update in pipeline.execute_stream(mock_chat_client, messages, chat_options, context, final_handler):
@@ -641,8 +641,8 @@ class TestChatMiddlewarePipeline:
async def final_handler(ctx: ChatContext) -> AsyncIterable[ChatResponseUpdate]:
execution_order.append("handler_start")
yield ChatResponseUpdate(contents=[TextContent(text="chunk1")])
yield ChatResponseUpdate(contents=[TextContent(text="chunk2")])
yield ChatResponseUpdate(contents=[Content.from_text(text="chunk1")])
yield ChatResponseUpdate(contents=[Content.from_text(text="chunk2")])
execution_order.append("handler_end")
updates: list[ChatResponseUpdate] = []
@@ -706,8 +706,8 @@ class TestChatMiddlewarePipeline:
async def final_handler(ctx: ChatContext) -> AsyncIterable[ChatResponseUpdate]:
# Handler should not be executed when terminated before next()
execution_order.append("handler_start")
yield ChatResponseUpdate(contents=[TextContent(text="chunk1")])
yield ChatResponseUpdate(contents=[TextContent(text="chunk2")])
yield ChatResponseUpdate(contents=[Content.from_text(text="chunk1")])
yield ChatResponseUpdate(contents=[Content.from_text(text="chunk2")])
execution_order.append("handler_end")
updates: list[ChatResponseUpdate] = []
@@ -730,8 +730,8 @@ class TestChatMiddlewarePipeline:
async def final_handler(ctx: ChatContext) -> AsyncIterable[ChatResponseUpdate]:
execution_order.append("handler_start")
yield ChatResponseUpdate(contents=[TextContent(text="chunk1")])
yield ChatResponseUpdate(contents=[TextContent(text="chunk2")])
yield ChatResponseUpdate(contents=[Content.from_text(text="chunk1")])
yield ChatResponseUpdate(contents=[Content.from_text(text="chunk2")])
execution_order.append("handler_end")
updates: list[ChatResponseUpdate] = []
@@ -1264,7 +1264,7 @@ class TestStreamingScenarios:
async def final_stream_handler(ctx: AgentRunContext) -> AsyncIterable[AgentResponseUpdate]:
streaming_flags.append(ctx.is_streaming)
yield AgentResponseUpdate(contents=[TextContent(text="chunk")])
yield AgentResponseUpdate(contents=[Content.from_text(text="chunk")])
updates: list[AgentResponseUpdate] = []
async for update in pipeline.execute_stream(mock_agent, messages, context_stream, final_stream_handler):
@@ -1292,9 +1292,9 @@ class TestStreamingScenarios:
async def final_stream_handler(ctx: AgentRunContext) -> AsyncIterable[AgentResponseUpdate]:
chunks_processed.append("stream_start")
yield AgentResponseUpdate(contents=[TextContent(text="chunk1")])
yield AgentResponseUpdate(contents=[Content.from_text(text="chunk1")])
chunks_processed.append("chunk1_yielded")
yield AgentResponseUpdate(contents=[TextContent(text="chunk2")])
yield AgentResponseUpdate(contents=[Content.from_text(text="chunk2")])
chunks_processed.append("chunk2_yielded")
chunks_processed.append("stream_end")
@@ -1342,7 +1342,7 @@ class TestStreamingScenarios:
async def final_stream_handler(ctx: ChatContext) -> AsyncIterable[ChatResponseUpdate]:
streaming_flags.append(ctx.is_streaming)
yield ChatResponseUpdate(contents=[TextContent(text="chunk")])
yield ChatResponseUpdate(contents=[Content.from_text(text="chunk")])
updates: list[ChatResponseUpdate] = []
async for update in pipeline.execute_stream(
@@ -1371,9 +1371,9 @@ class TestStreamingScenarios:
async def final_stream_handler(ctx: ChatContext) -> AsyncIterable[ChatResponseUpdate]:
chunks_processed.append("stream_start")
yield ChatResponseUpdate(contents=[TextContent(text="chunk1")])
yield ChatResponseUpdate(contents=[Content.from_text(text="chunk1")])
chunks_processed.append("chunk1_yielded")
yield ChatResponseUpdate(contents=[TextContent(text="chunk2")])
yield ChatResponseUpdate(contents=[Content.from_text(text="chunk2")])
chunks_processed.append("chunk2_yielded")
chunks_processed.append("stream_end")
@@ -1486,7 +1486,7 @@ class TestMiddlewareExecutionControl:
async def final_handler(ctx: AgentRunContext) -> AsyncIterable[AgentResponseUpdate]:
nonlocal handler_called
handler_called = True
yield AgentResponseUpdate(contents=[TextContent(text="should not execute")])
yield AgentResponseUpdate(contents=[Content.from_text(text="should not execute")])
# When middleware doesn't call next(), streaming should yield no updates
updates: list[AgentResponseUpdate] = []
@@ -1617,7 +1617,7 @@ class TestMiddlewareExecutionControl:
async def final_handler(ctx: ChatContext) -> AsyncIterable[ChatResponseUpdate]:
nonlocal handler_called
handler_called = True
yield ChatResponseUpdate(contents=[TextContent(text="should not execute")])
yield ChatResponseUpdate(contents=[Content.from_text(text="should not execute")])
# When middleware doesn't call next(), streaming should yield no updates
updates: list[ChatResponseUpdate] = []
@@ -13,8 +13,8 @@ from agent_framework import (
AgentResponseUpdate,
ChatAgent,
ChatMessage,
Content,
Role,
TextContent,
)
from agent_framework._middleware import (
AgentMiddleware,
@@ -75,8 +75,8 @@ class TestResultOverrideMiddleware:
"""Test that agent middleware can override response for streaming execution."""
async def override_stream() -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(contents=[TextContent(text="overridden")])
yield AgentResponseUpdate(contents=[TextContent(text=" stream")])
yield AgentResponseUpdate(contents=[Content.from_text(text="overridden")])
yield AgentResponseUpdate(contents=[Content.from_text(text=" stream")])
class StreamResponseOverrideMiddleware(AgentMiddleware):
async def process(
@@ -92,7 +92,7 @@ class TestResultOverrideMiddleware:
context = AgentRunContext(agent=mock_agent, messages=messages)
async def final_handler(ctx: AgentRunContext) -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(contents=[TextContent(text="original")])
yield AgentResponseUpdate(contents=[Content.from_text(text="original")])
updates: list[AgentResponseUpdate] = []
async for update in pipeline.execute_stream(mock_agent, messages, context, final_handler):
@@ -175,9 +175,9 @@ class TestResultOverrideMiddleware:
mock_chat_client = MockChatClient()
async def custom_stream() -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(contents=[TextContent(text="Custom")])
yield AgentResponseUpdate(contents=[TextContent(text=" streaming")])
yield AgentResponseUpdate(contents=[TextContent(text=" response!")])
yield AgentResponseUpdate(contents=[Content.from_text(text="Custom")])
yield AgentResponseUpdate(contents=[Content.from_text(text=" streaming")])
yield AgentResponseUpdate(contents=[Content.from_text(text=" response!")])
class ChatAgentStreamOverrideMiddleware(AgentMiddleware):
async def process(
@@ -13,10 +13,8 @@ from agent_framework import (
ChatMiddleware,
ChatResponse,
ChatResponseUpdate,
FunctionCallContent,
FunctionResultContent,
Content,
Role,
TextContent,
agent_middleware,
chat_middleware,
function_middleware,
@@ -201,7 +199,9 @@ class TestChatAgentFunctionBasedMiddleware:
ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(call_id="test_call", name="test_function", arguments={"text": "test"})
Content.from_function_call(
call_id="test_call", name="test_function", arguments={"text": "test"}
)
],
)
]
@@ -256,7 +256,9 @@ class TestChatAgentFunctionBasedMiddleware:
ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(call_id="test_call", name="test_function", arguments={"text": "test"})
Content.from_function_call(
call_id="test_call", name="test_function", arguments={"text": "test"}
)
],
)
]
@@ -365,8 +367,8 @@ class TestChatAgentStreamingMiddleware:
# Set up mock streaming responses
chat_client.streaming_responses = [
[
ChatResponseUpdate(contents=[TextContent(text="Streaming")], role=Role.ASSISTANT),
ChatResponseUpdate(contents=[TextContent(text=" response")], role=Role.ASSISTANT),
ChatResponseUpdate(contents=[Content.from_text(text="Streaming")], role=Role.ASSISTANT),
ChatResponseUpdate(contents=[Content.from_text(text=" response")], role=Role.ASSISTANT),
]
]
@@ -550,7 +552,7 @@ class TestChatAgentFunctionMiddlewareWithTools:
ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="call_123",
name="sample_tool_function",
arguments='{"location": "Seattle"}',
@@ -585,8 +587,8 @@ class TestChatAgentFunctionMiddlewareWithTools:
# Verify function call and result are in the response
all_contents = [content for message in response.messages for content in message.contents]
function_calls = [c for c in all_contents if isinstance(c, FunctionCallContent)]
function_results = [c for c in all_contents if isinstance(c, FunctionResultContent)]
function_calls = [c for c in all_contents if c.type == "function_call"]
function_results = [c for c in all_contents if c.type == "function_result"]
assert len(function_calls) == 1
assert len(function_results) == 1
@@ -610,7 +612,7 @@ class TestChatAgentFunctionMiddlewareWithTools:
ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="call_456",
name="sample_tool_function",
arguments='{"location": "San Francisco"}',
@@ -644,8 +646,8 @@ class TestChatAgentFunctionMiddlewareWithTools:
# Verify function call and result are in the response
all_contents = [content for message in response.messages for content in message.contents]
function_calls = [c for c in all_contents if isinstance(c, FunctionCallContent)]
function_results = [c for c in all_contents if isinstance(c, FunctionResultContent)]
function_calls = [c for c in all_contents if c.type == "function_call"]
function_results = [c for c in all_contents if c.type == "function_result"]
assert len(function_calls) == 1
assert len(function_results) == 1
@@ -682,7 +684,7 @@ class TestChatAgentFunctionMiddlewareWithTools:
ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="call_789",
name="sample_tool_function",
arguments='{"location": "New York"}',
@@ -723,8 +725,8 @@ class TestChatAgentFunctionMiddlewareWithTools:
# Verify function call and result are in the response
all_contents = [content for message in response.messages for content in message.contents]
function_calls = [c for c in all_contents if isinstance(c, FunctionCallContent)]
function_results = [c for c in all_contents if isinstance(c, FunctionResultContent)]
function_calls = [c for c in all_contents if c.type == "function_call"]
function_results = [c for c in all_contents if c.type == "function_result"]
assert len(function_calls) == 1
assert len(function_results) == 1
@@ -769,14 +771,16 @@ class TestChatAgentFunctionMiddlewareWithTools:
ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="test_call", name="sample_tool_function", arguments={"location": "Seattle"}
)
],
)
]
),
ChatResponse(messages=[ChatMessage(role=Role.ASSISTANT, contents=[TextContent("Function completed")])]),
ChatResponse(
messages=[ChatMessage(role=Role.ASSISTANT, contents=[Content.from_text("Function completed")])]
),
]
# Create ChatAgent with function middleware
@@ -1076,8 +1080,8 @@ class TestRunLevelMiddleware:
# Set up mock streaming responses
chat_client.streaming_responses = [
[
ChatResponseUpdate(contents=[TextContent(text="Stream")], role=Role.ASSISTANT),
ChatResponseUpdate(contents=[TextContent(text=" response")], role=Role.ASSISTANT),
ChatResponseUpdate(contents=[Content.from_text(text="Stream")], role=Role.ASSISTANT),
ChatResponseUpdate(contents=[Content.from_text(text=" response")], role=Role.ASSISTANT),
]
]
@@ -1159,7 +1163,7 @@ class TestRunLevelMiddleware:
ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="test_call",
name="custom_tool",
arguments='{"message": "test"}',
@@ -1204,8 +1208,8 @@ class TestRunLevelMiddleware:
# Verify function call and result are in the response
all_contents = [content for message in response.messages for content in message.contents]
function_calls = [c for c in all_contents if isinstance(c, FunctionCallContent)]
function_results = [c for c in all_contents if isinstance(c, FunctionResultContent)]
function_calls = [c for c in all_contents if c.type == "function_call"]
function_results = [c for c in all_contents if c.type == "function_result"]
assert len(function_calls) == 1
assert len(function_results) == 1
@@ -1248,7 +1252,7 @@ class TestMiddlewareDecoratorLogic:
ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="test_call",
name="custom_tool",
arguments='{"message": "test"}',
@@ -1315,7 +1319,7 @@ class TestMiddlewareDecoratorLogic:
ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="test_call",
name="custom_tool",
arguments='{"message": "test"}',
@@ -1365,7 +1369,7 @@ class TestMiddlewareDecoratorLogic:
ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="test_call",
name="custom_tool",
arguments='{"message": "test"}',
@@ -1704,8 +1708,8 @@ class TestChatAgentChatMiddleware:
# Set up mock streaming responses
chat_client.streaming_responses = [
[
ChatResponseUpdate(contents=[TextContent(text="Stream")], role=Role.ASSISTANT),
ChatResponseUpdate(contents=[TextContent(text=" response")], role=Role.ASSISTANT),
ChatResponseUpdate(contents=[Content.from_text(text="Stream")], role=Role.ASSISTANT),
ChatResponseUpdate(contents=[Content.from_text(text=" response")], role=Role.ASSISTANT),
]
]
@@ -1806,7 +1810,7 @@ class TestChatAgentChatMiddleware:
ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="call_456",
name="sample_tool_function",
arguments='{"location": "San Francisco"}',
@@ -1850,8 +1854,8 @@ class TestChatAgentChatMiddleware:
# Verify function call and result are in the response
all_contents = [content for message in response.messages for content in message.contents]
function_calls = [c for c in all_contents if isinstance(c, FunctionCallContent)]
function_results = [c for c in all_contents if isinstance(c, FunctionResultContent)]
function_calls = [c for c in all_contents if c.type == "function_call"]
function_results = [c for c in all_contents if c.type == "function_result"]
assert len(function_calls) == 1
assert len(function_results) == 1
@@ -9,7 +9,7 @@ from agent_framework import (
ChatMessage,
ChatMiddleware,
ChatResponse,
FunctionCallContent,
Content,
FunctionInvocationContext,
Role,
chat_middleware,
@@ -349,7 +349,7 @@ class TestChatMiddleware:
ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="call_1",
name="sample_tool",
arguments={"location": "San Francisco"},
@@ -405,7 +405,7 @@ class TestChatMiddleware:
ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="call_2",
name="sample_tool",
arguments={"location": "New York"},
+100 -105
View File
@@ -9,6 +9,7 @@ from pydantic import BaseModel, ValidationError
from agent_framework import (
AIFunction,
Content,
HostedCodeInterpreterTool,
HostedImageGenerationTool,
HostedMCPTool,
@@ -639,24 +640,22 @@ def test_parse_inputs_none():
def test_parse_inputs_string():
"""Test _parse_inputs with string input."""
from agent_framework import UriContent
result = _parse_inputs("http://example.com")
assert len(result) == 1
assert isinstance(result[0], UriContent)
assert result[0].type == "uri"
assert result[0].uri == "http://example.com"
assert result[0].media_type == "text/plain"
def test_parse_inputs_list_of_strings():
"""Test _parse_inputs with list of strings."""
from agent_framework import UriContent
inputs = ["http://example.com", "https://test.org"]
result = _parse_inputs(inputs)
assert len(result) == 2
assert all(isinstance(item, UriContent) for item in result)
assert all(item.type == "uri" for item in result)
assert result[0].uri == "http://example.com"
assert result[1].uri == "https://test.org"
assert all(item.media_type == "text/plain" for item in result)
@@ -664,88 +663,84 @@ def test_parse_inputs_list_of_strings():
def test_parse_inputs_uri_dict():
"""Test _parse_inputs with URI dictionary."""
from agent_framework import UriContent
input_dict = {"uri": "http://example.com", "media_type": "application/json"}
result = _parse_inputs(input_dict)
assert len(result) == 1
assert isinstance(result[0], UriContent)
assert result[0].type == "uri"
assert result[0].uri == "http://example.com"
assert result[0].media_type == "application/json"
def test_parse_inputs_hosted_file_dict():
"""Test _parse_inputs with hosted file dictionary."""
from agent_framework import HostedFileContent
input_dict = {"file_id": "file-123"}
result = _parse_inputs(input_dict)
assert len(result) == 1
assert isinstance(result[0], HostedFileContent)
assert result[0].type == "hosted_file"
assert result[0].file_id == "file-123"
def test_parse_inputs_hosted_vector_store_dict():
"""Test _parse_inputs with hosted vector store dictionary."""
from agent_framework import HostedVectorStoreContent
from agent_framework import Content
input_dict = {"vector_store_id": "vs-789"}
result = _parse_inputs(input_dict)
assert len(result) == 1
assert isinstance(result[0], HostedVectorStoreContent)
assert isinstance(result[0], Content)
assert result[0].type == "hosted_vector_store"
assert result[0].vector_store_id == "vs-789"
def test_parse_inputs_data_dict():
"""Test _parse_inputs with data dictionary."""
from agent_framework import DataContent
input_dict = {"data": b"test data", "media_type": "application/octet-stream"}
result = _parse_inputs(input_dict)
assert len(result) == 1
assert isinstance(result[0], DataContent)
assert result[0].type == "data"
assert result[0].uri == "data:application/octet-stream;base64,dGVzdCBkYXRh"
assert result[0].media_type == "application/octet-stream"
def test_parse_inputs_ai_contents_instance():
"""Test _parse_inputs with Contents instance."""
from agent_framework import TextContent
"""Test _parse_inputs with Content instance."""
text_content = TextContent(text="Hello, world!")
text_content = Content.from_text(text="Hello, world!")
result = _parse_inputs(text_content)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].type == "text"
assert result[0].text == "Hello, world!"
def test_parse_inputs_mixed_list():
"""Test _parse_inputs with mixed input types."""
from agent_framework import HostedFileContent, TextContent, UriContent
inputs = [
"http://example.com", # string
{"uri": "https://test.org", "media_type": "text/html"}, # URI dict
{"file_id": "file-456"}, # hosted file dict
TextContent(text="Hello"), # Contents instance
Content.from_text(text="Hello"), # Content instance
]
result = _parse_inputs(inputs)
assert len(result) == 4
assert isinstance(result[0], UriContent)
assert result[0].type == "uri"
assert result[0].uri == "http://example.com"
assert isinstance(result[1], UriContent)
assert result[1].type == "uri"
assert result[1].uri == "https://test.org"
assert result[1].media_type == "text/html"
assert isinstance(result[2], HostedFileContent)
assert result[2].type == "hosted_file"
assert result[2].file_id == "file-456"
assert isinstance(result[3], TextContent)
assert result[3].type == "text"
assert result[3].text == "Hello"
@@ -765,55 +760,51 @@ def test_parse_inputs_unsupported_type():
def test_hosted_code_interpreter_tool_with_string_input():
"""Test HostedCodeInterpreterTool with string input."""
from agent_framework import UriContent
tool = HostedCodeInterpreterTool(inputs="http://example.com")
assert len(tool.inputs) == 1
assert isinstance(tool.inputs[0], UriContent)
assert tool.inputs[0].type == "uri"
assert tool.inputs[0].uri == "http://example.com"
def test_hosted_code_interpreter_tool_with_dict_inputs():
"""Test HostedCodeInterpreterTool with dictionary inputs."""
from agent_framework import HostedFileContent, UriContent
inputs = [{"uri": "http://example.com", "media_type": "text/html"}, {"file_id": "file-123"}]
tool = HostedCodeInterpreterTool(inputs=inputs)
assert len(tool.inputs) == 2
assert isinstance(tool.inputs[0], UriContent)
assert tool.inputs[0].type == "uri"
assert tool.inputs[0].uri == "http://example.com"
assert tool.inputs[0].media_type == "text/html"
assert isinstance(tool.inputs[1], HostedFileContent)
assert tool.inputs[1].type == "hosted_file"
assert tool.inputs[1].file_id == "file-123"
def test_hosted_code_interpreter_tool_with_ai_contents():
"""Test HostedCodeInterpreterTool with Contents instances."""
from agent_framework import DataContent, TextContent
"""Test HostedCodeInterpreterTool with Content instances."""
inputs = [TextContent(text="Hello, world!"), DataContent(data=b"test", media_type="text/plain")]
inputs = [Content.from_text(text="Hello, world!"), Content.from_data(data=b"test", media_type="text/plain")]
tool = HostedCodeInterpreterTool(inputs=inputs)
assert len(tool.inputs) == 2
assert isinstance(tool.inputs[0], TextContent)
assert tool.inputs[0].type == "text"
assert tool.inputs[0].text == "Hello, world!"
assert isinstance(tool.inputs[1], DataContent)
assert tool.inputs[1].type == "data"
assert tool.inputs[1].media_type == "text/plain"
def test_hosted_code_interpreter_tool_with_single_input():
"""Test HostedCodeInterpreterTool with single input (not in list)."""
from agent_framework import HostedFileContent
input_dict = {"file_id": "file-single"}
tool = HostedCodeInterpreterTool(inputs=input_dict)
assert len(tool.inputs) == 1
assert isinstance(tool.inputs[0], HostedFileContent)
assert tool.inputs[0].type == "hosted_file"
assert tool.inputs[0].file_id == "file-single"
@@ -983,7 +974,7 @@ def mock_chat_client():
yield ChatResponseUpdate(contents=[content], role=msg.role)
else:
# Default response
yield ChatResponseUpdate(contents=["Default response"], role="assistant")
yield ChatResponseUpdate(text="Default response", role="assistant")
return MockChatClient()
@@ -1006,7 +997,7 @@ def requires_approval_tool(x: int) -> int:
async def test_non_streaming_single_function_no_approval():
"""Test non-streaming handler with single function call that doesn't require approval."""
from agent_framework import ChatMessage, ChatResponse, FunctionCallContent
from agent_framework import ChatMessage, ChatResponse
from agent_framework._tools import _handle_function_calls_response
# Create mock client
@@ -1017,11 +1008,11 @@ async def test_non_streaming_single_function_no_approval():
messages=[
ChatMessage(
role="assistant",
contents=[FunctionCallContent(call_id="call_1", name="no_approval_tool", arguments='{"x": 5}')],
contents=[Content.from_function_call(call_id="call_1", name="no_approval_tool", arguments='{"x": 5}')],
)
]
)
final_response = ChatResponse(messages=[ChatMessage(role="assistant", contents=["The result is 10"])])
final_response = ChatResponse(messages=[ChatMessage(role="assistant", text="The result is 10")])
call_count = [0]
responses = [initial_response, final_response]
@@ -1039,17 +1030,16 @@ async def test_non_streaming_single_function_no_approval():
# Verify: should have 3 messages: function call, function result, final answer
assert len(result.messages) == 3
assert isinstance(result.messages[0].contents[0], FunctionCallContent)
from agent_framework import FunctionResultContent
assert result.messages[0].contents[0].type == "function_call"
assert isinstance(result.messages[1].contents[0], FunctionResultContent)
assert result.messages[1].contents[0].type == "function_result"
assert result.messages[1].contents[0].result == 10 # 5 * 2
assert result.messages[2].contents[0] == "The result is 10"
assert result.messages[2].text == "The result is 10"
async def test_non_streaming_single_function_requires_approval():
"""Test non-streaming handler with single function call that requires approval."""
from agent_framework import ChatMessage, ChatResponse, FunctionCallContent
from agent_framework import ChatMessage, ChatResponse
from agent_framework._tools import _handle_function_calls_response
mock_client = type("MockClient", (), {})()
@@ -1059,7 +1049,9 @@ async def test_non_streaming_single_function_requires_approval():
messages=[
ChatMessage(
role="assistant",
contents=[FunctionCallContent(call_id="call_1", name="requires_approval_tool", arguments='{"x": 5}')],
contents=[
Content.from_function_call(call_id="call_1", name="requires_approval_tool", arguments='{"x": 5}')
],
)
]
)
@@ -1078,18 +1070,17 @@ async def test_non_streaming_single_function_requires_approval():
result = await wrapped(mock_client, messages=[], options={"tools": [requires_approval_tool]})
# Verify: should return 1 message with function call and approval request
from agent_framework import FunctionApprovalRequestContent
assert len(result.messages) == 1
assert len(result.messages[0].contents) == 2
assert isinstance(result.messages[0].contents[0], FunctionCallContent)
assert isinstance(result.messages[0].contents[1], FunctionApprovalRequestContent)
assert result.messages[0].contents[0].type == "function_call"
assert result.messages[0].contents[1].type == "function_approval_request"
assert result.messages[0].contents[1].function_call.name == "requires_approval_tool"
async def test_non_streaming_two_functions_both_no_approval():
"""Test non-streaming handler with two function calls, neither requiring approval."""
from agent_framework import ChatMessage, ChatResponse, FunctionCallContent
from agent_framework import ChatMessage, ChatResponse
from agent_framework._tools import _handle_function_calls_response
mock_client = type("MockClient", (), {})()
@@ -1100,15 +1091,13 @@ async def test_non_streaming_two_functions_both_no_approval():
ChatMessage(
role="assistant",
contents=[
FunctionCallContent(call_id="call_1", name="no_approval_tool", arguments='{"x": 5}'),
FunctionCallContent(call_id="call_2", name="no_approval_tool", arguments='{"x": 3}'),
Content.from_function_call(call_id="call_1", name="no_approval_tool", arguments='{"x": 5}'),
Content.from_function_call(call_id="call_2", name="no_approval_tool", arguments='{"x": 3}'),
],
)
]
)
final_response = ChatResponse(
messages=[ChatMessage(role="assistant", contents=["Both tools executed successfully"])]
)
final_response = ChatResponse(messages=[ChatMessage(role="assistant", text="Both tools executed successfully")])
call_count = [0]
responses = [initial_response, final_response]
@@ -1124,21 +1113,20 @@ async def test_non_streaming_two_functions_both_no_approval():
result = await wrapped(mock_client, messages=[], options={"tools": [no_approval_tool]})
# Verify: should have function calls, results, and final answer
from agent_framework import FunctionResultContent
assert len(result.messages) == 3
# First message has both function calls
assert len(result.messages[0].contents) == 2
# Second message has both results
assert len(result.messages[1].contents) == 2
assert all(isinstance(c, FunctionResultContent) for c in result.messages[1].contents)
assert all(c.type == "function_result" for c in result.messages[1].contents)
assert result.messages[1].contents[0].result == 10 # 5 * 2
assert result.messages[1].contents[1].result == 6 # 3 * 2
async def test_non_streaming_two_functions_both_require_approval():
"""Test non-streaming handler with two function calls, both requiring approval."""
from agent_framework import ChatMessage, ChatResponse, FunctionCallContent
from agent_framework import ChatMessage, ChatResponse
from agent_framework._tools import _handle_function_calls_response
mock_client = type("MockClient", (), {})()
@@ -1149,8 +1137,8 @@ async def test_non_streaming_two_functions_both_require_approval():
ChatMessage(
role="assistant",
contents=[
FunctionCallContent(call_id="call_1", name="requires_approval_tool", arguments='{"x": 5}'),
FunctionCallContent(call_id="call_2", name="requires_approval_tool", arguments='{"x": 3}'),
Content.from_function_call(call_id="call_1", name="requires_approval_tool", arguments='{"x": 5}'),
Content.from_function_call(call_id="call_2", name="requires_approval_tool", arguments='{"x": 3}'),
],
)
]
@@ -1170,12 +1158,11 @@ async def test_non_streaming_two_functions_both_require_approval():
result = await wrapped(mock_client, messages=[], options={"tools": [requires_approval_tool]})
# Verify: should return 1 message with function calls and approval requests
from agent_framework import FunctionApprovalRequestContent
assert len(result.messages) == 1
assert len(result.messages[0].contents) == 4 # 2 function calls + 2 approval requests
function_calls = [c for c in result.messages[0].contents if isinstance(c, FunctionCallContent)]
approval_requests = [c for c in result.messages[0].contents if isinstance(c, FunctionApprovalRequestContent)]
function_calls = [c for c in result.messages[0].contents if c.type == "function_call"]
approval_requests = [c for c in result.messages[0].contents if c.type == "function_approval_request"]
assert len(function_calls) == 2
assert len(approval_requests) == 2
assert approval_requests[0].function_call.name == "requires_approval_tool"
@@ -1184,7 +1171,7 @@ async def test_non_streaming_two_functions_both_require_approval():
async def test_non_streaming_two_functions_mixed_approval():
"""Test non-streaming handler with two function calls, one requiring approval."""
from agent_framework import ChatMessage, ChatResponse, FunctionCallContent
from agent_framework import ChatMessage, ChatResponse
from agent_framework._tools import _handle_function_calls_response
mock_client = type("MockClient", (), {})()
@@ -1195,8 +1182,8 @@ async def test_non_streaming_two_functions_mixed_approval():
ChatMessage(
role="assistant",
contents=[
FunctionCallContent(call_id="call_1", name="no_approval_tool", arguments='{"x": 5}'),
FunctionCallContent(call_id="call_2", name="requires_approval_tool", arguments='{"x": 3}'),
Content.from_function_call(call_id="call_1", name="no_approval_tool", arguments='{"x": 5}'),
Content.from_function_call(call_id="call_2", name="requires_approval_tool", arguments='{"x": 3}'),
],
)
]
@@ -1216,17 +1203,16 @@ async def test_non_streaming_two_functions_mixed_approval():
result = await wrapped(mock_client, messages=[], options={"tools": [no_approval_tool, requires_approval_tool]})
# Verify: should return approval requests for both (when one needs approval, all are sent for approval)
from agent_framework import FunctionApprovalRequestContent
assert len(result.messages) == 1
assert len(result.messages[0].contents) == 4 # 2 function calls + 2 approval requests
approval_requests = [c for c in result.messages[0].contents if isinstance(c, FunctionApprovalRequestContent)]
approval_requests = [c for c in result.messages[0].contents if c.type == "function_approval_request"]
assert len(approval_requests) == 2
async def test_streaming_single_function_no_approval():
"""Test streaming handler with single function call that doesn't require approval."""
from agent_framework import ChatResponseUpdate, FunctionCallContent
from agent_framework import ChatResponseUpdate
from agent_framework._tools import _handle_function_calls_streaming_response
mock_client = type("MockClient", (), {})()
@@ -1234,11 +1220,11 @@ async def test_streaming_single_function_no_approval():
# Initial response with function call, then final response after function execution
initial_updates = [
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="call_1", name="no_approval_tool", arguments='{"x": 5}')],
contents=[Content.from_function_call(call_id="call_1", name="no_approval_tool", arguments='{"x": 5}')],
role="assistant",
)
]
final_updates = [ChatResponseUpdate(contents=["The result is 10"], role="assistant")]
final_updates = [ChatResponseUpdate(text="The result is 10", role="assistant")]
call_count = [0]
updates_list = [initial_updates, final_updates]
@@ -1257,22 +1243,23 @@ async def test_streaming_single_function_no_approval():
updates.append(update)
# Verify: should have function call update, tool result update (injected), and final update
from agent_framework import FunctionResultContent, Role
from agent_framework import Role
assert len(updates) >= 3
# First update is the function call
assert isinstance(updates[0].contents[0], FunctionCallContent)
assert updates[0].contents[0].type == "function_call"
# Second update should be the tool result (injected by the wrapper)
assert updates[1].role == Role.TOOL
assert isinstance(updates[1].contents[0], FunctionResultContent)
assert updates[1].contents[0].type == "function_result"
assert updates[1].contents[0].result == 10 # 5 * 2
# Last update is the final message
assert updates[-1].contents[0] == "The result is 10"
assert updates[-1].contents[0].type == "text"
assert updates[-1].contents[0].text == "The result is 10"
async def test_streaming_single_function_requires_approval():
"""Test streaming handler with single function call that requires approval."""
from agent_framework import ChatResponseUpdate, FunctionCallContent
from agent_framework import ChatResponseUpdate
from agent_framework._tools import _handle_function_calls_streaming_response
mock_client = type("MockClient", (), {})()
@@ -1280,7 +1267,9 @@ async def test_streaming_single_function_requires_approval():
# Initial response with function call
initial_updates = [
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="call_1", name="requires_approval_tool", arguments='{"x": 5}')],
contents=[
Content.from_function_call(call_id="call_1", name="requires_approval_tool", arguments='{"x": 5}')
],
role="assistant",
)
]
@@ -1302,17 +1291,17 @@ async def test_streaming_single_function_requires_approval():
updates.append(update)
# Verify: should yield function call and then approval request
from agent_framework import FunctionApprovalRequestContent, Role
from agent_framework import Role
assert len(updates) == 2
assert isinstance(updates[0].contents[0], FunctionCallContent)
assert updates[0].contents[0].type == "function_call"
assert updates[1].role == Role.ASSISTANT
assert isinstance(updates[1].contents[0], FunctionApprovalRequestContent)
assert updates[1].contents[0].type == "function_approval_request"
async def test_streaming_two_functions_both_no_approval():
"""Test streaming handler with two function calls, neither requiring approval."""
from agent_framework import ChatResponseUpdate, FunctionCallContent
from agent_framework import ChatResponseUpdate
from agent_framework._tools import _handle_function_calls_streaming_response
mock_client = type("MockClient", (), {})()
@@ -1320,15 +1309,14 @@ async def test_streaming_two_functions_both_no_approval():
# Initial response with two function calls to the same tool
initial_updates = [
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="call_1", name="no_approval_tool", arguments='{"x": 5}')],
role="assistant",
),
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="call_2", name="no_approval_tool", arguments='{"x": 3}')],
contents=[
Content.from_function_call(call_id="call_1", name="no_approval_tool", arguments='{"x": 5}'),
Content.from_function_call(call_id="call_2", name="no_approval_tool", arguments='{"x": 3}'),
],
role="assistant",
),
]
final_updates = [ChatResponseUpdate(contents=["Both tools executed successfully"], role="assistant")]
final_updates = [ChatResponseUpdate(text="Both tools executed successfully", role="assistant")]
call_count = [0]
updates_list = [initial_updates, final_updates]
@@ -1347,22 +1335,23 @@ async def test_streaming_two_functions_both_no_approval():
updates.append(update)
# Verify: should have both function calls, one tool result update with both results, and final message
from agent_framework import FunctionResultContent, Role
from agent_framework import Role
assert len(updates) >= 3
# First two updates are function calls
assert isinstance(updates[0].contents[0], FunctionCallContent)
assert isinstance(updates[1].contents[0], FunctionCallContent)
assert len(updates) >= 2
# First update has both function calls
assert len(updates[0].contents) == 2
assert updates[0].contents[0].type == "function_call"
assert updates[0].contents[1].type == "function_call"
# Should have a tool result update with both results
tool_updates = [u for u in updates if u.role == Role.TOOL]
assert len(tool_updates) == 1
assert len(tool_updates[0].contents) == 2
assert all(isinstance(c, FunctionResultContent) for c in tool_updates[0].contents)
assert all(c.type == "function_result" for c in tool_updates[0].contents)
async def test_streaming_two_functions_both_require_approval():
"""Test streaming handler with two function calls, both requiring approval."""
from agent_framework import ChatResponseUpdate, FunctionCallContent
from agent_framework import ChatResponseUpdate
from agent_framework._tools import _handle_function_calls_streaming_response
mock_client = type("MockClient", (), {})()
@@ -1370,11 +1359,15 @@ async def test_streaming_two_functions_both_require_approval():
# Initial response with two function calls to the same tool
initial_updates = [
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="call_1", name="requires_approval_tool", arguments='{"x": 5}')],
contents=[
Content.from_function_call(call_id="call_1", name="requires_approval_tool", arguments='{"x": 5}')
],
role="assistant",
),
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="call_2", name="requires_approval_tool", arguments='{"x": 3}')],
contents=[
Content.from_function_call(call_id="call_2", name="requires_approval_tool", arguments='{"x": 3}')
],
role="assistant",
),
]
@@ -1396,20 +1389,20 @@ async def test_streaming_two_functions_both_require_approval():
updates.append(update)
# Verify: should yield both function calls and then approval requests
from agent_framework import FunctionApprovalRequestContent, Role
from agent_framework import Role
assert len(updates) == 3
assert isinstance(updates[0].contents[0], FunctionCallContent)
assert isinstance(updates[1].contents[0], FunctionCallContent)
assert updates[0].contents[0].type == "function_call"
assert updates[1].contents[0].type == "function_call"
# Assistant update with both approval requests
assert updates[2].role == Role.ASSISTANT
assert len(updates[2].contents) == 2
assert all(isinstance(c, FunctionApprovalRequestContent) for c in updates[2].contents)
assert all(c.type == "function_approval_request" for c in updates[2].contents)
async def test_streaming_two_functions_mixed_approval():
"""Test streaming handler with two function calls, one requiring approval."""
from agent_framework import ChatResponseUpdate, FunctionCallContent
from agent_framework import ChatResponseUpdate
from agent_framework._tools import _handle_function_calls_streaming_response
mock_client = type("MockClient", (), {})()
@@ -1417,11 +1410,13 @@ async def test_streaming_two_functions_mixed_approval():
# Initial response with two function calls
initial_updates = [
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="call_1", name="no_approval_tool", arguments='{"x": 5}')],
contents=[Content.from_function_call(call_id="call_1", name="no_approval_tool", arguments='{"x": 5}')],
role="assistant",
),
ChatResponseUpdate(
contents=[FunctionCallContent(call_id="call_2", name="requires_approval_tool", arguments='{"x": 3}')],
contents=[
Content.from_function_call(call_id="call_2", name="requires_approval_tool", arguments='{"x": 3}')
],
role="assistant",
),
]
@@ -1445,15 +1440,15 @@ async def test_streaming_two_functions_mixed_approval():
updates.append(update)
# Verify: should yield both function calls and then approval requests (when one needs approval, all wait)
from agent_framework import FunctionApprovalRequestContent, Role
from agent_framework import Role
assert len(updates) == 3
assert isinstance(updates[0].contents[0], FunctionCallContent)
assert isinstance(updates[1].contents[0], FunctionCallContent)
assert updates[0].contents[0].type == "function_call"
assert updates[1].contents[0].type == "function_call"
# Assistant update with both approval requests
assert updates[2].role == Role.ASSISTANT
assert len(updates[2].contents) == 2
assert all(isinstance(c, FunctionApprovalRequestContent) for c in updates[2].contents)
assert all(c.type == "function_approval_request" for c in updates[2].contents)
async def test_ai_function_with_kwargs_injection():
File diff suppressed because it is too large Load Diff
@@ -19,15 +19,10 @@ from agent_framework import (
ChatMessage,
ChatResponse,
ChatResponseUpdate,
FunctionCallContent,
FunctionResultContent,
Content,
HostedCodeInterpreterTool,
HostedFileSearchTool,
HostedVectorStoreContent,
Role,
TextContent,
UriContent,
UsageContent,
ai_function,
)
from agent_framework.exceptions import ServiceInitializationError
@@ -68,7 +63,7 @@ def create_test_openai_assistants_client(
return client
async def create_vector_store(client: OpenAIAssistantsClient) -> tuple[str, HostedVectorStoreContent]:
async def create_vector_store(client: OpenAIAssistantsClient) -> tuple[str, Content]:
"""Create a vector store with sample documents for testing."""
file = await client.client.files.create(
file=("todays_weather.txt", b"The weather today is sunny with a high of 25C."), purpose="user_data"
@@ -81,7 +76,7 @@ async def create_vector_store(client: OpenAIAssistantsClient) -> tuple[str, Host
if result.last_error is not None:
raise Exception(f"Vector store file processing failed with status: {result.last_error.message}")
return file.id, HostedVectorStoreContent(vector_store_id=vector_store.id)
return file.id, Content.from_hosted_vector_store(vector_store_id=vector_store.id)
async def delete_vector_store(client: OpenAIAssistantsClient, file_id: str, vector_store_id: str) -> None:
@@ -464,7 +459,7 @@ async def test_process_stream_events_requires_action(mock_async_openai: MagicMoc
chat_client = create_test_openai_assistants_client(mock_async_openai)
# Mock the _parse_function_calls_from_assistants method to return test content
test_function_content = FunctionCallContent(call_id="call-123", name="test_func", arguments={"arg": "value"})
test_function_content = Content.from_function_call(call_id="call-123", name="test_func", arguments={"arg": "value"})
chat_client._parse_function_calls_from_assistants = MagicMock(return_value=[test_function_content]) # type: ignore
# Create a mock Run object
@@ -578,10 +573,10 @@ async def test_process_stream_events_run_completed_with_usage(
# Check the usage content
usage_content = update.contents[0]
assert isinstance(usage_content, UsageContent)
assert usage_content.details.input_token_count == 100
assert usage_content.details.output_token_count == 50
assert usage_content.details.total_token_count == 150
assert usage_content.type == "usage"
assert usage_content.usage_details["input_token_count"] == 100
assert usage_content.usage_details["output_token_count"] == 50
assert usage_content.usage_details["total_token_count"] == 150
assert update.raw_representation == mock_run
@@ -609,7 +604,7 @@ def test_parse_function_calls_from_assistants_basic(mock_async_openai: MagicMock
# Test that one function call content was created
assert len(contents) == 1
assert isinstance(contents[0], FunctionCallContent)
assert contents[0].type == "function_call"
assert contents[0].name == "get_weather"
assert contents[0].arguments == {"location": "Seattle"}
@@ -830,7 +825,7 @@ def test_prepare_options_with_image_content(mock_async_openai: MagicMock) -> Non
chat_client = create_test_openai_assistants_client(mock_async_openai)
# Create message with image content
image_content = UriContent(uri="https://example.com/image.jpg", media_type="image/jpeg")
image_content = Content.from_uri(uri="https://example.com/image.jpg", media_type="image/jpeg")
messages = [ChatMessage(role=Role.USER, contents=[image_content])]
# Call the method
@@ -861,7 +856,7 @@ def test_prepare_tool_outputs_for_assistants_valid(mock_async_openai: MagicMock)
chat_client = create_test_openai_assistants_client(mock_async_openai)
call_id = json.dumps(["run-123", "call-456"])
function_result = FunctionResultContent(call_id=call_id, result="Function executed successfully")
function_result = Content.from_function_result(call_id=call_id, result="Function executed successfully")
run_id, tool_outputs = chat_client._prepare_tool_outputs_for_assistants([function_result]) # type: ignore
@@ -881,8 +876,8 @@ def test_prepare_tool_outputs_for_assistants_mismatched_run_ids(
# Create function results with different run IDs
call_id1 = json.dumps(["run-123", "call-456"])
call_id2 = json.dumps(["run-789", "call-xyz"]) # Different run ID
function_result1 = FunctionResultContent(call_id=call_id1, result="Result 1")
function_result2 = FunctionResultContent(call_id=call_id2, result="Result 2")
function_result1 = Content.from_function_result(call_id=call_id1, result="Result 1")
function_result2 = Content.from_function_result(call_id=call_id2, result="Result 2")
run_id, tool_outputs = chat_client._prepare_tool_outputs_for_assistants([function_result1, function_result2]) # type: ignore
@@ -1006,7 +1001,7 @@ async def test_streaming() -> None:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
if content.type == "text" and content.text:
full_message += content.text
assert any(word in full_message.lower() for word in ["sunny", "25", "weather", "seattle"])
@@ -1035,7 +1030,7 @@ async def test_streaming_tools() -> None:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
if content.type == "text" and content.text:
full_message += content.text
assert any(word in full_message.lower() for word in ["sunny", "25", "weather"])
@@ -1121,7 +1116,7 @@ async def test_file_search_streaming() -> None:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
if content.type == "text" and content.text:
full_message += content.text
await delete_vector_store(openai_assistants_client, file_id, vector_store.vector_store_id)
@@ -14,8 +14,7 @@ from agent_framework import (
ChatClientProtocol,
ChatMessage,
ChatResponse,
DataContent,
FunctionResultContent,
Content,
HostedWebSearchTool,
ToolProtocol,
ai_function,
@@ -282,7 +281,9 @@ def test_function_result_falsy_values_handling(openai_unit_test_env: dict[str, s
client = OpenAIChatClient()
# Test with empty list (falsy but not None)
message_with_empty_list = ChatMessage(role="tool", contents=[FunctionResultContent(call_id="call-123", result=[])])
message_with_empty_list = ChatMessage(
role="tool", contents=[Content.from_function_result(call_id="call-123", result=[])]
)
openai_messages = client._prepare_message_for_openai(message_with_empty_list)
assert len(openai_messages) == 1
@@ -290,7 +291,7 @@ def test_function_result_falsy_values_handling(openai_unit_test_env: dict[str, s
# Test with empty string (falsy but not None)
message_with_empty_string = ChatMessage(
role="tool", contents=[FunctionResultContent(call_id="call-456", result="")]
role="tool", contents=[Content.from_function_result(call_id="call-456", result="")]
)
openai_messages = client._prepare_message_for_openai(message_with_empty_string)
@@ -298,7 +299,9 @@ def test_function_result_falsy_values_handling(openai_unit_test_env: dict[str, s
assert openai_messages[0]["content"] == "" # Empty string should be preserved
# Test with False (falsy but not None)
message_with_false = ChatMessage(role="tool", contents=[FunctionResultContent(call_id="call-789", result=False)])
message_with_false = ChatMessage(
role="tool", contents=[Content.from_function_result(call_id="call-789", result=False)]
)
openai_messages = client._prepare_message_for_openai(message_with_false)
assert len(openai_messages) == 1
@@ -317,7 +320,7 @@ def test_function_result_exception_handling(openai_unit_test_env: dict[str, str]
message_with_exception = ChatMessage(
role="tool",
contents=[
FunctionResultContent(call_id="call-123", result="Error: Function failed.", exception=test_exception)
Content.from_function_result(call_id="call-123", result="Error: Function failed.", exception=test_exception)
],
)
@@ -339,7 +342,7 @@ def test_prepare_content_for_openai_data_content_image(openai_unit_test_env: dic
client = OpenAIChatClient()
# Test DataContent with image media type
image_data_content = DataContent(
image_data_content = Content.from_uri(
uri="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==",
media_type="image/png",
)
@@ -351,7 +354,7 @@ def test_prepare_content_for_openai_data_content_image(openai_unit_test_env: dic
assert result["image_url"]["url"] == image_data_content.uri
# Test DataContent with non-image media type should use default model_dump
text_data_content = DataContent(uri="data:text/plain;base64,SGVsbG8gV29ybGQ=", media_type="text/plain")
text_data_content = Content.from_uri(uri="data:text/plain;base64,SGVsbG8gV29ybGQ=", media_type="text/plain")
result = client._prepare_content_for_openai(text_data_content) # type: ignore
@@ -361,7 +364,7 @@ def test_prepare_content_for_openai_data_content_image(openai_unit_test_env: dic
assert result["media_type"] == "text/plain"
# Test DataContent with audio media type
audio_data_content = DataContent(
audio_data_content = Content.from_uri(
uri="data:audio/wav;base64,UklGRjBEAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQwEAAAAAAAAAAAA",
media_type="audio/wav",
)
@@ -375,7 +378,9 @@ def test_prepare_content_for_openai_data_content_image(openai_unit_test_env: dic
assert result["input_audio"]["format"] == "wav"
# Test DataContent with MP3 audio
mp3_data_content = DataContent(uri="data:audio/mp3;base64,//uQAAAAWGluZwAAAA8AAAACAAACcQ==", media_type="audio/mp3")
mp3_data_content = Content.from_uri(
uri="data:audio/mp3;base64,//uQAAAAWGluZwAAAA8AAAACAAACcQ==", media_type="audio/mp3"
)
result = client._prepare_content_for_openai(mp3_data_content) # type: ignore
@@ -391,7 +396,7 @@ def test_prepare_content_for_openai_document_file_mapping(openai_unit_test_env:
client = OpenAIChatClient()
# Test PDF without filename - should omit filename in OpenAI payload
pdf_data_content = DataContent(
pdf_data_content = Content.from_uri(
uri="data:application/pdf;base64,JVBERi0xLjQKJcfsj6IKNSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXS9QYXJlbnQgMiAwIFIvUmVzb3VyY2VzPDwvRm9udDw8L0YxIDQgMCBSPj4+Pi9Db250ZW50cyA1IDAgUj4+CmVuZG9iago0IDAgb2JqCjw8L1R5cGUvRm9udC9TdWJ0eXBlL1R5cGUxL0Jhc2VGb250L0hlbHZldGljYT4+CmVuZG9iago1IDAgb2JqCjw8L0xlbmd0aCA0ND4+CnN0cmVhbQpCVApxCjcwIDUwIFRECi9GMSA4IFRmCihIZWxsbyBXb3JsZCEpIFRqCkVUCmVuZHN0cmVhbQplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQ1IDAwMDAwIG4gCjAwMDAwMDAzMDcgMDAwMDAgbiAKdHJhaWxlcgo8PC9TaXplIDYvUm9vdCAxIDAgUj4+CnN0YXJ0eHJlZgo0MDUKJSVFT0Y=",
media_type="application/pdf",
)
@@ -407,7 +412,7 @@ def test_prepare_content_for_openai_document_file_mapping(openai_unit_test_env:
assert result["file"]["file_data"] == pdf_data_content.uri
# Test PDF with custom filename via additional_properties
pdf_with_filename = DataContent(
pdf_with_filename = Content.from_uri(
uri="data:application/pdf;base64,JVBERi0xLjQ=",
media_type="application/pdf",
additional_properties={"filename": "report.pdf"},
@@ -441,7 +446,7 @@ def test_prepare_content_for_openai_document_file_mapping(openai_unit_test_env:
for case in test_cases:
# Test without filename
doc_content = DataContent(
doc_content = Content.from_uri(
uri=f"data:{case['media_type']};base64,{case['base64']}",
media_type=case["media_type"],
)
@@ -454,7 +459,7 @@ def test_prepare_content_for_openai_document_file_mapping(openai_unit_test_env:
assert result["file"]["file_data"] == doc_content.uri
# Test with filename - should now use file format with filename
doc_with_filename = DataContent(
doc_with_filename = Content.from_uri(
uri=f"data:{case['media_type']};base64,{case['base64']}",
media_type=case["media_type"],
additional_properties={"filename": case["filename"]},
@@ -468,7 +473,7 @@ def test_prepare_content_for_openai_document_file_mapping(openai_unit_test_env:
assert result["file"]["file_data"] == doc_with_filename.uri
# Test edge case: empty additional_properties dict
pdf_empty_props = DataContent(
pdf_empty_props = Content.from_uri(
uri="data:application/pdf;base64,JVBERi0xLjQ=",
media_type="application/pdf",
additional_properties={},
@@ -480,7 +485,7 @@ def test_prepare_content_for_openai_document_file_mapping(openai_unit_test_env:
assert "filename" not in result["file"]
# Test edge case: None filename in additional_properties
pdf_none_filename = DataContent(
pdf_none_filename = Content.from_uri(
uri="data:application/pdf;base64,JVBERi0xLjQ=",
media_type="application/pdf",
additional_properties={"filename": None},
@@ -33,26 +33,13 @@ from agent_framework import (
ChatOptions,
ChatResponse,
ChatResponseUpdate,
CodeInterpreterToolCallContent,
CodeInterpreterToolResultContent,
DataContent,
FunctionApprovalRequestContent,
FunctionApprovalResponseContent,
FunctionCallContent,
FunctionResultContent,
Content,
HostedCodeInterpreterTool,
HostedFileContent,
HostedFileSearchTool,
HostedImageGenerationTool,
HostedMCPTool,
HostedVectorStoreContent,
HostedWebSearchTool,
ImageGenerationToolCallContent,
ImageGenerationToolResultContent,
Role,
TextContent,
TextReasoningContent,
UriContent,
ai_function,
)
from agent_framework.exceptions import (
@@ -81,7 +68,7 @@ class OutputStruct(BaseModel):
async def create_vector_store(
client: OpenAIResponsesClient,
) -> tuple[str, HostedVectorStoreContent]:
) -> tuple[str, Content]:
"""Create a vector store with sample documents for testing."""
file = await client.client.files.create(
file=("todays_weather.txt", b"The weather today is sunny with a high of 75F."),
@@ -99,7 +86,7 @@ async def create_vector_store(
if result.last_error is not None:
raise Exception(f"Vector store file processing failed with status: {result.last_error.message}")
return file.id, HostedVectorStoreContent(vector_store_id=vector_store.id)
return file.id, Content.from_hosted_vector_store(vector_store_id=vector_store.id)
async def delete_vector_store(client: OpenAIResponsesClient, file_id: str, vector_store_id: str) -> None:
@@ -285,7 +272,7 @@ def test_file_search_tool_with_invalid_inputs() -> None:
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
# Test with invalid inputs type (should trigger ValueError)
file_search_tool = HostedFileSearchTool(inputs=[HostedFileContent(file_id="invalid")])
file_search_tool = HostedFileSearchTool(inputs=[Content.from_hosted_file(file_id="invalid")])
# Should raise an error due to invalid inputs
with pytest.raises(ValueError, match="HostedFileSearchTool requires inputs to be of type"):
@@ -314,7 +301,7 @@ def test_code_interpreter_tool_variations() -> None:
# Test code interpreter with files
code_tool_with_files = HostedCodeInterpreterTool(
inputs=[HostedFileContent(file_id="file1"), HostedFileContent(file_id="file2")]
inputs=[Content.from_hosted_file(file_id="file1"), Content.from_hosted_file(file_id="file2")]
)
with pytest.raises(ServiceResponseException):
@@ -367,14 +354,14 @@ def test_chat_message_parsing_with_function_calls() -> None:
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
# Create messages with function call and result content
function_call = FunctionCallContent(
function_call = Content.from_function_call(
call_id="test-call-id",
name="test_function",
arguments='{"param": "value"}',
additional_properties={"fc_id": "test-fc-id"},
)
function_result = FunctionResultContent(call_id="test-call-id", result="Function executed successfully")
function_result = Content.from_function_result(call_id="test-call-id", result="Function executed successfully")
messages = [
ChatMessage(role="user", text="Call a function"),
@@ -516,7 +503,7 @@ def test_response_content_creation_with_annotations() -> None:
response = client._parse_response_from_openai(mock_response, options={}) # type: ignore
assert len(response.messages[0].contents) >= 1
assert isinstance(response.messages[0].contents[0], TextContent)
assert response.messages[0].contents[0].type == "text"
assert response.messages[0].contents[0].text == "Text with annotations."
assert response.messages[0].contents[0].annotations is not None
@@ -547,7 +534,7 @@ def test_response_content_creation_with_refusal() -> None:
response = client._parse_response_from_openai(mock_response, options={}) # type: ignore
assert len(response.messages[0].contents) == 1
assert isinstance(response.messages[0].contents[0], TextContent)
assert response.messages[0].contents[0].type == "text"
assert response.messages[0].contents[0].text == "I cannot provide that information."
@@ -577,7 +564,7 @@ def test_response_content_creation_with_reasoning() -> None:
response = client._parse_response_from_openai(mock_response, options={}) # type: ignore
assert len(response.messages[0].contents) == 2
assert isinstance(response.messages[0].contents[0], TextReasoningContent)
assert response.messages[0].contents[0].type == "text_reasoning"
assert response.messages[0].contents[0].text == "Reasoning step"
@@ -614,13 +601,13 @@ def test_response_content_creation_with_code_interpreter() -> None:
assert len(response.messages[0].contents) == 2
call_content, result_content = response.messages[0].contents
assert isinstance(call_content, CodeInterpreterToolCallContent)
assert call_content.type == "code_interpreter_tool_call"
assert call_content.inputs is not None
assert isinstance(call_content.inputs[0], TextContent)
assert isinstance(result_content, CodeInterpreterToolResultContent)
assert call_content.inputs[0].type == "text"
assert result_content.type == "code_interpreter_tool_result"
assert result_content.outputs is not None
assert any(isinstance(out, TextContent) for out in result_content.outputs)
assert any(isinstance(out, UriContent) for out in result_content.outputs)
assert any(out.type == "text" for out in result_content.outputs)
assert any(out.type == "uri" for out in result_content.outputs)
def test_response_content_creation_with_function_call() -> None:
@@ -648,7 +635,7 @@ def test_response_content_creation_with_function_call() -> None:
response = client._parse_response_from_openai(mock_response, options={}) # type: ignore
assert len(response.messages[0].contents) == 1
assert isinstance(response.messages[0].contents[0], FunctionCallContent)
assert response.messages[0].contents[0].type == "function_call"
function_call = response.messages[0].contents[0]
assert function_call.call_id == "call_123"
assert function_call.name == "get_weather"
@@ -708,7 +695,7 @@ def test_parse_response_from_openai_with_mcp_approval_request() -> None:
response = client._parse_response_from_openai(mock_response, options={}) # type: ignore
assert isinstance(response.messages[0].contents[0], FunctionApprovalRequestContent)
assert response.messages[0].contents[0].type == "function_approval_request"
req = response.messages[0].contents[0]
assert req.id == "approval-1"
assert req.function_call.name == "do_sensitive_action"
@@ -874,8 +861,8 @@ def test_parse_chunk_from_openai_with_mcp_approval_request() -> None:
mock_event.item = mock_item
update = client._parse_chunk_from_openai(mock_event, chat_options, function_call_ids)
assert any(isinstance(c, FunctionApprovalRequestContent) for c in update.contents)
fa = next(c for c in update.contents if isinstance(c, FunctionApprovalRequestContent))
assert any(c.type == "function_approval_request" for c in update.contents)
fa = next(c for c in update.contents if c.type == "function_approval_request")
assert fa.id == "approval-stream-1"
assert fa.function_call.name == "do_stream_action"
@@ -925,12 +912,12 @@ async def test_end_to_end_mcp_approval_flow(span_exporter) -> None:
with patch.object(client.client.responses, "create", side_effect=[mock_response1, mock_response2]) as mock_create:
# First call: get the approval request
response = await client.get_response(messages=[ChatMessage(role="user", text="Trigger approval")])
assert isinstance(response.messages[0].contents[0], FunctionApprovalRequestContent)
assert response.messages[0].contents[0].type == "function_approval_request"
req = response.messages[0].contents[0]
assert req.id == "approval-1"
# Build a user approval and send it (include required function_call)
approval = FunctionApprovalResponseContent(approved=True, id=req.id, function_call=req.function_call)
approval = Content.from_function_approval_response(approved=True, id=req.id, function_call=req.function_call)
approval_message = ChatMessage(role="user", contents=[approval])
_ = await client.get_response(messages=[approval_message])
@@ -961,9 +948,9 @@ def test_usage_details_basic() -> None:
details = client._parse_usage_from_openai(mock_usage) # type: ignore
assert details is not None
assert details.input_token_count == 100
assert details.output_token_count == 50
assert details.total_token_count == 150
assert details["input_token_count"] == 100
assert details["output_token_count"] == 50
assert details["total_token_count"] == 150
def test_usage_details_with_cached_tokens() -> None:
@@ -980,8 +967,8 @@ def test_usage_details_with_cached_tokens() -> None:
details = client._parse_usage_from_openai(mock_usage) # type: ignore
assert details is not None
assert details.input_token_count == 200
assert details.additional_counts["openai.cached_input_tokens"] == 25
assert details["input_token_count"] == 200
assert details["openai.cached_input_tokens"] == 25
def test_usage_details_with_reasoning_tokens() -> None:
@@ -998,8 +985,8 @@ def test_usage_details_with_reasoning_tokens() -> None:
details = client._parse_usage_from_openai(mock_usage) # type: ignore
assert details is not None
assert details.output_token_count == 80
assert details.additional_counts["openai.reasoning_tokens"] == 30
assert details["output_token_count"] == 80
assert details["openai.reasoning_tokens"] == 30
def test_get_metadata_from_response() -> None:
@@ -1098,7 +1085,7 @@ def test_streaming_annotation_added_with_file_path() -> None:
assert len(response.contents) == 1
content = response.contents[0]
assert isinstance(content, HostedFileContent)
assert content.type == "hosted_file"
assert content.file_id == "file-abc123"
assert content.additional_properties is not None
assert content.additional_properties.get("annotation_index") == 0
@@ -1125,7 +1112,7 @@ def test_streaming_annotation_added_with_file_citation() -> None:
assert len(response.contents) == 1
content = response.contents[0]
assert isinstance(content, HostedFileContent)
assert content.type == "hosted_file"
assert content.file_id == "file-xyz789"
assert content.additional_properties is not None
assert content.additional_properties.get("filename") == "sample.txt"
@@ -1154,7 +1141,7 @@ def test_streaming_annotation_added_with_container_file_citation() -> None:
assert len(response.contents) == 1
content = response.contents[0]
assert isinstance(content, HostedFileContent)
assert content.type == "hosted_file"
assert content.file_id == "file-container123"
assert content.additional_properties is not None
assert content.additional_properties.get("container_id") == "container-456"
@@ -1228,7 +1215,7 @@ def test_prepare_content_for_openai_image_content() -> None:
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
# Test image content with detail parameter and file_id
image_content_with_detail = UriContent(
image_content_with_detail = Content.from_uri(
uri="https://example.com/image.jpg",
media_type="image/jpeg",
additional_properties={"detail": "high", "file_id": "file_123"},
@@ -1240,7 +1227,7 @@ def test_prepare_content_for_openai_image_content() -> None:
assert result["file_id"] == "file_123"
# Test image content without additional properties (defaults)
image_content_basic = UriContent(uri="https://example.com/basic.png", media_type="image/png")
image_content_basic = Content.from_uri(uri="https://example.com/basic.png", media_type="image/png")
result = client._prepare_content_for_openai(Role.USER, image_content_basic, {}) # type: ignore
assert result["type"] == "input_image"
assert result["detail"] == "auto"
@@ -1252,14 +1239,14 @@ def test_prepare_content_for_openai_audio_content() -> None:
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
# Test WAV audio content
wav_content = UriContent(uri="data:audio/wav;base64,abc123", media_type="audio/wav")
wav_content = Content.from_uri(uri="data:audio/wav;base64,abc123", media_type="audio/wav")
result = client._prepare_content_for_openai(Role.USER, wav_content, {}) # type: ignore
assert result["type"] == "input_audio"
assert result["input_audio"]["data"] == "data:audio/wav;base64,abc123"
assert result["input_audio"]["format"] == "wav"
# Test MP3 audio content
mp3_content = UriContent(uri="data:audio/mp3;base64,def456", media_type="audio/mp3")
mp3_content = Content.from_uri(uri="data:audio/mp3;base64,def456", media_type="audio/mp3")
result = client._prepare_content_for_openai(Role.USER, mp3_content, {}) # type: ignore
assert result["type"] == "input_audio"
assert result["input_audio"]["format"] == "mp3"
@@ -1270,12 +1257,12 @@ def test_prepare_content_for_openai_unsupported_content() -> None:
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
# Test unsupported audio format
unsupported_audio = UriContent(uri="data:audio/ogg;base64,ghi789", media_type="audio/ogg")
unsupported_audio = Content.from_uri(uri="data:audio/ogg;base64,ghi789", media_type="audio/ogg")
result = client._prepare_content_for_openai(Role.USER, unsupported_audio, {}) # type: ignore
assert result == {}
# Test non-media content
text_uri_content = UriContent(uri="https://example.com/document.txt", media_type="text/plain")
text_uri_content = Content.from_uri(uri="https://example.com/document.txt", media_type="text/plain")
result = client._prepare_content_for_openai(Role.USER, text_uri_content, {}) # type: ignore
assert result == {}
@@ -1299,11 +1286,9 @@ def test_parse_chunk_from_openai_code_interpreter() -> None:
result = client._parse_chunk_from_openai(mock_event_image, chat_options, function_call_ids) # type: ignore
assert len(result.contents) == 1
assert isinstance(result.contents[0], CodeInterpreterToolResultContent)
assert result.contents[0].type == "code_interpreter_tool_result"
assert result.contents[0].outputs
assert any(
isinstance(out, UriContent) and out.uri == "https://example.com/plot.png" for out in result.contents[0].outputs
)
assert any(out.type == "uri" and out.uri == "https://example.com/plot.png" for out in result.contents[0].outputs)
def test_parse_chunk_from_openai_reasoning() -> None:
@@ -1324,7 +1309,7 @@ def test_parse_chunk_from_openai_reasoning() -> None:
result = client._parse_chunk_from_openai(mock_event_reasoning, chat_options, function_call_ids) # type: ignore
assert len(result.contents) == 1
assert isinstance(result.contents[0], TextReasoningContent)
assert result.contents[0].type == "text_reasoning"
assert result.contents[0].text == "Analyzing the problem step by step..."
if result.contents[0].additional_properties:
assert result.contents[0].additional_properties["summary"] == "Problem analysis summary"
@@ -1335,7 +1320,7 @@ def test_prepare_content_for_openai_text_reasoning_comprehensive() -> None:
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
# Test TextReasoningContent with all additional properties
comprehensive_reasoning = TextReasoningContent(
comprehensive_reasoning = Content.from_text_reasoning(
text="Comprehensive reasoning summary",
additional_properties={
"status": "in_progress",
@@ -1371,7 +1356,7 @@ def test_streaming_reasoning_text_delta_event() -> None:
response = client._parse_chunk_from_openai(event, chat_options, function_call_ids) # type: ignore
assert len(response.contents) == 1
assert isinstance(response.contents[0], TextReasoningContent)
assert response.contents[0].type == "text_reasoning"
assert response.contents[0].text == "reasoning delta"
assert response.contents[0].raw_representation == event
mock_metadata.assert_called_once_with(event)
@@ -1396,7 +1381,7 @@ def test_streaming_reasoning_text_done_event() -> None:
response = client._parse_chunk_from_openai(event, chat_options, function_call_ids) # type: ignore
assert len(response.contents) == 1
assert isinstance(response.contents[0], TextReasoningContent)
assert response.contents[0].type == "text_reasoning"
assert response.contents[0].text == "complete reasoning"
assert response.contents[0].raw_representation == event
mock_metadata.assert_called_once_with(event)
@@ -1422,7 +1407,7 @@ def test_streaming_reasoning_summary_text_delta_event() -> None:
response = client._parse_chunk_from_openai(event, chat_options, function_call_ids) # type: ignore
assert len(response.contents) == 1
assert isinstance(response.contents[0], TextReasoningContent)
assert response.contents[0].type == "text_reasoning"
assert response.contents[0].text == "summary delta"
assert response.contents[0].raw_representation == event
mock_metadata.assert_called_once_with(event)
@@ -1447,7 +1432,7 @@ def test_streaming_reasoning_summary_text_done_event() -> None:
response = client._parse_chunk_from_openai(event, chat_options, function_call_ids) # type: ignore
assert len(response.contents) == 1
assert isinstance(response.contents[0], TextReasoningContent)
assert response.contents[0].type == "text_reasoning"
assert response.contents[0].text == "complete summary"
assert response.contents[0].raw_representation == event
mock_metadata.assert_called_once_with(event)
@@ -1488,8 +1473,8 @@ def test_streaming_reasoning_events_preserve_metadata() -> None:
assert reasoning_response.additional_properties == {"test": "metadata"}
# Content types should be different
assert isinstance(text_response.contents[0], TextContent)
assert isinstance(reasoning_response.contents[0], TextReasoningContent)
assert text_response.contents[0].type == "text"
assert reasoning_response.contents[0].type == "text_reasoning"
def test_parse_response_from_openai_image_generation_raw_base64():
@@ -1521,11 +1506,11 @@ def test_parse_response_from_openai_image_generation_raw_base64():
# Verify the response contains call + result with DataContent output
assert len(response.messages[0].contents) == 2
call_content, result_content = response.messages[0].contents
assert isinstance(call_content, ImageGenerationToolCallContent)
assert isinstance(result_content, ImageGenerationToolResultContent)
assert call_content.type == "image_generation_tool_call"
assert result_content.type == "image_generation_tool_result"
assert result_content.outputs
data_out = result_content.outputs
assert isinstance(data_out, DataContent)
assert data_out.type == "data"
assert data_out.uri.startswith("data:image/png;base64,")
assert data_out.media_type == "image/png"
@@ -1558,11 +1543,11 @@ def test_parse_response_from_openai_image_generation_existing_data_uri():
# Verify the response contains call + result with DataContent output
assert len(response.messages[0].contents) == 2
call_content, result_content = response.messages[0].contents
assert isinstance(call_content, ImageGenerationToolCallContent)
assert isinstance(result_content, ImageGenerationToolResultContent)
assert call_content.type == "image_generation_tool_call"
assert result_content.type == "image_generation_tool_result"
assert result_content.outputs
data_out = result_content.outputs
assert isinstance(data_out, DataContent)
assert data_out.type == "data"
assert data_out.uri == f"data:image/webp;base64,{valid_webp_base64}"
assert data_out.media_type == "image/webp"
@@ -1591,9 +1576,9 @@ def test_parse_response_from_openai_image_generation_format_detection():
with patch.object(client, "_get_metadata_from_response", return_value={}):
response_jpeg = client._parse_response_from_openai(mock_response_jpeg, options={}) # type: ignore
result_contents = response_jpeg.messages[0].contents
assert isinstance(result_contents[1], ImageGenerationToolResultContent)
assert result_contents[1].type == "image_generation_tool_result"
outputs = result_contents[1].outputs
assert outputs and isinstance(outputs, DataContent)
assert outputs and outputs.type == "data"
assert outputs.media_type == "image/jpeg"
assert "data:image/jpeg;base64," in outputs.uri
@@ -1617,7 +1602,7 @@ def test_parse_response_from_openai_image_generation_format_detection():
with patch.object(client, "_get_metadata_from_response", return_value={}):
response_webp = client._parse_response_from_openai(mock_response_webp, options={}) # type: ignore
outputs_webp = response_webp.messages[0].contents[1].outputs
assert outputs_webp and isinstance(outputs_webp, DataContent)
assert outputs_webp and outputs_webp.type == "data"
assert outputs_webp.media_type == "image/webp"
assert "data:image/webp;base64," in outputs_webp.uri
@@ -1650,7 +1635,7 @@ def test_parse_response_from_openai_image_generation_fallback():
# Verify it falls back to PNG format for unrecognized binary data
assert len(response.messages[0].contents) == 2
result_content = response.messages[0].contents[1]
assert isinstance(result_content, ImageGenerationToolResultContent)
assert result_content.type == "image_generation_tool_result"
assert result_content.outputs
content = result_content.outputs
assert content.media_type == "image/png"
@@ -1944,7 +1929,7 @@ async def test_integration_streaming_file_search() -> None:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
if content.type == "text" and content.text:
full_message += content.text
await delete_vector_store(openai_responses_client, file_id, vector_store.vector_store_id)
@@ -5,7 +5,7 @@
import json
from datetime import datetime
from agent_framework._types import FunctionResultContent
from agent_framework import Content
from agent_framework.observability import _to_otel_part
@@ -14,7 +14,7 @@ def test_datetime_in_tool_results() -> None:
Reproduces issue #2219 where datetime objects caused TypeError.
"""
content = FunctionResultContent(
content = Content.from_function_result(
call_id="test-call",
result={"timestamp": datetime(2025, 11, 16, 10, 30, 0)},
)
@@ -11,9 +11,9 @@ from agent_framework import (
BaseAgent,
ChatMessage,
ChatMessageStore,
Content,
Role,
SequentialBuilder,
TextContent,
WorkflowOutputEvent,
WorkflowRunState,
WorkflowStatusEvent,
@@ -49,7 +49,7 @@ class _CountingAgent(BaseAgent):
**kwargs: Any,
) -> AsyncIterable[AgentResponseUpdate]:
self.call_count += 1
yield AgentResponseUpdate(contents=[TextContent(text=f"Response #{self.call_count}: {self.name}")])
yield AgentResponseUpdate(contents=[Content.from_text(text=f"Response #{self.call_count}: {self.name}")])
async def test_agent_executor_checkpoint_stores_and_restores_state() -> None:
@@ -19,12 +19,9 @@ from agent_framework import (
ChatMessage,
ChatResponse,
ChatResponseUpdate,
FunctionApprovalRequestContent,
FunctionCallContent,
FunctionResultContent,
Content,
RequestInfoEvent,
Role,
TextContent,
WorkflowBuilder,
WorkflowContext,
WorkflowOutputEvent,
@@ -60,14 +57,14 @@ class _ToolCallingAgent(BaseAgent):
"""Simulate streaming with tool calls and results."""
# First update: some text
yield AgentResponseUpdate(
contents=[TextContent(text="Let me search for that...")],
contents=[Content.from_text(text="Let me search for that...")],
role=Role.ASSISTANT,
)
# Second update: tool call (no text!)
yield AgentResponseUpdate(
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="call_123",
name="search",
arguments={"query": "weather"},
@@ -79,7 +76,7 @@ class _ToolCallingAgent(BaseAgent):
# Third update: tool result (no text!)
yield AgentResponseUpdate(
contents=[
FunctionResultContent(
Content.from_function_result(
call_id="call_123",
result={"temperature": 72, "condition": "sunny"},
)
@@ -89,7 +86,7 @@ class _ToolCallingAgent(BaseAgent):
# Fourth update: final text response
yield AgentResponseUpdate(
contents=[TextContent(text="The weather is sunny, 72°F.")],
contents=[Content.from_text(text="The weather is sunny, 72°F.")],
role=Role.ASSISTANT,
)
@@ -113,25 +110,25 @@ async def test_agent_executor_emits_tool_calls_in_streaming_mode() -> None:
# First event: text update
assert events[0].data is not None
assert isinstance(events[0].data.contents[0], TextContent)
assert events[0].data.contents[0].type == "text"
assert "Let me search" in events[0].data.contents[0].text
# Second event: function call
assert events[1].data is not None
assert isinstance(events[1].data.contents[0], FunctionCallContent)
assert events[1].data.contents[0].type == "function_call"
func_call = events[1].data.contents[0]
assert func_call.call_id == "call_123"
assert func_call.name == "search"
# Third event: function result
assert events[2].data is not None
assert isinstance(events[2].data.contents[0], FunctionResultContent)
assert events[2].data.contents[0].type == "function_result"
func_result = events[2].data.contents[0]
assert func_result.call_id == "call_123"
# Fourth event: final text
assert events[3].data is not None
assert isinstance(events[3].data.contents[0], TextContent)
assert events[3].data.contents[0].type == "text"
assert "sunny" in events[3].data.contents[0].text
@@ -161,10 +158,10 @@ class MockChatClient:
messages=ChatMessage(
role="assistant",
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="1", name="mock_tool_requiring_approval", arguments='{"query": "test"}'
),
FunctionCallContent(
Content.from_function_call(
call_id="2", name="mock_tool_requiring_approval", arguments='{"query": "test"}'
),
],
@@ -175,7 +172,7 @@ class MockChatClient:
messages=ChatMessage(
role="assistant",
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="1", name="mock_tool_requiring_approval", arguments='{"query": "test"}'
)
],
@@ -196,10 +193,10 @@ class MockChatClient:
if self._parallel_request:
yield ChatResponseUpdate(
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="1", name="mock_tool_requiring_approval", arguments='{"query": "test"}'
),
FunctionCallContent(
Content.from_function_call(
call_id="2", name="mock_tool_requiring_approval", arguments='{"query": "test"}'
),
],
@@ -208,15 +205,15 @@ class MockChatClient:
else:
yield ChatResponseUpdate(
contents=[
FunctionCallContent(
Content.from_function_call(
call_id="1", name="mock_tool_requiring_approval", arguments='{"query": "test"}'
)
],
role="assistant",
)
else:
yield ChatResponseUpdate(text=TextContent(text="Tool executed "), role="assistant")
yield ChatResponseUpdate(contents=[TextContent(text="successfully.")], role="assistant")
yield ChatResponseUpdate(text=Content.from_text(text="Tool executed "), role="assistant")
yield ChatResponseUpdate(contents=[Content.from_text(text="successfully.")], role="assistant")
self._iteration += 1
@@ -243,12 +240,14 @@ async def test_agent_executor_tool_call_with_approval() -> None:
# Assert
assert len(events.get_request_info_events()) == 1
approval_request = events.get_request_info_events()[0]
assert isinstance(approval_request.data, FunctionApprovalRequestContent)
assert approval_request.data.type == "function_approval_request"
assert approval_request.data.function_call.name == "mock_tool_requiring_approval"
assert approval_request.data.function_call.arguments == '{"query": "test"}'
# Act
events = await workflow.send_responses({approval_request.request_id: approval_request.data.create_response(True)})
events = await workflow.send_responses({
approval_request.request_id: approval_request.data.to_function_approval_response(True)
})
# Assert
final_response = events.get_outputs()
@@ -276,14 +275,14 @@ async def test_agent_executor_tool_call_with_approval_streaming() -> None:
# Assert
assert len(request_info_events) == 1
approval_request = request_info_events[0]
assert isinstance(approval_request.data, FunctionApprovalRequestContent)
assert approval_request.data.type == "function_approval_request"
assert approval_request.data.function_call.name == "mock_tool_requiring_approval"
assert approval_request.data.function_call.arguments == '{"query": "test"}'
# Act
output: str | None = None
async for event in workflow.send_responses_streaming({
approval_request.request_id: approval_request.data.create_response(True)
approval_request.request_id: approval_request.data.to_function_approval_response(True)
}):
if isinstance(event, WorkflowOutputEvent):
output = event.data
@@ -310,13 +309,13 @@ async def test_agent_executor_parallel_tool_call_with_approval() -> None:
# Assert
assert len(events.get_request_info_events()) == 2
for approval_request in events.get_request_info_events():
assert isinstance(approval_request.data, FunctionApprovalRequestContent)
assert approval_request.data.type == "function_approval_request"
assert approval_request.data.function_call.name == "mock_tool_requiring_approval"
assert approval_request.data.function_call.arguments == '{"query": "test"}'
# Act
responses = {
approval_request.request_id: approval_request.data.create_response(True) # type: ignore
approval_request.request_id: approval_request.data.to_function_approval_response(True) # type: ignore
for approval_request in events.get_request_info_events()
}
events = await workflow.send_responses(responses)
@@ -347,13 +346,13 @@ async def test_agent_executor_parallel_tool_call_with_approval_streaming() -> No
# Assert
assert len(request_info_events) == 2
for approval_request in request_info_events:
assert isinstance(approval_request.data, FunctionApprovalRequestContent)
assert approval_request.data.type == "function_approval_request"
assert approval_request.data.function_call.name == "mock_tool_requiring_approval"
assert approval_request.data.function_call.arguments == '{"query": "test"}'
# Act
responses = {
approval_request.request_id: approval_request.data.create_response(True) # type: ignore
approval_request.request_id: approval_request.data.to_function_approval_response(True) # type: ignore
for approval_request in request_info_events
}
@@ -14,10 +14,10 @@ from agent_framework import (
AgentThread,
BaseAgent,
ChatMessage,
Content,
Executor,
Role,
SequentialBuilder,
TextContent,
WorkflowBuilder,
WorkflowContext,
WorkflowRunState,
@@ -50,7 +50,7 @@ class _SimpleAgent(BaseAgent):
**kwargs: Any,
) -> AsyncIterable[AgentResponseUpdate]:
# This agent does not support streaming; yield a single complete response
yield AgentResponseUpdate(contents=[TextContent(text=self._reply_text)])
yield AgentResponseUpdate(contents=[Content.from_text(text=self._reply_text)])
class _CaptureFullConversation(Executor):
@@ -136,7 +136,7 @@ class _CaptureAgent(BaseAgent):
elif isinstance(m, str):
norm.append(ChatMessage(role=Role.USER, text=m))
self._last_messages = norm
yield AgentResponseUpdate(contents=[TextContent(text=self._reply_text)])
yield AgentResponseUpdate(contents=[Content.from_text(text=self._reply_text)])
async def test_sequential_adapter_uses_full_conversation() -> None:
@@ -17,6 +17,7 @@ from agent_framework import (
ChatMessage,
ChatResponse,
ChatResponseUpdate,
Content,
GroupChatBuilder,
GroupChatState,
MagenticContext,
@@ -25,7 +26,6 @@ from agent_framework import (
MagenticProgressLedgerItem,
RequestInfoEvent,
Role,
TextContent,
WorkflowOutputEvent,
WorkflowRunState,
WorkflowStatusEvent,
@@ -57,7 +57,7 @@ class StubAgent(BaseAgent):
) -> AsyncIterable[AgentResponseUpdate]:
async def _stream() -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(
contents=[TextContent(text=self._reply_text)], role=Role.ASSISTANT, author_name=self.name
contents=[Content.from_text(text=self._reply_text)], role=Role.ASSISTANT, author_name=self.name
)
return _stream()
@@ -141,7 +141,7 @@ class StubManagerAgent(ChatAgent):
async def _stream_initial() -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(
contents=[
TextContent(
Content.from_text(
text=(
'{"terminate": false, "reason": "Selecting agent", '
'"next_speaker": "agent", "final_message": null}'
@@ -157,7 +157,7 @@ class StubManagerAgent(ChatAgent):
async def _stream_final() -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(
contents=[
TextContent(
Content.from_text(
text=(
'{"terminate": true, "reason": "Task complete", '
'"next_speaker": null, "final_message": "agent manager final"}'
@@ -11,12 +11,11 @@ from agent_framework import (
ChatMessage,
ChatResponse,
ChatResponseUpdate,
FunctionCallContent,
Content,
HandoffAgentUserRequest,
HandoffBuilder,
RequestInfoEvent,
Role,
TextContent,
WorkflowEvent,
WorkflowOutputEvent,
resolve_agent_id,
@@ -74,14 +73,16 @@ def _build_reply_contents(
agent_name: str,
handoff_to: str | None,
call_id: str | None,
) -> list[TextContent | FunctionCallContent]:
contents: list[TextContent | FunctionCallContent] = []
) -> list[Content]:
contents: list[Content] = []
if handoff_to and call_id:
contents.append(
FunctionCallContent(call_id=call_id, name=f"handoff_to_{handoff_to}", arguments={"handoff_to": handoff_to})
Content.from_function_call(
call_id=call_id, name=f"handoff_to_{handoff_to}", arguments={"handoff_to": handoff_to}
)
)
text = f"{agent_name} reply"
contents.append(TextContent(text=text))
contents.append(Content.from_text(text=text))
return contents
@@ -15,6 +15,7 @@ from agent_framework import (
AgentThread,
BaseAgent,
ChatMessage,
Content,
Executor,
GroupChatRequestMessage,
MagenticBuilder,
@@ -28,7 +29,6 @@ from agent_framework import (
RequestInfoEvent,
Role,
StandardMagenticManager,
TextContent,
Workflow,
WorkflowCheckpoint,
WorkflowCheckpointException,
@@ -172,7 +172,7 @@ class StubAgent(BaseAgent):
) -> AsyncIterable[AgentResponseUpdate]:
async def _stream() -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(
contents=[TextContent(text=self._reply_text)], role=Role.ASSISTANT, author_name=self.name
contents=[Content.from_text(text=self._reply_text)], role=Role.ASSISTANT, author_name=self.name
)
return _stream()
@@ -541,7 +541,7 @@ class StubThreadAgent(BaseAgent):
async def run_stream(self, messages=None, *, thread=None, **kwargs): # type: ignore[override]
yield AgentResponseUpdate(
contents=[TextContent(text="thread-ok")],
contents=[Content.from_text(text="thread-ok")],
author_name=self.name,
role=Role.ASSISTANT,
)
@@ -563,7 +563,7 @@ class StubAssistantsAgent(BaseAgent):
async def run_stream(self, messages=None, *, thread=None, **kwargs): # type: ignore[override]
yield AgentResponseUpdate(
contents=[TextContent(text="assistants-ok")],
contents=[Content.from_text(text="assistants-ok")],
author_name=self.name,
role=Role.ASSISTANT,
)
@@ -12,10 +12,10 @@ from agent_framework import (
AgentThread,
BaseAgent,
ChatMessage,
Content,
Executor,
Role,
SequentialBuilder,
TextContent,
TypeCompatibilityError,
WorkflowContext,
WorkflowOutputEvent,
@@ -46,7 +46,7 @@ class _EchoAgent(BaseAgent):
**kwargs: Any,
) -> AsyncIterable[AgentResponseUpdate]:
# Minimal async generator with one assistant update
yield AgentResponseUpdate(contents=[TextContent(text=f"{self.name} reply")])
yield AgentResponseUpdate(contents=[Content.from_text(text=f"{self.name} reply")])
class _SummarizerExec(Executor):
@@ -18,12 +18,12 @@ from agent_framework import (
AgentThread,
BaseAgent,
ChatMessage,
Content,
Executor,
FileCheckpointStorage,
Message,
RequestInfoEvent,
Role,
TextContent,
WorkflowBuilder,
WorkflowCheckpointException,
WorkflowContext,
@@ -881,7 +881,7 @@ class _StreamingTestAgent(BaseAgent):
"""Streaming run - yields incremental updates."""
# Simulate streaming by yielding character by character
for char in self._reply_text:
yield AgentResponseUpdate(contents=[TextContent(text=char)])
yield AgentResponseUpdate(contents=[Content.from_text(text=char)])
async def test_agent_streaming_vs_non_streaming() -> None:
@@ -14,16 +14,9 @@ from agent_framework import (
AgentThread,
ChatMessage,
ChatMessageStore,
DataContent,
Content,
Executor,
FunctionApprovalRequestContent,
FunctionApprovalResponseContent,
FunctionCallContent,
FunctionResultContent,
Role,
TextContent,
UriContent,
UsageContent,
UsageDetails,
WorkflowAgent,
WorkflowBuilder,
@@ -44,17 +37,15 @@ class SimpleExecutor(Executor):
@handler
async def handle_message(self, message: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None:
input_text = (
message[0].contents[0].text if message and isinstance(message[0].contents[0], TextContent) else "no input"
)
input_text = message[0].contents[0].text if message and message[0].contents[0].type == "text" else "no input"
response_text = f"{self.response_text}: {input_text}"
# Create response message for both streaming and non-streaming cases
response_message = ChatMessage(role=Role.ASSISTANT, contents=[TextContent(text=response_text)])
response_message = ChatMessage(role=Role.ASSISTANT, contents=[Content.from_text(text=response_text)])
# Emit update event.
streaming_update = AgentResponseUpdate(
contents=[TextContent(text=response_text)], role=Role.ASSISTANT, message_id=str(uuid.uuid4())
contents=[Content.from_text(text=response_text)], role=Role.ASSISTANT, message_id=str(uuid.uuid4())
)
await ctx.add_event(AgentRunUpdateEvent(executor_id=self.id, data=streaming_update))
@@ -76,7 +67,7 @@ class RequestingExecutor(Executor):
) -> None:
# Handle the response and emit completion response
update = AgentResponseUpdate(
contents=[TextContent(text="Request completed successfully")],
contents=[Content.from_text(text="Request completed successfully")],
role=Role.ASSISTANT,
message_id=str(uuid.uuid4()),
)
@@ -99,10 +90,10 @@ class ConversationHistoryCapturingExecutor(Executor):
message_count = len(messages)
response_text = f"Received {message_count} messages"
response_message = ChatMessage(role=Role.ASSISTANT, contents=[TextContent(text=response_text)])
response_message = ChatMessage(role=Role.ASSISTANT, contents=[Content.from_text(text=response_text)])
streaming_update = AgentResponseUpdate(
contents=[TextContent(text=response_text)], role=Role.ASSISTANT, message_id=str(uuid.uuid4())
contents=[Content.from_text(text=response_text)], role=Role.ASSISTANT, message_id=str(uuid.uuid4())
)
await ctx.add_event(AgentRunUpdateEvent(executor_id=self.id, data=streaming_update))
await ctx.send_message([response_message])
@@ -134,7 +125,7 @@ class TestWorkflowAgent:
for message in result.messages:
first_content = message.contents[0]
if isinstance(first_content, TextContent):
if first_content.type == "text":
text = first_content.text
if text.startswith("Step1:"):
step1_messages.append(message)
@@ -172,11 +163,11 @@ class TestWorkflowAgent:
# Verify we got a streaming update
assert updates[0].contents is not None
first_content: TextContent = updates[0].contents[0] # type: ignore[assignment]
second_content: TextContent = updates[1].contents[0] # type: ignore[assignment]
assert isinstance(first_content, TextContent)
first_content: Content = updates[0].contents[0] # type: ignore[assignment]
second_content: Content = updates[1].contents[0] # type: ignore[assignment]
assert first_content.type == "text"
assert "Streaming1: Test input" in first_content.text
assert isinstance(second_content, TextContent)
assert second_content.type == "text"
assert "Streaming2: Streaming1: Test input" in second_content.text
async def test_end_to_end_request_info_handling(self):
@@ -200,17 +191,15 @@ class TestWorkflowAgent:
approval_update: AgentResponseUpdate | None = None
for update in updates:
if any(isinstance(content, FunctionApprovalRequestContent) for content in update.contents):
if any(content.type == "function_approval_request" for content in update.contents):
approval_update = update
break
assert approval_update is not None, "Should have received a request_info approval request"
function_call = next(
content for content in approval_update.contents if isinstance(content, FunctionCallContent)
)
function_call = next(content for content in approval_update.contents if content.type == "function_call")
approval_request = next(
content for content in approval_update.contents if isinstance(content, FunctionApprovalRequestContent)
content for content in approval_update.contents if content.type == "function_approval_request"
)
# Verify the function call has expected structure
@@ -233,10 +222,10 @@ class TestWorkflowAgent:
data="User provided answer",
).to_dict()
approval_response = FunctionApprovalResponseContent(
approval_response = Content.from_function_approval_response(
approved=True,
id=approval_request.id,
function_call=FunctionCallContent(
function_call=Content.from_function_call(
call_id=function_call.call_id,
name=function_call.name,
arguments=response_args,
@@ -306,7 +295,7 @@ class TestWorkflowAgent:
workflow = WorkflowBuilder().set_start_executor(yielding_executor).build()
# Run directly - should return WorkflowOutputEvent in result
direct_result = await workflow.run([ChatMessage(role=Role.USER, contents=[TextContent(text="hello")])])
direct_result = await workflow.run([ChatMessage(role=Role.USER, contents=[Content.from_text(text="hello")])])
direct_outputs = direct_result.get_outputs()
assert len(direct_outputs) == 1
assert direct_outputs[0] == "processed: hello"
@@ -340,14 +329,14 @@ class TestWorkflowAgent:
assert "second output" in texts
async def test_workflow_as_agent_yield_output_with_content_types(self) -> None:
"""Test that yield_output preserves different content types (TextContent, DataContent, etc.)."""
"""Test that yield_output preserves different content types (Content, Content, etc.)."""
@executor
async def content_yielding_executor(messages: list[ChatMessage], ctx: WorkflowContext) -> None:
# Yield different content types
await ctx.yield_output(TextContent(text="text content"))
await ctx.yield_output(DataContent(data=b"binary data", media_type="application/octet-stream"))
await ctx.yield_output(UriContent(uri="https://example.com/image.png", media_type="image/png"))
await ctx.yield_output(Content.from_text(text="text content"))
await ctx.yield_output(Content.from_data(data=b"binary data", media_type="application/octet-stream"))
await ctx.yield_output(Content.from_uri(uri="https://example.com/image.png", media_type="image/png"))
workflow = WorkflowBuilder().set_start_executor(content_yielding_executor).build()
agent = workflow.as_agent("content-test-agent")
@@ -358,13 +347,13 @@ class TestWorkflowAgent:
assert len(result.messages) == 3
# Verify each content type is preserved
assert isinstance(result.messages[0].contents[0], TextContent)
assert result.messages[0].contents[0].type == "text"
assert result.messages[0].contents[0].text == "text content"
assert isinstance(result.messages[1].contents[0], DataContent)
assert result.messages[1].contents[0].type == "data"
assert result.messages[1].contents[0].media_type == "application/octet-stream"
assert isinstance(result.messages[2].contents[0], UriContent)
assert result.messages[2].contents[0].type == "uri"
assert result.messages[2].contents[0].uri == "https://example.com/image.png"
async def test_workflow_as_agent_yield_output_with_chat_message(self) -> None:
@@ -374,7 +363,7 @@ class TestWorkflowAgent:
async def chat_message_executor(messages: list[ChatMessage], ctx: WorkflowContext) -> None:
msg = ChatMessage(
role=Role.ASSISTANT,
contents=[TextContent(text="response text")],
contents=[Content.from_text(text="response text")],
author_name="custom-author",
)
await ctx.yield_output(msg)
@@ -404,7 +393,7 @@ class TestWorkflowAgent:
async def raw_yielding_executor(messages: list[ChatMessage], ctx: WorkflowContext) -> None:
# Yield different types of data
await ctx.yield_output("simple string")
await ctx.yield_output(TextContent(text="text content"))
await ctx.yield_output(Content.from_text(text="text content"))
custom = CustomData(42)
await ctx.yield_output(custom)
@@ -420,7 +409,7 @@ class TestWorkflowAgent:
# Verify raw_representation is set for each update
assert updates[0].raw_representation == "simple string"
assert isinstance(updates[1].raw_representation, TextContent)
assert updates[1].raw_representation.type == "text"
assert updates[1].raw_representation.text == "text content"
assert isinstance(updates[2].raw_representation, CustomData)
assert updates[2].raw_representation.value == 42
@@ -428,19 +417,19 @@ class TestWorkflowAgent:
async def test_workflow_as_agent_yield_output_with_list_of_chat_messages(self) -> None:
"""Test that yield_output with list[ChatMessage] extracts contents from all messages.
Note: TextContent items are coalesced by _finalize_response, so multiple text contents
become a single merged TextContent in the final response.
Note: Content items are coalesced by _finalize_response, so multiple text contents
become a single merged Content in the final response.
"""
@executor
async def list_yielding_executor(messages: list[ChatMessage], ctx: WorkflowContext) -> None:
# Yield a list of ChatMessages (as SequentialBuilder does)
msg_list = [
ChatMessage(role=Role.USER, contents=[TextContent(text="first message")]),
ChatMessage(role=Role.ASSISTANT, contents=[TextContent(text="second message")]),
ChatMessage(role=Role.USER, contents=[Content.from_text(text="first message")]),
ChatMessage(role=Role.ASSISTANT, contents=[Content.from_text(text="second message")]),
ChatMessage(
role=Role.ASSISTANT,
contents=[TextContent(text="third"), TextContent(text="fourth")],
contents=[Content.from_text(text="third"), Content.from_text(text="fourth")],
),
]
await ctx.yield_output(msg_list)
@@ -455,7 +444,7 @@ class TestWorkflowAgent:
assert len(updates) == 1
assert len(updates[0].contents) == 4
texts = [c.text for c in updates[0].contents if isinstance(c, TextContent)]
texts = [c.text for c in updates[0].contents if c.type == "text"]
assert texts == ["first message", "second message", "third", "fourth"]
# Verify run() coalesces text contents (expected behavior)
@@ -463,7 +452,7 @@ class TestWorkflowAgent:
assert isinstance(result, AgentResponse)
assert len(result.messages) == 1
# TextContent items are coalesced into one
# Content items are coalesced into one
assert len(result.messages[0].contents) == 1
assert result.messages[0].text == "first messagesecond messagethirdfourth"
@@ -599,7 +588,7 @@ class TestWorkflowAgent:
) -> AsyncIterable[AgentResponseUpdate]:
for word in self._response_text.split():
yield AgentResponseUpdate(
contents=[TextContent(text=word + " ")],
contents=[Content.from_text(text=word + " ")],
role=Role.ASSISTANT,
author_name=self._name,
)
@@ -672,7 +661,7 @@ class TestWorkflowAgent:
self, messages: Any, *, thread: AgentThread | None = None, **kwargs: Any
) -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(
contents=[TextContent(text=self._response_text)],
contents=[Content.from_text(text=self._response_text)],
role=Role.ASSISTANT,
author_name=self._name,
)
@@ -738,7 +727,7 @@ class TestWorkflowAgentAuthorName:
async def handle_message(self, message: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None:
# Emit update with explicit author_name
update = AgentResponseUpdate(
contents=[TextContent(text="Response with author")],
contents=[Content.from_text(text="Response with author")],
role=Role.ASSISTANT,
author_name="custom_author_name", # Explicitly set
message_id=str(uuid.uuid4()),
@@ -790,7 +779,7 @@ class TestWorkflowAgentMergeUpdates:
updates = [
# Response B, Message 2 (latest in resp B)
AgentResponseUpdate(
contents=[TextContent(text="RespB-Msg2")],
contents=[Content.from_text(text="RespB-Msg2")],
role=Role.ASSISTANT,
response_id="resp-b",
message_id="msg-2",
@@ -798,7 +787,7 @@ class TestWorkflowAgentMergeUpdates:
),
# Response A, Message 1 (earliest overall)
AgentResponseUpdate(
contents=[TextContent(text="RespA-Msg1")],
contents=[Content.from_text(text="RespA-Msg1")],
role=Role.ASSISTANT,
response_id="resp-a",
message_id="msg-1",
@@ -806,7 +795,7 @@ class TestWorkflowAgentMergeUpdates:
),
# Response B, Message 1 (earlier in resp B)
AgentResponseUpdate(
contents=[TextContent(text="RespB-Msg1")],
contents=[Content.from_text(text="RespB-Msg1")],
role=Role.ASSISTANT,
response_id="resp-b",
message_id="msg-1",
@@ -814,7 +803,7 @@ class TestWorkflowAgentMergeUpdates:
),
# Response A, Message 2 (later in resp A)
AgentResponseUpdate(
contents=[TextContent(text="RespA-Msg2")],
contents=[Content.from_text(text="RespA-Msg2")],
role=Role.ASSISTANT,
response_id="resp-a",
message_id="msg-2",
@@ -822,7 +811,7 @@ class TestWorkflowAgentMergeUpdates:
),
# Global dangling update (no response_id) - should go at end
AgentResponseUpdate(
contents=[TextContent(text="Global-Dangling")],
contents=[Content.from_text(text="Global-Dangling")],
role=Role.ASSISTANT,
response_id=None,
message_id="msg-global",
@@ -841,9 +830,7 @@ class TestWorkflowAgentMergeUpdates:
# Verify ordering: responses are processed by response_id groups,
# within each group messages are chronologically ordered,
# global dangling goes at the end
message_texts = [
msg.contents[0].text if isinstance(msg.contents[0], TextContent) else "" for msg in result.messages
]
message_texts = [msg.contents[0].text if msg.contents[0].type == "text" else "" for msg in result.messages]
# The exact order depends on dict iteration order for response_ids,
# but within each response group, chronological order should be maintained
@@ -894,9 +881,9 @@ class TestWorkflowAgentMergeUpdates:
updates = [
AgentResponseUpdate(
contents=[
TextContent(text="First"),
UsageContent(
details=UsageDetails(input_token_count=10, output_token_count=5, total_token_count=15)
Content.from_text(text="First"),
Content.from_usage(
usage_details={"input_token_count": 10, "output_token_count": 5, "total_token_count": 15}
),
],
role=Role.ASSISTANT,
@@ -907,9 +894,9 @@ class TestWorkflowAgentMergeUpdates:
),
AgentResponseUpdate(
contents=[
TextContent(text="Second"),
UsageContent(
details=UsageDetails(input_token_count=20, output_token_count=8, total_token_count=28)
Content.from_text(text="Second"),
Content.from_usage(
usage_details={"input_token_count": 20, "output_token_count": 8, "total_token_count": 28}
),
],
role=Role.ASSISTANT,
@@ -920,8 +907,10 @@ class TestWorkflowAgentMergeUpdates:
),
AgentResponseUpdate(
contents=[
TextContent(text="Third"),
UsageContent(details=UsageDetails(input_token_count=5, output_token_count=3, total_token_count=8)),
Content.from_text(text="Third"),
Content.from_usage(
usage_details={"input_token_count": 5, "output_token_count": 3, "total_token_count": 8}
),
],
role=Role.ASSISTANT,
response_id="resp-1", # Same response_id as first
@@ -985,7 +974,7 @@ class TestWorkflowAgentMergeUpdates:
updates = [
# User question
AgentResponseUpdate(
contents=[TextContent(text="What is the weather?")],
contents=[Content.from_text(text="What is the weather?")],
role=Role.USER,
response_id="resp-1",
message_id="msg-1",
@@ -993,7 +982,9 @@ class TestWorkflowAgentMergeUpdates:
),
# Assistant with function call
AgentResponseUpdate(
contents=[FunctionCallContent(call_id=call_id, name="get_weather", arguments='{"location": "NYC"}')],
contents=[
Content.from_function_call(call_id=call_id, name="get_weather", arguments='{"location": "NYC"}')
],
role=Role.ASSISTANT,
response_id="resp-1",
message_id="msg-2",
@@ -1002,7 +993,7 @@ class TestWorkflowAgentMergeUpdates:
# Function result: no response_id previously caused this to go to global_dangling
# and be placed at the end (the bug); fix now correctly associates via call_id
AgentResponseUpdate(
contents=[FunctionResultContent(call_id=call_id, result="Sunny, 72F")],
contents=[Content.from_function_result(call_id=call_id, result="Sunny, 72F")],
role=Role.TOOL,
response_id=None,
message_id="msg-3",
@@ -1010,7 +1001,7 @@ class TestWorkflowAgentMergeUpdates:
),
# Final assistant answer
AgentResponseUpdate(
contents=[TextContent(text="The weather in NYC is sunny and 72F.")],
contents=[Content.from_text(text="The weather in NYC is sunny and 72F.")],
role=Role.ASSISTANT,
response_id="resp-1",
message_id="msg-4",
@@ -1026,11 +1017,11 @@ class TestWorkflowAgentMergeUpdates:
content_sequence = []
for msg in result.messages:
for content in msg.contents:
if isinstance(content, TextContent):
if content.type == "text":
content_sequence.append(("text", msg.role))
elif isinstance(content, FunctionCallContent):
elif content.type == "function_call":
content_sequence.append(("function_call", msg.role))
elif isinstance(content, FunctionResultContent):
elif content.type == "function_result":
content_sequence.append(("function_result", msg.role))
# Verify correct ordering: user -> function_call -> function_result -> assistant_answer
@@ -1051,10 +1042,10 @@ class TestWorkflowAgentMergeUpdates:
function_result_idx = None
for i, msg in enumerate(result.messages):
for content in msg.contents:
if isinstance(content, FunctionCallContent):
if content.type == "function_call":
function_call_idx = i
assert content.call_id == call_id
elif isinstance(content, FunctionResultContent):
elif content.type == "function_result":
function_result_idx = i
assert content.call_id == call_id
@@ -1081,7 +1072,7 @@ class TestWorkflowAgentMergeUpdates:
updates = [
# User question
AgentResponseUpdate(
contents=[TextContent(text="What's the weather and time?")],
contents=[Content.from_text(text="What's the weather and time?")],
role=Role.USER,
response_id="resp-1",
message_id="msg-1",
@@ -1089,7 +1080,9 @@ class TestWorkflowAgentMergeUpdates:
),
# Assistant with first function call
AgentResponseUpdate(
contents=[FunctionCallContent(call_id=call_id_1, name="get_weather", arguments='{"location": "NYC"}')],
contents=[
Content.from_function_call(call_id=call_id_1, name="get_weather", arguments='{"location": "NYC"}')
],
role=Role.ASSISTANT,
response_id="resp-1",
message_id="msg-2",
@@ -1097,7 +1090,9 @@ class TestWorkflowAgentMergeUpdates:
),
# Assistant with second function call
AgentResponseUpdate(
contents=[FunctionCallContent(call_id=call_id_2, name="get_time", arguments='{"timezone": "EST"}')],
contents=[
Content.from_function_call(call_id=call_id_2, name="get_time", arguments='{"timezone": "EST"}')
],
role=Role.ASSISTANT,
response_id="resp-1",
message_id="msg-3",
@@ -1105,7 +1100,7 @@ class TestWorkflowAgentMergeUpdates:
),
# Second function result arrives first (no response_id)
AgentResponseUpdate(
contents=[FunctionResultContent(call_id=call_id_2, result="3:00 PM EST")],
contents=[Content.from_function_result(call_id=call_id_2, result="3:00 PM EST")],
role=Role.TOOL,
response_id=None,
message_id="msg-4",
@@ -1113,7 +1108,7 @@ class TestWorkflowAgentMergeUpdates:
),
# First function result arrives second (no response_id)
AgentResponseUpdate(
contents=[FunctionResultContent(call_id=call_id_1, result="Sunny, 72F")],
contents=[Content.from_function_result(call_id=call_id_1, result="Sunny, 72F")],
role=Role.TOOL,
response_id=None,
message_id="msg-5",
@@ -1121,7 +1116,7 @@ class TestWorkflowAgentMergeUpdates:
),
# Final assistant answer
AgentResponseUpdate(
contents=[TextContent(text="It's sunny (72F) and 3 PM in NYC.")],
contents=[Content.from_text(text="It's sunny (72F) and 3 PM in NYC.")],
role=Role.ASSISTANT,
response_id="resp-1",
message_id="msg-6",
@@ -1137,11 +1132,11 @@ class TestWorkflowAgentMergeUpdates:
content_sequence = []
for msg in result.messages:
for content in msg.contents:
if isinstance(content, TextContent):
if content.type == "text":
content_sequence.append(("text", None))
elif isinstance(content, FunctionCallContent):
elif content.type == "function_call":
content_sequence.append(("function_call", content.call_id))
elif isinstance(content, FunctionResultContent):
elif content.type == "function_result":
content_sequence.append(("function_result", content.call_id))
# Verify all function results appear before the final assistant text
@@ -1172,7 +1167,7 @@ class TestWorkflowAgentMergeUpdates:
"""
updates = [
AgentResponseUpdate(
contents=[TextContent(text="Hello")],
contents=[Content.from_text(text="Hello")],
role=Role.USER,
response_id="resp-1",
message_id="msg-1",
@@ -1180,14 +1175,14 @@ class TestWorkflowAgentMergeUpdates:
),
# Function result with no matching call
AgentResponseUpdate(
contents=[FunctionResultContent(call_id="orphan_call_id", result="orphan result")],
contents=[Content.from_function_result(call_id="orphan_call_id", result="orphan result")],
role=Role.TOOL,
response_id=None,
message_id="msg-2",
created_at="2024-01-01T12:00:01Z",
),
AgentResponseUpdate(
contents=[TextContent(text="Goodbye")],
contents=[Content.from_text(text="Goodbye")],
role=Role.ASSISTANT,
response_id="resp-1",
message_id="msg-3",
@@ -1203,9 +1198,9 @@ class TestWorkflowAgentMergeUpdates:
content_types = []
for msg in result.messages:
for content in msg.contents:
if isinstance(content, TextContent):
if content.type == "text":
content_types.append("text")
elif isinstance(content, FunctionResultContent):
elif content.type == "function_result":
content_types.append("function_result")
# Order: text (user), text (assistant), function_result (orphan at end)
@@ -12,12 +12,12 @@ from agent_framework import (
BaseAgent,
ChatMessage,
ConcurrentBuilder,
Content,
GroupChatBuilder,
GroupChatState,
HandoffBuilder,
Role,
SequentialBuilder,
TextContent,
WorkflowRunState,
WorkflowStatusEvent,
ai_function,
@@ -67,7 +67,7 @@ class _KwargsCapturingAgent(BaseAgent):
**kwargs: Any,
) -> AsyncIterable[AgentResponseUpdate]:
self.captured_kwargs.append(dict(kwargs))
yield AgentResponseUpdate(contents=[TextContent(text=f"{self.name} response")])
yield AgentResponseUpdate(contents=[Content.from_text(text=f"{self.name} response")])
# region Sequential Builder Tests
@@ -9,12 +9,11 @@ from agent_framework import (
AIFunction,
ChatAgent,
ChatClientProtocol,
Content,
HostedCodeInterpreterTool,
HostedFileContent,
HostedFileSearchTool,
HostedMCPSpecificApproval,
HostedMCPTool,
HostedVectorStoreContent,
HostedWebSearchTool,
ToolProtocol,
)
@@ -739,14 +738,14 @@ class AgentFactory:
if tool_resource.filters:
add_props["filters"] = tool_resource.filters
return HostedFileSearchTool(
inputs=[HostedVectorStoreContent(id) for id in tool_resource.vectorStoreIds or []],
inputs=[Content.from_hosted_vector_store(id) for id in tool_resource.vectorStoreIds or []],
description=tool_resource.description,
max_results=tool_resource.maximumResultCount,
additional_properties=add_props,
)
case CodeInterpreterTool():
return HostedCodeInterpreterTool(
inputs=[HostedFileContent(file_id=file) for file in tool_resource.fileIds or []],
inputs=[Content.from_hosted_file(file_id=file) for file in tool_resource.fileIds or []],
description=tool_resource.description,
)
case McpTool():
@@ -21,8 +21,7 @@ from typing import Any, cast
from agent_framework import (
ChatMessage,
FunctionCallContent,
FunctionResultContent,
Content,
WorkflowContext,
handler,
response_handler,
@@ -191,7 +190,7 @@ def _validate_conversation_history(messages: list[ChatMessage], agent_name: str)
if not hasattr(msg, "contents") or msg.contents is None:
continue
for content in msg.contents:
if isinstance(content, FunctionCallContent) and content.call_id:
if content.type == "function_call" and content.call_id:
tool_call_ids.add(content.call_id)
logger.debug(
"Agent '%s': Found tool call '%s' (id=%s) in message %d",
@@ -200,7 +199,7 @@ def _validate_conversation_history(messages: list[ChatMessage], agent_name: str)
content.call_id,
i,
)
elif isinstance(content, FunctionResultContent) and content.call_id:
elif content.type == "function_result" and content.call_id:
tool_result_ids.add(content.call_id)
logger.debug(
"Agent '%s': Found tool result for call_id=%s in message %d",
@@ -265,7 +264,7 @@ class AgentResult:
response: str
agent_name: str
messages: list[ChatMessage] = field(default_factory=lambda: cast(list[ChatMessage], []))
tool_calls: list[FunctionCallContent] = field(default_factory=lambda: cast(list[FunctionCallContent], []))
tool_calls: list[Content] = field(default_factory=lambda: cast(list[Content], []))
error: str | None = None
@@ -311,7 +310,7 @@ class AgentExternalInputRequest:
agent_response: str
iteration: int = 0
messages: list[ChatMessage] = field(default_factory=lambda: cast(list[ChatMessage], []))
function_calls: list[FunctionCallContent] = field(default_factory=lambda: cast(list[FunctionCallContent], []))
function_calls: list[Content] = field(default_factory=lambda: cast(list[Content], []))
@dataclass
@@ -342,9 +341,7 @@ class AgentExternalInputResponse:
user_input: str
messages: list[ChatMessage] = field(default_factory=lambda: cast(list[ChatMessage], []))
function_results: dict[str, FunctionResultContent] = field(
default_factory=lambda: cast(dict[str, FunctionResultContent], {})
)
function_results: dict[str, Content] = field(default_factory=lambda: cast(dict[str, Content], {}))
@dataclass
@@ -641,7 +638,7 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
"""
accumulated_response = ""
all_messages: list[ChatMessage] = []
tool_calls: list[FunctionCallContent] = []
tool_calls: list[Content] = []
# Add user input to conversation history first (via state.append only)
if input_text:
@@ -679,7 +676,7 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
all_messages = list(cast(list[ChatMessage], result_messages))
result_tool_calls: Any = getattr(result, "tool_calls", None)
if result_tool_calls is not None:
tool_calls = list(cast(list[FunctionCallContent], result_tool_calls))
tool_calls = list(cast(list[Content], result_tool_calls))
else:
raise RuntimeError(f"Agent '{agent_name}' has no run or run_stream method")
@@ -321,7 +321,7 @@ class InMemoryConversationStore(ConversationStore):
# Convert ChatMessage contents to OpenAI TextContent format
message_content = []
for content_item in msg.contents:
if hasattr(content_item, "type") and content_item.type == "text":
if content_item.type == "text":
# Extract text from TextContent object
text_value = getattr(content_item, "text", "")
message_content.append(TextContent(type="text", text=text_value))
@@ -7,7 +7,7 @@ import logging
from collections.abc import AsyncGenerator
from typing import Any
from agent_framework import AgentProtocol
from agent_framework import AgentProtocol, Content
from agent_framework._workflows._events import RequestInfoEvent
from ._conversations import ConversationStore, InMemoryConversationStore
@@ -602,7 +602,7 @@ class AgentFrameworkExecutor:
"""
# Import Agent Framework types
try:
from agent_framework import ChatMessage, DataContent, Role, TextContent
from agent_framework import ChatMessage, Role
except ImportError:
# Fallback to string extraction if Agent Framework not available
return self._extract_user_message_fallback(input_data)
@@ -613,14 +613,12 @@ class AgentFrameworkExecutor:
# Handle OpenAI ResponseInputParam (List[ResponseInputItemParam])
if isinstance(input_data, list):
return self._convert_openai_input_to_chat_message(input_data, ChatMessage, TextContent, DataContent, Role)
return self._convert_openai_input_to_chat_message(input_data, ChatMessage, Role)
# Fallback for other formats
return self._extract_user_message_fallback(input_data)
def _convert_openai_input_to_chat_message(
self, input_items: list[Any], ChatMessage: Any, TextContent: Any, DataContent: Any, Role: Any
) -> Any:
def _convert_openai_input_to_chat_message(self, input_items: list[Any], ChatMessage: Any, Role: Any) -> Any:
"""Convert OpenAI ResponseInputParam to Agent Framework ChatMessage.
Processes text, images, files, and other content types from OpenAI format
@@ -629,14 +627,12 @@ class AgentFrameworkExecutor:
Args:
input_items: List of OpenAI ResponseInputItemParam objects (dicts or objects)
ChatMessage: ChatMessage class for creating chat messages
TextContent: TextContent class for text content
DataContent: DataContent class for data/media content
Role: Role enum for message roles
Returns:
ChatMessage with converted content
"""
contents = []
contents: list[Content] = []
# Process each input item
for item in input_items:
@@ -649,7 +645,7 @@ class AgentFrameworkExecutor:
# Handle both string content and list content
if isinstance(message_content, str):
contents.append(TextContent(text=message_content))
contents.append(Content.from_text(text=message_content))
elif isinstance(message_content, list):
for content_item in message_content:
# Handle dict content items
@@ -658,7 +654,7 @@ class AgentFrameworkExecutor:
if content_type == "input_text":
text = content_item.get("text", "")
contents.append(TextContent(text=text))
contents.append(Content.from_text(text=text))
elif content_type == "input_image":
image_url = content_item.get("image_url", "")
@@ -676,7 +672,7 @@ class AgentFrameworkExecutor:
media_type = "image/png"
else:
media_type = "image/png"
contents.append(DataContent(uri=image_url, media_type=media_type))
contents.append(Content.from_uri(uri=image_url, media_type=media_type))
elif content_type == "input_file":
# Handle file input
@@ -710,7 +706,7 @@ class AgentFrameworkExecutor:
# Assume file_data is base64, create data URI
data_uri = f"data:{media_type};base64,{file_data}"
contents.append(
DataContent(
Content.from_uri(
uri=data_uri,
media_type=media_type,
additional_properties=additional_props,
@@ -718,7 +714,7 @@ class AgentFrameworkExecutor:
)
elif file_url:
contents.append(
DataContent(
Content.from_uri(
uri=file_url,
media_type=media_type,
additional_properties=additional_props,
@@ -728,21 +724,19 @@ class AgentFrameworkExecutor:
elif content_type == "function_approval_response":
# Handle function approval response (DevUI extension)
try:
from agent_framework import FunctionApprovalResponseContent, FunctionCallContent
request_id = content_item.get("request_id", "")
approved = content_item.get("approved", False)
function_call_data = content_item.get("function_call", {})
# Create FunctionCallContent from the function_call data
function_call = FunctionCallContent(
function_call = Content.from_function_call(
call_id=function_call_data.get("id", ""),
name=function_call_data.get("name", ""),
arguments=function_call_data.get("arguments", {}),
)
# Create FunctionApprovalResponseContent with correct signature
approval_response = FunctionApprovalResponseContent(
approval_response = Content.from_function_approval_response(
approved, # positional argument
id=request_id, # keyword argument 'id', NOT 'request_id'
function_call=function_call, # FunctionCallContent object
@@ -764,7 +758,7 @@ class AgentFrameworkExecutor:
# If no contents found, create a simple text message
if not contents:
contents.append(TextContent(text=""))
contents.append(Content.from_text(text=""))
chat_message = ChatMessage(role=Role.USER, contents=contents)
@@ -12,7 +12,7 @@ from datetime import datetime
from typing import Any, Union
from uuid import uuid4
from agent_framework import ChatMessage, TextContent
from agent_framework import ChatMessage, Content
from openai.types.responses import (
Response,
ResponseContentPartAddedEvent,
@@ -92,7 +92,7 @@ def _serialize_content_recursive(value: Any) -> Any:
if isinstance(value, (list, tuple)):
serialized = [_serialize_content_recursive(item) for item in value]
# For single-item lists containing text Content, extract just the text
# This handles the MCP case where result = [TextContent(text="Hello")]
# This handles the MCP case where result = [Content.from_text(text="Hello")]
# and we want output = "Hello" not output = '[{"type": "text", "text": "Hello"}]'
if len(serialized) == 1 and isinstance(serialized[0], dict) and serialized[0].get("type") == "text":
return serialized[0].get("text", "")
@@ -127,18 +127,18 @@ class MessageMapper:
# Register content type mappers for all 12 Agent Framework content types
self.content_mappers = {
"TextContent": self._map_text_content,
"TextReasoningContent": self._map_reasoning_content,
"FunctionCallContent": self._map_function_call_content,
"FunctionResultContent": self._map_function_result_content,
"ErrorContent": self._map_error_content,
"UsageContent": self._map_usage_content,
"DataContent": self._map_data_content,
"UriContent": self._map_uri_content,
"HostedFileContent": self._map_hosted_file_content,
"HostedVectorStoreContent": self._map_hosted_vector_store_content,
"FunctionApprovalRequestContent": self._map_approval_request_content,
"FunctionApprovalResponseContent": self._map_approval_response_content,
"text": self._map_text_content,
"text_reasoning": self._map_reasoning_content,
"function_call": self._map_function_call_content,
"function_result": self._map_function_result_content,
"error": self._map_error_content,
"usage": self._map_usage_content,
"data": self._map_data_content,
"uri": self._map_uri_content,
"hosted_file": self._map_hosted_file_content,
"hosted_vector_store": self._map_hosted_vector_store_content,
"function_approval_request": self._map_approval_request_content,
"function_approval_response": self._map_approval_response_content,
}
async def convert_event(self, raw_event: Any, request: AgentFrameworkRequest) -> Sequence[Any]:
@@ -603,7 +603,7 @@ class MessageMapper:
return events
# Check if we're streaming text content
has_text_content = any(isinstance(content, TextContent) for content in update.contents)
has_text_content = any(content.type == "text" for content in update.contents)
# Check if we're in an executor context with an existing item
executor_id = context.get("current_executor_id")
@@ -647,10 +647,8 @@ class MessageMapper:
# Process each content item
for content in update.contents:
content_type = content.__class__.__name__
# Special handling for TextContent to use proper delta events
if content_type == "TextContent" and "current_message_id" in context:
if content.type == "text" and "current_message_id" in context:
# Stream text content via proper delta events
events.append(
ResponseTextDeltaEvent(
@@ -663,9 +661,9 @@ class MessageMapper:
sequence_number=self._next_sequence(context),
)
)
elif content_type in self.content_mappers:
elif content.type in self.content_mappers:
# Use existing mappers for other content types
mapped_events = await self.content_mappers[content_type](content, context)
mapped_events = await self.content_mappers[content.type](content, context)
if mapped_events is not None: # Handle None returns (e.g., UsageContent)
if isinstance(mapped_events, list):
events.extend(mapped_events)
@@ -676,7 +674,7 @@ class MessageMapper:
events.append(await self._create_unknown_content_event(content, context))
# Don't increment content_index for text deltas within the same part
if content_type != "TextContent":
if content.type != "text":
context["content_index"] = context.get("content_index", 0) + 1
except Exception as e:
@@ -708,10 +706,8 @@ class MessageMapper:
for message in messages:
if hasattr(message, "contents") and message.contents:
for content in message.contents:
content_type = content.__class__.__name__
if content_type in self.content_mappers:
mapped_events = await self.content_mappers[content_type](content, context)
if content.type in self.content_mappers:
mapped_events = await self.content_mappers[content.type](content, context)
if mapped_events is not None: # Handle None returns (e.g., UsageContent)
if isinstance(mapped_events, list):
events.extend(mapped_events)
@@ -726,9 +722,7 @@ class MessageMapper:
# Add usage information if present
usage_details = getattr(response, "usage_details", None)
if usage_details:
from agent_framework import UsageContent
usage_content = UsageContent(details=usage_details)
usage_content = Content.from_usage(usage_details=usage_details)
await self._map_usage_content(usage_content, context)
# Note: _map_usage_content returns None - it accumulates usage for final Response.usage
@@ -1421,11 +1415,11 @@ class MessageMapper:
Returns:
None - no event emitted (usage goes in final Response.usage)
"""
# Extract usage from UsageContent.details (UsageDetails object)
details = getattr(content, "details", None)
total_tokens = getattr(details, "total_token_count", 0) or 0
prompt_tokens = getattr(details, "input_token_count", 0) or 0
completion_tokens = getattr(details, "output_token_count", 0) or 0
# Extract usage from UsageContent.usage_details (UsageDetails object)
details = content.usage_details or {}
total_tokens = details.get("total_token_count", 0)
prompt_tokens = details.get("input_token_count", 0)
completion_tokens = details.get("output_token_count", 0)
# Accumulate for final Response.usage
request_id = context.get("request_id", "default")
@@ -187,7 +187,7 @@ export interface HostedVectorStoreContent extends BaseContent {
}
// Union type for all content
export type Contents =
export type Content =
| TextContent
| FunctionCallContent
| FunctionResultContent
@@ -209,7 +209,7 @@ export interface UsageDetails {
// Agent run response update (streaming)
export interface AgentResponseUpdate {
contents: Contents[];
contents: Content[];
role?: Role;
author_name?: string;
response_id?: string;
@@ -233,7 +233,7 @@ export interface AgentResponse {
// Chat message
export interface ChatMessage {
contents: Contents[];
contents: Content[];
role?: Role;
author_name?: string;
message_id?: string;
@@ -244,7 +244,7 @@ export interface ChatMessage {
// Chat response update (model client streaming)
export interface ChatResponseUpdate {
contents: Contents[];
contents: Content[];
role?: Role;
author_name?: string;
response_id?: string;
@@ -330,18 +330,18 @@ export interface TraceSpan {
}
// Helper type guards for Agent Framework content types
export function isTextContent(content: Contents): content is TextContent {
export function isTextContent(content: Content): content is TextContent {
return content.type === "text";
}
export function isFunctionCallContent(
content: Contents
content: Content
): content is FunctionCallContent {
return content.type === "function_call";
}
export function isFunctionResultContent(
content: Contents
content: Content
): content is FunctionResultContent {
return content.type === "function_result";
}
@@ -188,7 +188,7 @@ export interface MetaResponse {
export interface ChatMessage {
id: string;
role: "user" | "assistant" | "system" | "tool";
contents: import("./agent-framework").Contents[];
contents: import("./agent-framework").Content[];
timestamp: string;
streaming?: boolean;
author_name?: string;
@@ -7,7 +7,7 @@ import tempfile
from pathlib import Path
import pytest
from agent_framework import AgentResponse, ChatMessage, Role, TextContent
from agent_framework import AgentResponse, ChatMessage, Content, Role
from agent_framework_devui import register_cleanup
from agent_framework_devui._discovery import EntityDiscovery
@@ -36,7 +36,7 @@ class MockAgent:
async def run_stream(self, messages=None, *, thread=None, **kwargs):
"""Mock streaming run method."""
yield AgentResponse(
messages=[ChatMessage(role=Role.ASSISTANT, contents=[TextContent(text="Test response")])],
messages=[ChatMessage(role=Role.ASSISTANT, contents=[Content.from_text(text="Test response")])],
)
@@ -259,7 +259,7 @@ async def test_cleanup_with_file_based_discovery():
# Write agent module with cleanup registration
agent_file = agent_dir / "__init__.py"
agent_file.write_text("""
from agent_framework import AgentResponse, ChatMessage, Role, TextContent
from agent_framework import AgentResponse, ChatMessage, Role, Content
from agent_framework_devui import register_cleanup
class MockCredential:
@@ -279,7 +279,7 @@ class TestAgent:
async def run_stream(self, messages=None, *, thread=None, **kwargs):
yield AgentResponse(
messages=[ChatMessage(role=Role.ASSISTANT, content=[TextContent(text="Test")])],
messages=[ChatMessage(role=Role.ASSISTANT, content=[Content.from_text(text="Test")])],
inner_messages=[],
)

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