mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into feature-azure-functions
This commit is contained in:
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251111"
|
||||
version = "1.0.0b251112"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -85,6 +85,7 @@ class AgentFrameworkEventBridge:
|
||||
self.input_messages = input_messages or []
|
||||
self.pending_tool_calls: list[dict[str, Any]] = [] # Track tool calls for assistant message
|
||||
self.tool_results: list[dict[str, Any]] = [] # Track tool results
|
||||
self.tool_calls_ended: set[str] = set() # Track which tool calls have had ToolCallEndEvent emitted
|
||||
|
||||
async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[BaseEvent]:
|
||||
"""
|
||||
@@ -118,12 +119,14 @@ class AgentFrameworkEventBridge:
|
||||
message_id=self.current_message_id,
|
||||
role="assistant",
|
||||
)
|
||||
logger.debug(f"Emitting TextMessageStartEvent with message_id={self.current_message_id}")
|
||||
events.append(start_event)
|
||||
|
||||
event = TextMessageContentEvent(
|
||||
message_id=self.current_message_id,
|
||||
delta=content.text,
|
||||
)
|
||||
logger.debug(f"Emitting TextMessageContentEvent with delta: {content.text}")
|
||||
events.append(event)
|
||||
|
||||
elif isinstance(content, FunctionCallContent):
|
||||
@@ -378,6 +381,7 @@ class AgentFrameworkEventBridge:
|
||||
)
|
||||
logger.info(f"Emitting ToolCallEndEvent for completed tool call '{content.call_id}'")
|
||||
events.append(end_event)
|
||||
self.tool_calls_ended.add(content.call_id) # Track that we emitted end event
|
||||
|
||||
# Log total StateDeltaEvent count for this tool call
|
||||
if self.state_delta_count > 0:
|
||||
@@ -617,6 +621,7 @@ class AgentFrameworkEventBridge:
|
||||
f"Emitting ToolCallEndEvent for approval-required tool '{content.function_call.call_id}'"
|
||||
)
|
||||
events.append(end_event)
|
||||
self.tool_calls_ended.add(content.function_call.call_id) # Track that we emitted end event
|
||||
|
||||
# Emit custom event for approval request
|
||||
# Note: In AG-UI protocol, the frontend handles interrupts automatically
|
||||
|
||||
@@ -38,22 +38,69 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
|
||||
"""
|
||||
result: list[ChatMessage] = []
|
||||
for msg in messages:
|
||||
# Check for backend tool rendering results FIRST (may not have role field)
|
||||
if "actionExecutionId" in msg or "actionName" in msg:
|
||||
# Backend tool rendering - convert to FunctionResultContent
|
||||
from agent_framework import FunctionResultContent
|
||||
# Handle standard tool result messages early (role="tool") to preserve provider invariants
|
||||
# This path maps AG‑UI tool messages to FunctionResultContent with the correct tool_call_id
|
||||
role_str = msg.get("role", "user")
|
||||
if role_str == "tool":
|
||||
# Prefer explicit tool_call_id fields; fall back to backend fields only if necessary
|
||||
tool_call_id = msg.get("tool_call_id") or msg.get("toolCallId")
|
||||
|
||||
tool_call_id = msg.get("actionExecutionId", "")
|
||||
# If no explicit tool_call_id, treat as backend tool rendering payloads where
|
||||
# AG‑UI may send actionExecutionId/actionName. This must still map to the
|
||||
# assistant's tool call id to satisfy provider requirements.
|
||||
if not tool_call_id:
|
||||
tool_call_id = msg.get("actionExecutionId") or ""
|
||||
|
||||
# Extract raw content text
|
||||
result_content = msg.get("content")
|
||||
if result_content is None:
|
||||
result_content = msg.get("result", "")
|
||||
|
||||
# Distinguish approval payloads from actual tool results
|
||||
is_approval = False
|
||||
if isinstance(result_content, str) and result_content:
|
||||
import json as _json
|
||||
|
||||
try:
|
||||
parsed = _json.loads(result_content)
|
||||
is_approval = isinstance(parsed, dict) and "accepted" in parsed
|
||||
except Exception:
|
||||
is_approval = False
|
||||
|
||||
if is_approval:
|
||||
# Approval responses should be treated as user messages to trigger human-in-the-loop flow
|
||||
chat_msg = ChatMessage(
|
||||
role=Role.USER,
|
||||
contents=[TextContent(text=str(result_content))],
|
||||
additional_properties={"is_tool_result": True, "tool_call_id": str(tool_call_id or "")},
|
||||
)
|
||||
if "id" in msg:
|
||||
chat_msg.message_id = msg["id"]
|
||||
result.append(chat_msg)
|
||||
continue
|
||||
|
||||
chat_msg = ChatMessage(
|
||||
role=Role.TOOL,
|
||||
contents=[FunctionResultContent(call_id=str(tool_call_id), result=result_content)],
|
||||
)
|
||||
if "id" in msg:
|
||||
chat_msg.message_id = msg["id"]
|
||||
result.append(chat_msg)
|
||||
continue
|
||||
|
||||
# Backend tool rendering payloads without an explicit role
|
||||
# Prefer standard tool mapping above; this block only covers legacy/minimal payloads
|
||||
if "actionExecutionId" in msg or "actionName" in msg:
|
||||
# Prefer toolCallId if present; otherwise fall back to actionExecutionId
|
||||
tool_call_id = msg.get("toolCallId") or msg.get("tool_call_id") or msg.get("actionExecutionId", "")
|
||||
result_content = msg.get("result", msg.get("content", ""))
|
||||
|
||||
chat_msg = ChatMessage(
|
||||
role=Role.TOOL, # Tool results must be tool role
|
||||
contents=[FunctionResultContent(call_id=tool_call_id, result=result_content)],
|
||||
role=Role.TOOL,
|
||||
contents=[FunctionResultContent(call_id=str(tool_call_id), result=result_content)],
|
||||
)
|
||||
|
||||
if "id" in msg:
|
||||
chat_msg.message_id = msg["id"]
|
||||
|
||||
result.append(chat_msg)
|
||||
continue
|
||||
|
||||
@@ -93,55 +140,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
|
||||
result.append(chat_msg)
|
||||
continue
|
||||
|
||||
role_str = msg.get("role", "user")
|
||||
|
||||
# Handle tool result messages (with role="tool")
|
||||
if role_str == "tool":
|
||||
# Check if this is a standard tool result (has tool_call_id or toolCallId)
|
||||
tool_call_id = msg.get("tool_call_id") or msg.get("toolCallId")
|
||||
result_content = msg.get("content", "")
|
||||
|
||||
# Distinguish between backend tool results and approval responses
|
||||
# Approval responses have {"accepted": ...} structure
|
||||
is_approval = False
|
||||
if result_content:
|
||||
import json
|
||||
|
||||
try:
|
||||
parsed_content = json.loads(result_content)
|
||||
is_approval = "accepted" in parsed_content
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
is_approval = False
|
||||
|
||||
# Backend tool results have non-empty content WITHOUT "accepted" field
|
||||
if tool_call_id and result_content and not is_approval:
|
||||
# Tool execution result - convert to FunctionResultContent with correct role
|
||||
from agent_framework import FunctionResultContent
|
||||
|
||||
chat_msg = ChatMessage(
|
||||
role=Role.TOOL,
|
||||
contents=[FunctionResultContent(call_id=tool_call_id, result=result_content)],
|
||||
)
|
||||
|
||||
if "id" in msg:
|
||||
chat_msg.message_id = msg["id"]
|
||||
|
||||
result.append(chat_msg)
|
||||
continue
|
||||
else:
|
||||
# Human-in-the-loop approval response - mark for special handling
|
||||
content = msg.get("content", "")
|
||||
chat_msg = ChatMessage(
|
||||
role=Role.USER, # Approval responses are user messages
|
||||
contents=[TextContent(text=content)],
|
||||
additional_properties={"is_tool_result": True, "tool_call_id": msg.get("toolCallId", "")},
|
||||
)
|
||||
|
||||
if "id" in msg:
|
||||
chat_msg.message_id = msg["id"]
|
||||
|
||||
result.append(chat_msg)
|
||||
continue
|
||||
# No special handling required for assistant/plain messages here
|
||||
|
||||
role = _AGUI_TO_FRAMEWORK_ROLE.get(role_str, Role.USER)
|
||||
|
||||
|
||||
@@ -16,7 +16,15 @@ from ag_ui.core import (
|
||||
TextMessageEndEvent,
|
||||
TextMessageStartEvent,
|
||||
)
|
||||
from agent_framework import AgentProtocol, AgentThread, ChatAgent, TextContent
|
||||
from agent_framework import (
|
||||
AgentProtocol,
|
||||
AgentThread,
|
||||
ChatAgent,
|
||||
ChatMessage,
|
||||
FunctionCallContent,
|
||||
FunctionResultContent,
|
||||
TextContent,
|
||||
)
|
||||
|
||||
from ._utils import convert_agui_tools_to_agent_framework, generate_event_id
|
||||
|
||||
@@ -276,6 +284,98 @@ class DefaultOrchestrator(Orchestrator):
|
||||
response_format = context.agent.chat_options.response_format
|
||||
skip_text_content = response_format is not None
|
||||
|
||||
# Sanitizer: ensure tool results only follow assistant tool calls
|
||||
# Also inject synthetic tool results for confirm_changes
|
||||
def sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]:
|
||||
sanitized: list[ChatMessage] = []
|
||||
pending_tool_call_ids: set[str] | None = None
|
||||
pending_confirm_changes_id: str | None = None
|
||||
|
||||
for msg in messages:
|
||||
role_value = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
|
||||
|
||||
if role_value == "assistant":
|
||||
tool_ids = {
|
||||
str(content.call_id)
|
||||
for content in msg.contents or []
|
||||
if isinstance(content, FunctionCallContent) and content.call_id
|
||||
}
|
||||
# Check for confirm_changes tool call
|
||||
confirm_changes_call = None
|
||||
for content in msg.contents or []:
|
||||
if isinstance(content, FunctionCallContent) and content.name == "confirm_changes":
|
||||
confirm_changes_call = content
|
||||
break
|
||||
|
||||
sanitized.append(msg)
|
||||
pending_tool_call_ids = tool_ids if tool_ids else None
|
||||
pending_confirm_changes_id = (
|
||||
str(confirm_changes_call.call_id)
|
||||
if confirm_changes_call and confirm_changes_call.call_id
|
||||
else None
|
||||
)
|
||||
continue
|
||||
|
||||
if role_value == "user" and pending_confirm_changes_id:
|
||||
# Check if this is a confirm_changes response (JSON with "accepted" field)
|
||||
user_text = ""
|
||||
for content in msg.contents or []:
|
||||
if isinstance(content, TextContent):
|
||||
user_text = content.text
|
||||
break
|
||||
|
||||
try:
|
||||
parsed = json.loads(user_text)
|
||||
if "accepted" in parsed:
|
||||
# This is a confirm_changes response - inject synthetic tool result
|
||||
logger.info(
|
||||
f"Injecting synthetic tool result for confirm_changes call_id={pending_confirm_changes_id}"
|
||||
)
|
||||
synthetic_result = ChatMessage(
|
||||
role="tool",
|
||||
contents=[
|
||||
FunctionResultContent(
|
||||
call_id=pending_confirm_changes_id,
|
||||
result="Confirmed" if parsed.get("accepted") else "Rejected",
|
||||
)
|
||||
],
|
||||
)
|
||||
sanitized.append(synthetic_result)
|
||||
if pending_tool_call_ids:
|
||||
pending_tool_call_ids.discard(pending_confirm_changes_id)
|
||||
pending_confirm_changes_id = None
|
||||
# Don't add the user message to sanitized - it's been converted to tool result
|
||||
continue
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
# Failed to parse user message as confirm_changes response; continue normal processing
|
||||
logger.debug(f"Could not parse user message as confirm_changes response: {e}")
|
||||
|
||||
# Not a confirm_changes response, continue normal processing
|
||||
sanitized.append(msg)
|
||||
pending_tool_call_ids = None
|
||||
pending_confirm_changes_id = None
|
||||
continue
|
||||
|
||||
if role_value == "tool":
|
||||
if not pending_tool_call_ids:
|
||||
continue
|
||||
keep = False
|
||||
for content in msg.contents or []:
|
||||
if isinstance(content, FunctionResultContent):
|
||||
call_id = str(content.call_id)
|
||||
if call_id in pending_tool_call_ids:
|
||||
keep = True
|
||||
break
|
||||
if keep:
|
||||
sanitized.append(msg)
|
||||
continue
|
||||
|
||||
sanitized.append(msg)
|
||||
pending_tool_call_ids = None
|
||||
pending_confirm_changes_id = None
|
||||
|
||||
return sanitized
|
||||
|
||||
# Create event bridge
|
||||
event_bridge = AgentFrameworkEventBridge(
|
||||
run_id=context.run_id,
|
||||
@@ -328,22 +428,151 @@ class DefaultOrchestrator(Orchestrator):
|
||||
if current_state:
|
||||
thread.metadata["current_state"] = current_state # type: ignore[attr-defined]
|
||||
|
||||
# Add incoming AG-UI messages to the thread history
|
||||
if context.messages:
|
||||
await thread.on_new_messages(context.messages)
|
||||
|
||||
# Use the full incoming message batch to preserve tool-call adjacency
|
||||
if not context.messages:
|
||||
raw_messages = context.messages or []
|
||||
if not raw_messages:
|
||||
logger.warning("No messages provided in AG-UI input")
|
||||
yield event_bridge.create_run_finished_event()
|
||||
return
|
||||
|
||||
logger.info(f"Received {len(raw_messages)} raw messages from client")
|
||||
for i, msg in enumerate(raw_messages):
|
||||
role = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
|
||||
msg_id = getattr(msg, "message_id", None)
|
||||
logger.info(f" Raw 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(f" Content {j}: {content_type} - {content.text}")
|
||||
elif isinstance(content, FunctionCallContent):
|
||||
logger.debug(f" Content {j}: {content_type} - {content.name}({content.arguments})")
|
||||
elif isinstance(content, FunctionResultContent):
|
||||
logger.debug(
|
||||
f" Content {j}: {content_type} - call_id={content.call_id}, result={content.result}"
|
||||
)
|
||||
else:
|
||||
logger.debug(f" Content {j}: {content_type} - {content}")
|
||||
|
||||
# After getting sanitized_messages, deduplicate them
|
||||
def deduplicate_messages(messages: list[ChatMessage]) -> list[ChatMessage]:
|
||||
"""Remove duplicate messages while preserving order.
|
||||
|
||||
For tool results with the same call_id, prefer the one with actual data.
|
||||
"""
|
||||
seen_keys: dict[Any, int] = {} # key -> index in unique_messages (key can be various tuple types)
|
||||
unique_messages: list[ChatMessage] = []
|
||||
|
||||
for idx, msg in enumerate(messages):
|
||||
role_value = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
|
||||
|
||||
# For tool messages, use call_id as unique key
|
||||
if role_value == "tool" and msg.contents and isinstance(msg.contents[0], FunctionResultContent):
|
||||
call_id = str(msg.contents[0].call_id)
|
||||
key: Any = (role_value, call_id)
|
||||
|
||||
# Check if we already have this tool result
|
||||
if key in seen_keys:
|
||||
existing_idx = seen_keys[key]
|
||||
existing_msg = unique_messages[existing_idx]
|
||||
|
||||
# Compare results - prefer non-empty over empty
|
||||
existing_result = None
|
||||
if existing_msg.contents and isinstance(existing_msg.contents[0], FunctionResultContent):
|
||||
existing_result = existing_msg.contents[0].result
|
||||
new_result = msg.contents[0].result
|
||||
|
||||
# Replace if existing is empty/None and new has data
|
||||
if (not existing_result or existing_result == "") and new_result:
|
||||
logger.info(
|
||||
f"Replacing empty tool result at index {existing_idx} with data from index {idx}"
|
||||
)
|
||||
unique_messages[existing_idx] = msg
|
||||
else:
|
||||
logger.info(f"Skipping duplicate tool result at index {idx}: call_id={call_id}")
|
||||
continue
|
||||
|
||||
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)
|
||||
):
|
||||
# For assistant messages with tool_calls, use the tool call IDs
|
||||
tool_call_ids = tuple(
|
||||
sorted(str(c.call_id) for c in msg.contents if isinstance(c, FunctionCallContent) and c.call_id)
|
||||
)
|
||||
key = (role_value, tool_call_ids)
|
||||
|
||||
if key in seen_keys:
|
||||
logger.info(f"Skipping duplicate assistant tool call at index {idx}")
|
||||
continue
|
||||
|
||||
seen_keys[key] = len(unique_messages)
|
||||
unique_messages.append(msg)
|
||||
|
||||
else:
|
||||
# For other messages (system, user, assistant without tools), hash the content
|
||||
content_str = str([str(c) for c in msg.contents]) if msg.contents else ""
|
||||
key = (role_value, hash(content_str))
|
||||
|
||||
if key in seen_keys:
|
||||
logger.info(f"Skipping duplicate message at index {idx}: role={role_value}")
|
||||
continue
|
||||
|
||||
seen_keys[key] = len(unique_messages)
|
||||
unique_messages.append(msg)
|
||||
|
||||
return unique_messages
|
||||
|
||||
# Then use it:
|
||||
sanitized_messages = sanitize_tool_history(raw_messages)
|
||||
provider_messages = deduplicate_messages(sanitized_messages)
|
||||
|
||||
if not provider_messages:
|
||||
logger.info("No provider-eligible messages after filtering; finishing run without invoking agent.")
|
||||
yield event_bridge.create_run_finished_event()
|
||||
return
|
||||
|
||||
logger.info(f"Processing {len(provider_messages)} provider messages after sanitization/deduplication")
|
||||
for i, msg in enumerate(provider_messages):
|
||||
role = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
|
||||
logger.info(f" Message {i}: role={role}")
|
||||
if hasattr(msg, "contents") and msg.contents:
|
||||
for j, content in enumerate(msg.contents):
|
||||
content_type = type(content).__name__
|
||||
if isinstance(content, TextContent):
|
||||
logger.info(f" Content {j}: {content_type} - {content.text}")
|
||||
elif isinstance(content, FunctionCallContent):
|
||||
logger.info(f" Content {j}: {content_type} - {content.name}({content.arguments})")
|
||||
elif isinstance(content, FunctionResultContent):
|
||||
logger.info(
|
||||
f" Content {j}: {content_type} - call_id={content.call_id}, result={content.result}"
|
||||
)
|
||||
else:
|
||||
logger.info(f" Content {j}: {content_type} - {content}")
|
||||
|
||||
# NOTE: For AG-UI, the client sends the full conversation history on each request.
|
||||
# We should NOT add to thread.on_new_messages() as that would cause duplication.
|
||||
# Instead, we pass messages directly to the agent via messages_to_run.
|
||||
|
||||
# Inject current state as system message context if we have state
|
||||
messages_to_run: list[Any] = []
|
||||
if current_state and context.config.state_schema:
|
||||
state_json = json.dumps(current_state, indent=2)
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
conversation_has_tool_calls = False
|
||||
logger.debug(f"Checking {len(provider_messages)} provider messages for tool calls")
|
||||
for i, msg in enumerate(provider_messages):
|
||||
logger.debug(
|
||||
f" Message {i}: role={msg.role.value}, contents={len(msg.contents) if hasattr(msg, 'contents') and msg.contents else 0}"
|
||||
)
|
||||
for msg in provider_messages:
|
||||
if msg.role.value == "assistant" and hasattr(msg, "contents") and msg.contents:
|
||||
if any(isinstance(content, FunctionCallContent) for content in msg.contents):
|
||||
conversation_has_tool_calls = True
|
||||
break
|
||||
if current_state and context.config.state_schema and not conversation_has_tool_calls:
|
||||
state_json = json.dumps(current_state, indent=2)
|
||||
state_context_msg = ChatMessage(
|
||||
role="system",
|
||||
contents=[
|
||||
@@ -359,9 +588,9 @@ Never replace existing data - always append or merge."""
|
||||
)
|
||||
messages_to_run.append(state_context_msg)
|
||||
|
||||
# Preserve order from client to satisfy provider constraints (assistant tool_calls must
|
||||
# immediately precede tool result messages). Using the full batch avoids reordering.
|
||||
messages_to_run.extend(context.messages)
|
||||
# Add all provider messages to messages_to_run
|
||||
# AG-UI sends full conversation history on each request, so we pass it directly to the agent
|
||||
messages_to_run.extend(provider_messages)
|
||||
|
||||
# Handle client tools for hybrid execution
|
||||
# Client sends tool metadata, server merges with its own tools.
|
||||
@@ -370,11 +599,23 @@ Never replace existing data - always append or merge."""
|
||||
from agent_framework import BaseChatClient
|
||||
|
||||
client_tools = convert_agui_tools_to_agent_framework(context.input_data.get("tools"))
|
||||
logger.info(f"[TOOLS] Client sent {len(client_tools) if client_tools else 0} tools")
|
||||
if client_tools:
|
||||
for tool in client_tools:
|
||||
tool_name = getattr(tool, "name", "unknown")
|
||||
declaration_only = getattr(tool, "declaration_only", None)
|
||||
logger.info(f"[TOOLS] - Client tool: {tool_name}, declaration_only={declaration_only}")
|
||||
|
||||
# Extract server tools - use type narrowing when possible
|
||||
server_tools: list[Any] = []
|
||||
if isinstance(context.agent, ChatAgent):
|
||||
server_tools = context.agent.chat_options.tools or []
|
||||
tools_from_agent = context.agent.chat_options.tools
|
||||
server_tools = list(tools_from_agent) if tools_from_agent else []
|
||||
logger.info(f"[TOOLS] Agent has {len(server_tools)} configured tools")
|
||||
for tool in server_tools:
|
||||
tool_name = getattr(tool, "name", "unknown")
|
||||
approval_mode = getattr(tool, "approval_mode", None)
|
||||
logger.info(f"[TOOLS] - {tool_name}: approval_mode={approval_mode}")
|
||||
else:
|
||||
# AgentProtocol allows duck-typed implementations - fallback to attribute access
|
||||
# This supports test mocks and custom agent implementations
|
||||
@@ -412,15 +653,37 @@ Never replace existing data - always append or merge."""
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
combined_tools: list[Any] = []
|
||||
if server_tools:
|
||||
combined_tools.extend(server_tools)
|
||||
# For tools parameter: only pass if we have client tools to add
|
||||
# If we pass tools=, it overrides the agent's configured tools and loses metadata like approval_mode
|
||||
# So only pass tools when we need to add client tools on top of server tools
|
||||
# IMPORTANT: Don't include client tools that duplicate server tools (same name)
|
||||
tools_param = None
|
||||
if client_tools:
|
||||
combined_tools.extend(client_tools)
|
||||
# Get server tool names
|
||||
server_tool_names = {getattr(tool, "name", None) for tool in server_tools}
|
||||
|
||||
# Filter out client tools that duplicate server tools
|
||||
unique_client_tools = [
|
||||
tool for tool in client_tools if getattr(tool, "name", None) not in server_tool_names
|
||||
]
|
||||
|
||||
if unique_client_tools:
|
||||
combined_tools: list[Any] = []
|
||||
if server_tools:
|
||||
combined_tools.extend(server_tools)
|
||||
combined_tools.extend(unique_client_tools)
|
||||
tools_param = combined_tools
|
||||
logger.info(
|
||||
f"[TOOLS] Passing tools= parameter with {len(combined_tools)} tools ({len(server_tools)} server + {len(unique_client_tools)} unique client)"
|
||||
)
|
||||
else:
|
||||
logger.info("[TOOLS] All client tools duplicate server tools - not passing tools= parameter")
|
||||
else:
|
||||
logger.info("[TOOLS] No client tools - not passing tools= parameter (using agent's configured tools)")
|
||||
|
||||
# Collect all updates to get the final structured output
|
||||
all_updates: list[Any] = []
|
||||
async for update in context.agent.run_stream(messages_to_run, thread=thread, tools=combined_tools or None):
|
||||
async for update in context.agent.run_stream(messages_to_run, thread=thread, tools=tools_param):
|
||||
all_updates.append(update)
|
||||
events = await event_bridge.from_agent_run_update(update)
|
||||
for event in events:
|
||||
@@ -432,6 +695,27 @@ Never replace existing data - always append or merge."""
|
||||
yield event_bridge.create_run_finished_event()
|
||||
return
|
||||
|
||||
# Check if there are pending tool calls (declaration-only tools that weren't executed)
|
||||
# These need ToolCallEndEvent to signal the client to execute them
|
||||
# Only emit for tool calls that haven't already had ToolCallEndEvent emitted
|
||||
# (approval-required tools already had their end event emitted)
|
||||
if event_bridge.pending_tool_calls:
|
||||
pending_without_end = [
|
||||
tc for tc in event_bridge.pending_tool_calls if tc.get("id") not in event_bridge.tool_calls_ended
|
||||
]
|
||||
if pending_without_end:
|
||||
logger.info(
|
||||
f"Found {len(pending_without_end)} pending tool calls without end event - emitting ToolCallEndEvent"
|
||||
)
|
||||
for tool_call in pending_without_end:
|
||||
tool_call_id = tool_call.get("id")
|
||||
if tool_call_id:
|
||||
from ag_ui.core import ToolCallEndEvent
|
||||
|
||||
end_event = ToolCallEndEvent(tool_call_id=tool_call_id)
|
||||
logger.info(f"Emitting ToolCallEndEvent for declaration-only tool call '{tool_call_id}'")
|
||||
yield end_event
|
||||
|
||||
# After streaming completes, check if agent has response_format and extract structured output
|
||||
if all_updates and response_format:
|
||||
from agent_framework import AgentRunResponse
|
||||
|
||||
@@ -10,11 +10,37 @@ pip install agent-framework-ag-ui
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Using Example Agents with Any Chat Client
|
||||
|
||||
All example agents are factory functions that accept any `ChatClientProtocol`-compatible chat client:
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
|
||||
from agent_framework_ag_ui_examples.agents import simple_agent, weather_agent
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# Option 1: Use Azure OpenAI
|
||||
azure_client = AzureOpenAIChatClient(model_id="gpt-4")
|
||||
add_agent_framework_fastapi_endpoint(app, simple_agent(azure_client), "/chat")
|
||||
|
||||
# Option 2: Use OpenAI
|
||||
openai_client = OpenAIChatClient(model_id="gpt-4o")
|
||||
add_agent_framework_fastapi_endpoint(app, weather_agent(openai_client), "/weather")
|
||||
|
||||
# Run with: uvicorn main:app --reload
|
||||
```
|
||||
|
||||
### Creating Your Own Agent
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
from agent_framework import ChatAgent
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
|
||||
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
|
||||
|
||||
# Create your agent
|
||||
agent = ChatAgent(
|
||||
@@ -44,38 +70,97 @@ This integration supports all 7 AG-UI features:
|
||||
|
||||
## Examples
|
||||
|
||||
Complete examples for all features are in the `examples/` directory:
|
||||
All example agents are implemented as **factory functions** that accept any chat client implementing `ChatClientProtocol`. This provides maximum flexibility to use Azure OpenAI, OpenAI, Anthropic, or any custom chat client implementation.
|
||||
|
||||
- `examples/agents/simple_agent.py` - Basic agentic chat
|
||||
- `examples/agents/weather_agent.py` - Backend tool rendering
|
||||
- `examples/agents/task_planner_agent.py` - Human in the loop with approvals
|
||||
- `examples/agents/research_assistant_agent.py` - Agentic generative UI
|
||||
- `examples/agents/ui_generator_agent.py` - Tool-based generative UI
|
||||
- `examples/agents/recipe_agent.py` - Shared state management
|
||||
- `examples/agents/document_writer_agent.py` - Predictive state updates
|
||||
- `examples/server/main.py` - FastAPI server with all endpoints
|
||||
### Available Example Agents
|
||||
|
||||
Run the example server:
|
||||
Complete examples for all AG-UI features are available:
|
||||
|
||||
```bash
|
||||
cd examples/server
|
||||
uvicorn main:app --reload
|
||||
- `simple_agent(chat_client)` - Basic agentic chat (Feature 1)
|
||||
- `weather_agent(chat_client)` - Backend tool rendering (Feature 2)
|
||||
- `human_in_the_loop_agent(chat_client)` - Human-in-the-loop with step customization (Feature 3)
|
||||
- `task_steps_agent_wrapped(chat_client)` - Agentic generative UI with step execution (Feature 4)
|
||||
- `ui_generator_agent(chat_client)` - Tool-based generative UI (Feature 5)
|
||||
- `recipe_agent(chat_client)` - Shared state management (Feature 6)
|
||||
- `document_writer_agent(chat_client)` - Predictive state updates (Feature 7)
|
||||
- `research_assistant_agent(chat_client)` - Research with progress events
|
||||
- `task_planner_agent(chat_client)` - Task planning with approvals
|
||||
|
||||
### Using Example Agents
|
||||
|
||||
```python
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from agent_framework_ag_ui_examples.agents import (
|
||||
simple_agent,
|
||||
weather_agent,
|
||||
recipe_agent,
|
||||
)
|
||||
|
||||
# Create a chat client (use any ChatClientProtocol implementation)
|
||||
azure_client = AzureOpenAIChatClient(model_id="gpt-4")
|
||||
openai_client = OpenAIChatClient(model_id="gpt-4o")
|
||||
|
||||
# Create agent instances by calling the factory functions
|
||||
agent1 = simple_agent(azure_client)
|
||||
agent2 = weather_agent(openai_client)
|
||||
agent3 = recipe_agent(azure_client)
|
||||
```
|
||||
|
||||
To enable debug logging:
|
||||
### Running the Example Server
|
||||
|
||||
The example server demonstrates all 7 AG-UI features:
|
||||
|
||||
```bash
|
||||
ENABLE_DEBUG_LOGGING=1 uvicorn main:app --reload
|
||||
# Install the package
|
||||
pip install agent-framework-ag-ui
|
||||
|
||||
# Run the example server
|
||||
python -m agent_framework_ag_ui_examples
|
||||
|
||||
# Or with debug logging
|
||||
ENABLE_DEBUG_LOGGING=1 python -m agent_framework_ag_ui_examples
|
||||
```
|
||||
|
||||
The server exposes endpoints at:
|
||||
- `/agentic_chat`
|
||||
- `/backend_tool_rendering`
|
||||
- `/human_in_the_loop`
|
||||
- `/agentic_generative_ui`
|
||||
- `/tool_based_generative_ui`
|
||||
- `/shared_state`
|
||||
- `/predictive_state_updates`
|
||||
- `/agentic_chat` - Simple chat with `simple_agent`
|
||||
- `/backend_tool_rendering` - Weather tools with `weather_agent`
|
||||
- `/human_in_the_loop` - Step approval with `human_in_the_loop_agent`
|
||||
- `/agentic_generative_ui` - Task steps with `task_steps_agent_wrapped`
|
||||
- `/tool_based_generative_ui` - Custom UI components with `ui_generator_agent`
|
||||
- `/shared_state` - Recipe management with `recipe_agent`
|
||||
- `/predictive_state_updates` - Document writing with `document_writer_agent`
|
||||
|
||||
### Complete FastAPI Example
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
|
||||
from agent_framework_ag_ui_examples.agents import (
|
||||
simple_agent,
|
||||
weather_agent,
|
||||
human_in_the_loop_agent,
|
||||
task_steps_agent_wrapped,
|
||||
ui_generator_agent,
|
||||
recipe_agent,
|
||||
document_writer_agent,
|
||||
)
|
||||
|
||||
app = FastAPI(title="AG-UI Examples")
|
||||
|
||||
# Create a chat client (shared across all agents, or create individual ones)
|
||||
chat_client = AzureOpenAIChatClient(model_id="gpt-4")
|
||||
|
||||
# Add all example endpoints
|
||||
add_agent_framework_fastapi_endpoint(app, simple_agent(chat_client), "/agentic_chat")
|
||||
add_agent_framework_fastapi_endpoint(app, weather_agent(chat_client), "/backend_tool_rendering")
|
||||
add_agent_framework_fastapi_endpoint(app, human_in_the_loop_agent(chat_client), "/human_in_the_loop")
|
||||
add_agent_framework_fastapi_endpoint(app, task_steps_agent_wrapped(chat_client), "/agentic_generative_ui") # type: ignore[arg-type]
|
||||
add_agent_framework_fastapi_endpoint(app, ui_generator_agent(chat_client), "/tool_based_generative_ui")
|
||||
add_agent_framework_fastapi_endpoint(app, recipe_agent(chat_client), "/shared_state")
|
||||
add_agent_framework_fastapi_endpoint(app, document_writer_agent(chat_client), "/predictive_state_updates")
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -97,6 +182,48 @@ The package uses a clean, orchestrator-based architecture:
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Creating Custom Agent Factories
|
||||
|
||||
You can create your own agent factories following the same pattern as the examples:
|
||||
|
||||
```python
|
||||
from agent_framework import ChatAgent, ai_function
|
||||
from agent_framework._clients import ChatClientProtocol
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
|
||||
@ai_function
|
||||
def my_tool(param: str) -> str:
|
||||
"""My custom tool."""
|
||||
return f"Result: {param}"
|
||||
|
||||
def my_custom_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent:
|
||||
"""Create a custom agent with the specified chat client.
|
||||
|
||||
Args:
|
||||
chat_client: The chat client to use for the agent
|
||||
|
||||
Returns:
|
||||
A configured AgentFrameworkAgent instance
|
||||
"""
|
||||
agent = ChatAgent(
|
||||
name="my_custom_agent",
|
||||
instructions="Custom instructions here",
|
||||
chat_client=chat_client,
|
||||
tools=[my_tool],
|
||||
)
|
||||
|
||||
return AgentFrameworkAgent(
|
||||
agent=agent,
|
||||
name="MyCustomAgent",
|
||||
description="My custom agent description",
|
||||
)
|
||||
|
||||
# Use it
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
chat_client = AzureOpenAIChatClient()
|
||||
agent = my_custom_agent(chat_client)
|
||||
```
|
||||
|
||||
### Shared State
|
||||
|
||||
State is injected as system messages and updated via predictive state updates:
|
||||
|
||||
@@ -6,7 +6,7 @@ from .document_writer_agent import document_writer_agent
|
||||
from .human_in_the_loop_agent import human_in_the_loop_agent
|
||||
from .recipe_agent import recipe_agent
|
||||
from .research_assistant_agent import research_assistant_agent
|
||||
from .simple_agent import agent as simple_agent
|
||||
from .simple_agent import simple_agent
|
||||
from .task_planner_agent import task_planner_agent
|
||||
from .task_steps_agent import task_steps_agent_wrapped
|
||||
from .ui_generator_agent import ui_generator_agent
|
||||
|
||||
+39
-27
@@ -3,7 +3,7 @@
|
||||
"""Example agent demonstrating predictive state updates with document writing."""
|
||||
|
||||
from agent_framework import ChatAgent, ai_function
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from agent_framework._clients import ChatClientProtocol
|
||||
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent, DocumentWriterConfirmationStrategy
|
||||
|
||||
@@ -28,31 +28,43 @@ def write_document_local(document: str) -> str:
|
||||
return "Document written."
|
||||
|
||||
|
||||
agent = ChatAgent(
|
||||
name="document_writer",
|
||||
instructions=(
|
||||
"You are a helpful assistant for writing documents. "
|
||||
"To write the document, you MUST use the write_document_local tool. "
|
||||
"You MUST write the full document, even when changing only a few words. "
|
||||
"When you wrote the document, DO NOT repeat it as a message. "
|
||||
"Just briefly summarize the changes you made. 2 sentences max. "
|
||||
"\n\n"
|
||||
"The current state of the document will be provided to you. "
|
||||
"When editing, make minimal changes - do not change every word unless requested."
|
||||
),
|
||||
chat_client=AzureOpenAIChatClient(),
|
||||
tools=[write_document_local],
|
||||
_DOCUMENT_WRITER_INSTRUCTIONS = (
|
||||
"You are a helpful assistant for writing documents. "
|
||||
"To write the document, you MUST use the write_document_local tool. "
|
||||
"You MUST write the full document, even when changing only a few words. "
|
||||
"When you wrote the document, DO NOT repeat it as a message. "
|
||||
"Just briefly summarize the changes you made. 2 sentences max. "
|
||||
"\n\n"
|
||||
"The current state of the document will be provided to you. "
|
||||
"When editing, make minimal changes - do not change every word unless requested."
|
||||
)
|
||||
|
||||
document_writer_agent = AgentFrameworkAgent(
|
||||
agent=agent,
|
||||
name="DocumentWriter",
|
||||
description="Writes and edits documents with predictive state updates",
|
||||
state_schema={
|
||||
"document": {"type": "string", "description": "The current document content"},
|
||||
},
|
||||
predict_state_config={
|
||||
"document": {"tool": "write_document_local", "tool_argument": "document"},
|
||||
},
|
||||
confirmation_strategy=DocumentWriterConfirmationStrategy(),
|
||||
)
|
||||
|
||||
def document_writer_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent:
|
||||
"""Create a document writer agent with predictive state updates.
|
||||
|
||||
Args:
|
||||
chat_client: The chat client to use for the agent
|
||||
|
||||
Returns:
|
||||
A configured AgentFrameworkAgent instance with document writing capabilities
|
||||
"""
|
||||
agent = ChatAgent(
|
||||
name="document_writer",
|
||||
instructions=_DOCUMENT_WRITER_INSTRUCTIONS,
|
||||
chat_client=chat_client,
|
||||
tools=[write_document_local],
|
||||
)
|
||||
|
||||
return AgentFrameworkAgent(
|
||||
agent=agent,
|
||||
name="DocumentWriter",
|
||||
description="Writes and edits documents with predictive state updates",
|
||||
state_schema={
|
||||
"document": {"type": "string", "description": "The current document content"},
|
||||
},
|
||||
predict_state_config={
|
||||
"document": {"tool": "write_document_local", "tool_argument": "document"},
|
||||
},
|
||||
confirmation_strategy=DocumentWriterConfirmationStrategy(),
|
||||
)
|
||||
|
||||
+16
-8
@@ -5,7 +5,7 @@
|
||||
from enum import Enum
|
||||
|
||||
from agent_framework import ChatAgent, ai_function
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from agent_framework._clients import ChatClientProtocol
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
@@ -43,10 +43,18 @@ def generate_task_steps(steps: list[TaskStep]) -> str:
|
||||
return f"Generated {len(steps)} execution steps for the task."
|
||||
|
||||
|
||||
# Create the human-in-the-loop agent using tool-based approach for predictive state
|
||||
human_in_the_loop_agent = ChatAgent(
|
||||
name="human_in_the_loop_agent",
|
||||
instructions="""You are a helpful assistant that can perform any task by breaking it down into steps.
|
||||
def human_in_the_loop_agent(chat_client: ChatClientProtocol) -> ChatAgent:
|
||||
"""Create a human-in-the-loop agent using tool-based approach for predictive state.
|
||||
|
||||
Args:
|
||||
chat_client: The chat client to use for the agent
|
||||
|
||||
Returns:
|
||||
A configured ChatAgent instance with human-in-the-loop capabilities
|
||||
"""
|
||||
return ChatAgent(
|
||||
name="human_in_the_loop_agent",
|
||||
instructions="""You are a helpful assistant that can perform any task by breaking it down into steps.
|
||||
|
||||
When asked to perform a task, you MUST call the `generate_task_steps` function with the proper
|
||||
number of steps per the request.
|
||||
@@ -71,6 +79,6 @@ human_in_the_loop_agent = ChatAgent(
|
||||
After calling the function, provide a brief acknowledgment like:
|
||||
"I've created a plan with 10 steps. You can customize which steps to enable before I proceed."
|
||||
""",
|
||||
chat_client=AzureOpenAIChatClient(),
|
||||
tools=[generate_task_steps],
|
||||
)
|
||||
chat_client=chat_client,
|
||||
tools=[generate_task_steps],
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
from enum import Enum
|
||||
|
||||
from agent_framework import ChatAgent, ai_function
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from agent_framework._clients import ChatClientProtocol
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent, RecipeConfirmationStrategy
|
||||
@@ -67,10 +67,7 @@ def update_recipe(recipe: Recipe) -> str:
|
||||
return "Recipe updated."
|
||||
|
||||
|
||||
# Create the recipe agent using tool-based approach for streaming
|
||||
agent = ChatAgent(
|
||||
name="recipe_agent",
|
||||
instructions="""You are a helpful recipe assistant that creates and modifies recipes.
|
||||
_RECIPE_INSTRUCTIONS = """You are a helpful recipe assistant that creates and modifies recipes.
|
||||
|
||||
CRITICAL RULES:
|
||||
1. You will receive the current recipe state in the system context
|
||||
@@ -103,20 +100,34 @@ agent = ChatAgent(
|
||||
- Add aromatics: garlic, shallots
|
||||
- Add finishing touches: lemon zest, fresh parsley
|
||||
- Make instructions more detailed and professional
|
||||
""",
|
||||
chat_client=AzureOpenAIChatClient(),
|
||||
tools=[update_recipe],
|
||||
)
|
||||
"""
|
||||
|
||||
recipe_agent = AgentFrameworkAgent(
|
||||
agent=agent,
|
||||
name="RecipeAgent",
|
||||
description="Creates and modifies recipes with streaming state updates",
|
||||
state_schema={
|
||||
"recipe": {"type": "object", "description": "The current recipe"},
|
||||
},
|
||||
predict_state_config={
|
||||
"recipe": {"tool": "update_recipe", "tool_argument": "recipe"},
|
||||
},
|
||||
confirmation_strategy=RecipeConfirmationStrategy(),
|
||||
)
|
||||
|
||||
def recipe_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent:
|
||||
"""Create a recipe agent with streaming state updates.
|
||||
|
||||
Args:
|
||||
chat_client: The chat client to use for the agent
|
||||
|
||||
Returns:
|
||||
A configured AgentFrameworkAgent instance with recipe management
|
||||
"""
|
||||
agent = ChatAgent(
|
||||
name="recipe_agent",
|
||||
instructions=_RECIPE_INSTRUCTIONS,
|
||||
chat_client=chat_client,
|
||||
tools=[update_recipe],
|
||||
)
|
||||
|
||||
return AgentFrameworkAgent(
|
||||
agent=agent,
|
||||
name="RecipeAgent",
|
||||
description="Creates and modifies recipes with streaming state updates",
|
||||
state_schema={
|
||||
"recipe": {"type": "object", "description": "The current recipe"},
|
||||
},
|
||||
predict_state_config={
|
||||
"recipe": {"tool": "update_recipe", "tool_argument": "recipe"},
|
||||
},
|
||||
confirmation_strategy=RecipeConfirmationStrategy(),
|
||||
)
|
||||
|
||||
+27
-15
@@ -5,7 +5,7 @@
|
||||
import asyncio
|
||||
|
||||
from agent_framework import ChatAgent, ai_function
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from agent_framework._clients import ChatClientProtocol
|
||||
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
|
||||
@@ -82,19 +82,31 @@ async def analyze_data(dataset: str) -> str:
|
||||
return f"Analysis of '{dataset}':\n" + "\n".join(insights)
|
||||
|
||||
|
||||
agent = ChatAgent(
|
||||
name="research_assistant",
|
||||
instructions=(
|
||||
"You are a research and analysis assistant. "
|
||||
"You can research topics, create presentations, and analyze data. "
|
||||
"Use the available tools to help users with their research needs."
|
||||
),
|
||||
chat_client=AzureOpenAIChatClient(),
|
||||
tools=[research_topic, create_presentation, analyze_data],
|
||||
_RESEARCH_ASSISTANT_INSTRUCTIONS = (
|
||||
"You are a research and analysis assistant. "
|
||||
"You can research topics, create presentations, and analyze data. "
|
||||
"Use the available tools to help users with their research needs."
|
||||
)
|
||||
|
||||
research_assistant_agent = AgentFrameworkAgent(
|
||||
agent=agent,
|
||||
name="ResearchAssistant",
|
||||
description="Research assistant that emits progress events during task execution",
|
||||
)
|
||||
|
||||
def research_assistant_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent:
|
||||
"""Create a research assistant agent with progress events.
|
||||
|
||||
Args:
|
||||
chat_client: The chat client to use for the agent
|
||||
|
||||
Returns:
|
||||
A configured AgentFrameworkAgent instance with research capabilities
|
||||
"""
|
||||
agent = ChatAgent(
|
||||
name="research_assistant",
|
||||
instructions=_RESEARCH_ASSISTANT_INSTRUCTIONS,
|
||||
chat_client=chat_client,
|
||||
tools=[research_topic, create_presentation, analyze_data],
|
||||
)
|
||||
|
||||
return AgentFrameworkAgent(
|
||||
agent=agent,
|
||||
name="ResearchAssistant",
|
||||
description="Research assistant that emits progress events during task execution",
|
||||
)
|
||||
|
||||
@@ -3,11 +3,20 @@
|
||||
"""Simple agentic chat example (Feature 1: Agentic Chat)."""
|
||||
|
||||
from agent_framework import ChatAgent
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from agent_framework._clients import ChatClientProtocol
|
||||
|
||||
# Create a simple chat agent
|
||||
agent = ChatAgent(
|
||||
name="simple_chat_agent",
|
||||
instructions="You are a helpful assistant. Be concise and friendly.",
|
||||
chat_client=AzureOpenAIChatClient(),
|
||||
)
|
||||
|
||||
def simple_agent(chat_client: ChatClientProtocol) -> ChatAgent:
|
||||
"""Create a simple chat agent.
|
||||
|
||||
Args:
|
||||
chat_client: The chat client to use for the agent
|
||||
|
||||
Returns:
|
||||
A configured ChatAgent instance
|
||||
"""
|
||||
return ChatAgent(
|
||||
name="simple_chat_agent",
|
||||
instructions="You are a helpful assistant. Be concise and friendly.",
|
||||
chat_client=chat_client,
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"""Example agent demonstrating human-in-the-loop with function approvals."""
|
||||
|
||||
from agent_framework import ChatAgent, ai_function
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from agent_framework._clients import ChatClientProtocol
|
||||
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent, TaskPlannerConfirmationStrategy
|
||||
|
||||
@@ -54,20 +54,32 @@ def book_meeting_room(room_name: str, date: str, start_time: str, end_time: str)
|
||||
return f"Meeting room '{room_name}' booked for {date} from {start_time} to {end_time}"
|
||||
|
||||
|
||||
agent = ChatAgent(
|
||||
name="task_planner",
|
||||
instructions=(
|
||||
"You are a helpful assistant that plans and executes tasks. "
|
||||
"You have access to calendar, email, and meeting room booking functions. "
|
||||
"All of these actions require user approval before execution."
|
||||
),
|
||||
chat_client=AzureOpenAIChatClient(),
|
||||
tools=[create_calendar_event, send_email, book_meeting_room],
|
||||
_TASK_PLANNER_INSTRUCTIONS = (
|
||||
"You are a helpful assistant that plans and executes tasks. "
|
||||
"You have access to calendar, email, and meeting room booking functions. "
|
||||
"All of these actions require user approval before execution."
|
||||
)
|
||||
|
||||
task_planner_agent = AgentFrameworkAgent(
|
||||
agent=agent,
|
||||
name="TaskPlanner",
|
||||
description="Plans and executes tasks with user approval",
|
||||
confirmation_strategy=TaskPlannerConfirmationStrategy(),
|
||||
)
|
||||
|
||||
def task_planner_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent:
|
||||
"""Create a task planner agent with user approval for actions.
|
||||
|
||||
Args:
|
||||
chat_client: The chat client to use for the agent
|
||||
|
||||
Returns:
|
||||
A configured AgentFrameworkAgent instance with task planning capabilities
|
||||
"""
|
||||
agent = ChatAgent(
|
||||
name="task_planner",
|
||||
instructions=_TASK_PLANNER_INSTRUCTIONS,
|
||||
chat_client=chat_client,
|
||||
tools=[create_calendar_event, send_email, book_meeting_room],
|
||||
)
|
||||
|
||||
return AgentFrameworkAgent(
|
||||
agent=agent,
|
||||
name="TaskPlanner",
|
||||
description="Plans and executes tasks with user approval",
|
||||
confirmation_strategy=TaskPlannerConfirmationStrategy(),
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ from ag_ui.core import (
|
||||
ToolCallStartEvent,
|
||||
)
|
||||
from agent_framework import ChatAgent, ai_function
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from agent_framework._clients import ChatClientProtocol
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
@@ -54,10 +54,18 @@ def generate_task_steps(steps: list[TaskStep]) -> str:
|
||||
return "Steps generated."
|
||||
|
||||
|
||||
# Create the task steps agent using tool-based approach for streaming
|
||||
agent = ChatAgent(
|
||||
name="task_steps_agent",
|
||||
instructions="""You are a helpful assistant that breaks down tasks into actionable steps.
|
||||
def _create_task_steps_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent:
|
||||
"""Create the task steps agent using tool-based approach for streaming.
|
||||
|
||||
Args:
|
||||
chat_client: The chat client to use for the agent
|
||||
|
||||
Returns:
|
||||
A configured AgentFrameworkAgent instance
|
||||
"""
|
||||
agent = ChatAgent(
|
||||
name="task_steps_agent",
|
||||
instructions="""You are a helpful assistant that breaks down tasks into actionable steps.
|
||||
|
||||
When asked to perform a task, you MUST:
|
||||
1. Use the generate_task_steps tool to create the steps
|
||||
@@ -75,25 +83,25 @@ agent = ChatAgent(
|
||||
- "Installing platform"
|
||||
- "Adding finishing touches"
|
||||
""",
|
||||
chat_client=AzureOpenAIChatClient(),
|
||||
tools=[generate_task_steps],
|
||||
)
|
||||
chat_client=chat_client,
|
||||
tools=[generate_task_steps],
|
||||
)
|
||||
|
||||
task_steps_agent = AgentFrameworkAgent(
|
||||
agent=agent,
|
||||
name="TaskStepsAgent",
|
||||
description="Generates task steps with streaming state updates",
|
||||
state_schema={
|
||||
"steps": {"type": "array", "description": "The list of task steps"},
|
||||
},
|
||||
predict_state_config={
|
||||
"steps": {
|
||||
"tool": "generate_task_steps",
|
||||
"tool_argument": "steps",
|
||||
}
|
||||
},
|
||||
require_confirmation=False, # Agentic generative UI updates automatically without confirmation
|
||||
)
|
||||
return AgentFrameworkAgent(
|
||||
agent=agent,
|
||||
name="TaskStepsAgent",
|
||||
description="Generates task steps with streaming state updates",
|
||||
state_schema={
|
||||
"steps": {"type": "array", "description": "The list of task steps"},
|
||||
},
|
||||
predict_state_config={
|
||||
"steps": {
|
||||
"tool": "generate_task_steps",
|
||||
"tool_argument": "steps",
|
||||
}
|
||||
},
|
||||
require_confirmation=False, # Agentic generative UI updates automatically without confirmation
|
||||
)
|
||||
|
||||
|
||||
# Wrap the agent's run method to add step execution simulation
|
||||
@@ -131,7 +139,7 @@ class TaskStepsAgentWithExecution:
|
||||
logger.info("TaskStepsAgentWithExecution.run_agent() called - wrapper is active")
|
||||
|
||||
# First, run the base agent to generate the plan - buffer text messages
|
||||
final_state: dict[str, Any] | None = None
|
||||
final_state: dict[str, Any] = {}
|
||||
run_finished_event: Any = None
|
||||
tool_call_id: str | None = None
|
||||
buffered_text_events: list[Any] = [] # Buffer text from first LLM call
|
||||
@@ -142,9 +150,20 @@ class TaskStepsAgentWithExecution:
|
||||
|
||||
match event:
|
||||
case StateSnapshotEvent(snapshot=snapshot):
|
||||
final_state = snapshot
|
||||
final_state = snapshot.copy() if snapshot else {}
|
||||
logger.info(f"Captured STATE_SNAPSHOT event with state: {final_state}")
|
||||
yield event
|
||||
case StateDeltaEvent(delta=delta):
|
||||
# Apply state delta to final_state
|
||||
if delta:
|
||||
for patch in delta:
|
||||
if patch.get("op") == "replace" and patch.get("path") == "/steps":
|
||||
final_state["steps"] = patch.get("value", [])
|
||||
logger.info(
|
||||
f"Applied STATE_DELTA: updated steps to {len(final_state.get('steps', []))} items"
|
||||
)
|
||||
logger.info(f"Yielding event immediately: {event_type_str}")
|
||||
yield event
|
||||
case RunFinishedEvent():
|
||||
run_finished_event = event
|
||||
logger.info("Captured RUN_FINISHED event - will send after step execution and summary")
|
||||
@@ -314,5 +333,14 @@ class TaskStepsAgentWithExecution:
|
||||
yield run_finished_event
|
||||
|
||||
|
||||
# Export the wrapped agent
|
||||
task_steps_agent_wrapped = TaskStepsAgentWithExecution(task_steps_agent)
|
||||
def task_steps_agent_wrapped(chat_client: ChatClientProtocol) -> TaskStepsAgentWithExecution:
|
||||
"""Create a task steps agent with execution simulation.
|
||||
|
||||
Args:
|
||||
chat_client: The chat client to use for the agent
|
||||
|
||||
Returns:
|
||||
A wrapped agent instance with step execution simulation
|
||||
"""
|
||||
base_agent = _create_task_steps_agent(chat_client)
|
||||
return TaskStepsAgentWithExecution(base_agent)
|
||||
|
||||
+136
-77
@@ -4,23 +4,39 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import ChatAgent, ai_function
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from agent_framework import AIFunction, ChatAgent
|
||||
from agent_framework._clients import ChatClientProtocol
|
||||
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
|
||||
|
||||
@ai_function
|
||||
def generate_haiku(english: list[str], japanese: list[str], image_name: str | None, gradient: str) -> str:
|
||||
"""Generate a haiku with image and gradient background (FRONTEND_RENDER).
|
||||
# Declaration-only tools (func=None) - actual rendering happens on the client side
|
||||
generate_haiku = AIFunction[Any, str](
|
||||
name="generate_haiku",
|
||||
description="""Generate a haiku with image and gradient background (FRONTEND_RENDER).
|
||||
|
||||
This tool generates UI for displaying a haiku with an image and gradient background.
|
||||
The frontend should render this as a custom haiku component.
|
||||
|
||||
Args:
|
||||
english: English haiku lines (exactly 3 lines)
|
||||
japanese: Japanese haiku lines (exactly 3 lines)
|
||||
image_name: Image filename for visual accompaniment. Must be one of:
|
||||
The frontend should render this as a custom haiku component.""",
|
||||
func=None, # Makes declaration_only=True so client renders the UI
|
||||
input_model={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"english": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "English haiku lines (exactly 3 lines)",
|
||||
"minItems": 3,
|
||||
"maxItems": 3,
|
||||
},
|
||||
"japanese": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Japanese haiku lines (exactly 3 lines)",
|
||||
"minItems": 3,
|
||||
"maxItems": 3,
|
||||
},
|
||||
"image_name": {
|
||||
"type": "string",
|
||||
"description": """Image filename for visual accompaniment. Must be one of:
|
||||
- "Osaka_Castle_Turret_Stone_Wall_Pine_Trees_Daytime.jpg"
|
||||
- "Tokyo_Skyline_Night_Tokyo_Tower_Mount_Fuji_View.jpg"
|
||||
- "Itsukushima_Shrine_Miyajima_Floating_Torii_Gate_Sunset_Long_Exposure.jpg"
|
||||
@@ -31,71 +47,100 @@ def generate_haiku(english: list[str], japanese: list[str], image_name: str | No
|
||||
- "Senso-ji_Temple_Asakusa_Cherry_Blossoms_Kimono_Umbrella.jpg"
|
||||
- "Cherry_Blossoms_Sakura_Night_View_City_Lights_Japan.jpg"
|
||||
- "Mount_Fuji_Lake_Reflection_Cherry_Blossoms_Sakura_Spring.jpg"
|
||||
gradient: CSS gradient string for background (e.g., "linear-gradient(135deg, #667eea 0%, #764ba2 100%)")
|
||||
""",
|
||||
},
|
||||
"gradient": {
|
||||
"type": "string",
|
||||
"description": 'CSS gradient string for background (e.g., "linear-gradient(135deg, #667eea 0%, #764ba2 100%)")',
|
||||
},
|
||||
},
|
||||
"required": ["english", "japanese", "image_name", "gradient"],
|
||||
},
|
||||
)
|
||||
|
||||
Returns:
|
||||
Haiku metadata for frontend rendering
|
||||
"""
|
||||
return f"Haiku generated with image: {image_name}"
|
||||
|
||||
|
||||
@ai_function
|
||||
def create_chart(chart_type: str, data_points: list[dict[str, Any]], title: str) -> str:
|
||||
"""Create an interactive chart (FRONTEND_RENDER).
|
||||
create_chart = AIFunction[Any, str](
|
||||
name="create_chart",
|
||||
description="""Create an interactive chart (FRONTEND_RENDER).
|
||||
|
||||
This tool creates chart specifications for frontend rendering.
|
||||
The frontend should render this as an interactive chart component.
|
||||
The frontend should render this as an interactive chart component.""",
|
||||
func=None, # Makes declaration_only=True so client renders the UI
|
||||
input_model={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"chart_type": {
|
||||
"type": "string",
|
||||
"description": "Type of chart (bar, line, pie, scatter)",
|
||||
},
|
||||
"data_points": {
|
||||
"type": "array",
|
||||
"items": {"type": "object"},
|
||||
"description": "Data points for the chart",
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Chart title",
|
||||
},
|
||||
},
|
||||
"required": ["chart_type", "data_points", "title"],
|
||||
},
|
||||
)
|
||||
|
||||
Args:
|
||||
chart_type: Type of chart (bar, line, pie, scatter)
|
||||
data_points: Data points for the chart
|
||||
title: Chart title
|
||||
|
||||
Returns:
|
||||
Chart specification for frontend rendering
|
||||
"""
|
||||
return f"Chart '{title}' created with {len(data_points)} data points"
|
||||
|
||||
|
||||
@ai_function
|
||||
def display_timeline(events: list[dict[str, Any]], start_date: str, end_date: str) -> str:
|
||||
"""Display an interactive timeline (FRONTEND_RENDER).
|
||||
display_timeline = AIFunction[Any, str](
|
||||
name="display_timeline",
|
||||
description="""Display an interactive timeline (FRONTEND_RENDER).
|
||||
|
||||
This tool creates timeline specifications for frontend rendering.
|
||||
The frontend should render this as an interactive timeline component.
|
||||
The frontend should render this as an interactive timeline component.""",
|
||||
func=None, # Makes declaration_only=True so client renders the UI
|
||||
input_model={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"events": {
|
||||
"type": "array",
|
||||
"items": {"type": "object"},
|
||||
"description": "Events to display on the timeline",
|
||||
},
|
||||
"start_date": {
|
||||
"type": "string",
|
||||
"description": "Timeline start date",
|
||||
},
|
||||
"end_date": {
|
||||
"type": "string",
|
||||
"description": "Timeline end date",
|
||||
},
|
||||
},
|
||||
"required": ["events", "start_date", "end_date"],
|
||||
},
|
||||
)
|
||||
|
||||
Args:
|
||||
events: Events to display on the timeline
|
||||
start_date: Timeline start date
|
||||
end_date: Timeline end date
|
||||
|
||||
Returns:
|
||||
Timeline specification for frontend rendering
|
||||
"""
|
||||
return f"Timeline created with {len(events)} events from {start_date} to {end_date}"
|
||||
|
||||
|
||||
@ai_function
|
||||
def show_comparison_table(items: list[dict[str, Any]], columns: list[str]) -> str:
|
||||
"""Show a comparison table (FRONTEND_RENDER).
|
||||
show_comparison_table = AIFunction[Any, str](
|
||||
name="show_comparison_table",
|
||||
description="""Show a comparison table (FRONTEND_RENDER).
|
||||
|
||||
This tool creates table specifications for frontend rendering.
|
||||
The frontend should render this as an interactive comparison table.
|
||||
|
||||
Args:
|
||||
items: Items to compare
|
||||
columns: Column names
|
||||
|
||||
Returns:
|
||||
Table specification for frontend rendering
|
||||
"""
|
||||
return f"Comparison table created with {len(items)} items and {len(columns)} columns"
|
||||
The frontend should render this as an interactive comparison table.""",
|
||||
func=None, # Makes declaration_only=True so client renders the UI
|
||||
input_model={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {"type": "object"},
|
||||
"description": "Items to compare",
|
||||
},
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Column names",
|
||||
},
|
||||
},
|
||||
"required": ["items", "columns"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Create the UI generator agent using tool-based approach with forced tool usage
|
||||
agent = ChatAgent(
|
||||
name="ui_generator",
|
||||
instructions="""You MUST use the provided tools to generate content. Never respond with plain text descriptions.
|
||||
_UI_GENERATOR_INSTRUCTIONS = """You MUST use the provided tools to generate content. Never respond with plain text descriptions.
|
||||
|
||||
For haiku requests:
|
||||
- Call generate_haiku tool with all 4 required parameters
|
||||
@@ -105,15 +150,29 @@ agent = ChatAgent(
|
||||
- gradient: CSS gradient string
|
||||
|
||||
For other requests, use the appropriate tool (create_chart, display_timeline, show_comparison_table).
|
||||
""",
|
||||
chat_client=AzureOpenAIChatClient(),
|
||||
tools=[generate_haiku, create_chart, display_timeline, show_comparison_table],
|
||||
# Force tool usage - the LLM MUST call a tool, cannot respond with plain text
|
||||
chat_options={"tool_choice": "required"},
|
||||
)
|
||||
"""
|
||||
|
||||
ui_generator_agent = AgentFrameworkAgent(
|
||||
agent=agent,
|
||||
name="UIGenerator",
|
||||
description="Generates custom UI components through tool calls",
|
||||
)
|
||||
|
||||
def ui_generator_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent:
|
||||
"""Create a UI generator agent with frontend rendering tools.
|
||||
|
||||
Args:
|
||||
chat_client: The chat client to use for the agent
|
||||
|
||||
Returns:
|
||||
A configured AgentFrameworkAgent instance with UI generation tools
|
||||
"""
|
||||
agent = ChatAgent(
|
||||
name="ui_generator",
|
||||
instructions=_UI_GENERATOR_INSTRUCTIONS,
|
||||
chat_client=chat_client,
|
||||
tools=[generate_haiku, create_chart, display_timeline, show_comparison_table],
|
||||
# Force tool usage - the LLM MUST call a tool, cannot respond with plain text
|
||||
chat_options={"tool_choice": "required"},
|
||||
)
|
||||
|
||||
return AgentFrameworkAgent(
|
||||
agent=agent,
|
||||
name="UIGenerator",
|
||||
description="Generates custom UI components through tool calls",
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import ChatAgent, ai_function
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from agent_framework._clients import ChatClientProtocol
|
||||
|
||||
|
||||
@ai_function
|
||||
@@ -58,14 +58,22 @@ def get_forecast(location: str, days: int = 3) -> str:
|
||||
return f"{days}-day forecast for {location}:\n" + "\n".join(forecast)
|
||||
|
||||
|
||||
# Create the weather agent
|
||||
weather_agent = ChatAgent(
|
||||
name="weather_agent",
|
||||
instructions=(
|
||||
"You are a helpful weather assistant. "
|
||||
"Use the get_weather and get_forecast functions to help users with weather information. "
|
||||
"Always provide friendly and informative responses."
|
||||
),
|
||||
chat_client=AzureOpenAIChatClient(),
|
||||
tools=[get_weather, get_forecast],
|
||||
)
|
||||
def weather_agent(chat_client: ChatClientProtocol) -> ChatAgent:
|
||||
"""Create a weather agent with get_weather and get_forecast tools.
|
||||
|
||||
Args:
|
||||
chat_client: The chat client to use for the agent
|
||||
|
||||
Returns:
|
||||
A configured ChatAgent instance with weather tools
|
||||
"""
|
||||
return ChatAgent(
|
||||
name="weather_agent",
|
||||
instructions=(
|
||||
"You are a helpful weather assistant. "
|
||||
"Use the get_weather and get_forecast functions to help users with weather information. "
|
||||
"Always provide friendly and informative responses."
|
||||
),
|
||||
chat_client=chat_client,
|
||||
tools=[get_weather, get_forecast],
|
||||
)
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""API endpoints for AG-UI examples."""
|
||||
+5
-1
@@ -2,6 +2,7 @@
|
||||
|
||||
"""Backend tool rendering endpoint."""
|
||||
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from fastapi import FastAPI
|
||||
|
||||
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
|
||||
@@ -15,8 +16,11 @@ def register_backend_tool_rendering(app: FastAPI) -> None:
|
||||
Args:
|
||||
app: The FastAPI application.
|
||||
"""
|
||||
# Create a chat client and call the factory function
|
||||
chat_client = AzureOpenAIChatClient()
|
||||
|
||||
add_agent_framework_fastapi_endpoint(
|
||||
app,
|
||||
weather_agent,
|
||||
weather_agent(chat_client),
|
||||
"/backend_tool_rendering",
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
import os
|
||||
|
||||
import uvicorn
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
@@ -14,8 +15,8 @@ from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
|
||||
from ..agents.document_writer_agent import document_writer_agent
|
||||
from ..agents.human_in_the_loop_agent import human_in_the_loop_agent
|
||||
from ..agents.recipe_agent import recipe_agent
|
||||
from ..agents.simple_agent import agent as simple_agent
|
||||
from ..agents.task_steps_agent import task_steps_agent_wrapped as task_steps_agent # Custom wrapper
|
||||
from ..agents.simple_agent import simple_agent
|
||||
from ..agents.task_steps_agent import task_steps_agent_wrapped
|
||||
from ..agents.ui_generator_agent import ui_generator_agent
|
||||
from ..agents.weather_agent import weather_agent
|
||||
|
||||
@@ -58,38 +59,42 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Create a shared chat client for all agents
|
||||
# You can use different chat clients for different agents if needed
|
||||
chat_client = AzureOpenAIChatClient()
|
||||
|
||||
# Agentic Chat - basic chat agent
|
||||
add_agent_framework_fastapi_endpoint(
|
||||
app=app,
|
||||
agent=simple_agent,
|
||||
agent=simple_agent(chat_client),
|
||||
path="/agentic_chat",
|
||||
)
|
||||
|
||||
# Backend Tool Rendering - agent with tools
|
||||
add_agent_framework_fastapi_endpoint(
|
||||
app=app,
|
||||
agent=weather_agent,
|
||||
agent=weather_agent(chat_client),
|
||||
path="/backend_tool_rendering",
|
||||
)
|
||||
|
||||
# Shared State - recipe agent with structured output
|
||||
add_agent_framework_fastapi_endpoint(
|
||||
app=app,
|
||||
agent=recipe_agent,
|
||||
agent=recipe_agent(chat_client),
|
||||
path="/shared_state",
|
||||
)
|
||||
|
||||
# Predictive State Updates - document writer with predictive state
|
||||
add_agent_framework_fastapi_endpoint(
|
||||
app=app,
|
||||
agent=document_writer_agent,
|
||||
agent=document_writer_agent(chat_client),
|
||||
path="/predictive_state_updates",
|
||||
)
|
||||
|
||||
# Human in the Loop - human-in-the-loop agent with step customization
|
||||
add_agent_framework_fastapi_endpoint(
|
||||
app=app,
|
||||
agent=human_in_the_loop_agent,
|
||||
agent=human_in_the_loop_agent(chat_client),
|
||||
path="/human_in_the_loop",
|
||||
state_schema={"steps": {"type": "array"}},
|
||||
predict_state_config={"steps": {"tool": "generate_task_steps", "tool_argument": "steps"}},
|
||||
@@ -98,23 +103,26 @@ add_agent_framework_fastapi_endpoint(
|
||||
# Agentic Generative UI - task steps agent with streaming state updates
|
||||
add_agent_framework_fastapi_endpoint(
|
||||
app=app,
|
||||
agent=task_steps_agent, # type: ignore[arg-type]
|
||||
agent=task_steps_agent_wrapped(chat_client), # type: ignore[arg-type]
|
||||
path="/agentic_generative_ui",
|
||||
)
|
||||
|
||||
# Tool-based Generative UI - UI generator with frontend-rendered tools
|
||||
add_agent_framework_fastapi_endpoint(
|
||||
app=app,
|
||||
agent=ui_generator_agent,
|
||||
agent=ui_generator_agent(chat_client),
|
||||
path="/tool_based_generative_ui",
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the server."""
|
||||
port = int(os.getenv("PORT", "8888"))
|
||||
port = int(os.getenv("PORT", "8887"))
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
|
||||
print(f"\nAG-UI Examples Server starting on http://{host}:{port}")
|
||||
print("Set ENABLE_DEBUG_LOGGING=1 for detailed request logging\n")
|
||||
|
||||
# Use log_config=None to prevent uvicorn from reconfiguring logging
|
||||
# This preserves our file + console logging setup
|
||||
uvicorn.run(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "agent-framework-ag-ui"
|
||||
version = "1.0.0b251111"
|
||||
version = "1.0.0b251112"
|
||||
description = "AG-UI protocol integration for Agent Framework"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
|
||||
@@ -505,17 +505,20 @@ async def test_error_handling_with_exception():
|
||||
|
||||
|
||||
async def test_json_decode_error_in_tool_result():
|
||||
"""Test handling of JSONDecodeError when parsing tool result."""
|
||||
"""Test handling of orphaned tool result - should be sanitized out."""
|
||||
from agent_framework_ag_ui import AgentFrameworkAgent
|
||||
|
||||
class MockChatClient:
|
||||
async def get_streaming_response(self, messages, chat_options, **kwargs):
|
||||
yield ChatResponseUpdate(contents=[TextContent(text="Fallback response")])
|
||||
# Should not be called since orphaned tool result is dropped
|
||||
if False:
|
||||
yield
|
||||
raise AssertionError("ChatClient should not be called with orphaned tool result")
|
||||
|
||||
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=MockChatClient())
|
||||
wrapper = AgentFrameworkAgent(agent=agent)
|
||||
|
||||
# Send invalid JSON as tool result
|
||||
# Send invalid JSON as tool result without preceding tool call
|
||||
input_data = {
|
||||
"messages": [
|
||||
{
|
||||
@@ -530,10 +533,12 @@ async def test_json_decode_error_in_tool_result():
|
||||
async for event in wrapper.run_agent(input_data):
|
||||
events.append(event)
|
||||
|
||||
# Should fall through to normal agent processing
|
||||
# Orphaned tool result should be sanitized out
|
||||
# Only run lifecycle events should be emitted, no text/tool events
|
||||
text_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"]
|
||||
assert len(text_events) > 0
|
||||
assert text_events[0].delta == "Fallback response"
|
||||
tool_events = [e for e in events if e.type.startswith("TOOL_CALL")]
|
||||
assert len(text_events) == 0
|
||||
assert len(tool_events) == 0
|
||||
|
||||
|
||||
async def test_suppressed_summary_with_document_state():
|
||||
|
||||
@@ -0,0 +1,811 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Comprehensive tests for orchestrator coverage."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import (
|
||||
AgentRunResponseUpdate,
|
||||
ChatMessage,
|
||||
TextContent,
|
||||
ai_function,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework_ag_ui._agent import AgentConfig
|
||||
from agent_framework_ag_ui._orchestrators import (
|
||||
DefaultOrchestrator,
|
||||
ExecutionContext,
|
||||
HumanInTheLoopOrchestrator,
|
||||
)
|
||||
|
||||
|
||||
@ai_function(approval_mode="always_require")
|
||||
def approval_tool(param: str) -> str:
|
||||
"""Tool requiring approval."""
|
||||
return f"executed: {param}"
|
||||
|
||||
|
||||
class MockAgent:
|
||||
"""Mock agent for testing."""
|
||||
|
||||
def __init__(self, updates: list[AgentRunResponseUpdate] | None = None) -> None:
|
||||
self.updates = updates or [AgentRunResponseUpdate(contents=[TextContent(text="response")], role="assistant")]
|
||||
self.chat_options = SimpleNamespace(tools=[approval_tool], response_format=None)
|
||||
self.chat_client = SimpleNamespace(function_invocation_configuration=None)
|
||||
self.messages_received: list[Any] = []
|
||||
self.tools_received: list[Any] | None = None
|
||||
|
||||
async def run_stream(
|
||||
self,
|
||||
messages: list[Any],
|
||||
*,
|
||||
thread: Any = None,
|
||||
tools: list[Any] | None = None,
|
||||
) -> AsyncGenerator[AgentRunResponseUpdate, None]:
|
||||
self.messages_received = messages
|
||||
self.tools_received = tools
|
||||
for update in self.updates:
|
||||
yield update
|
||||
|
||||
|
||||
async def test_human_in_the_loop_json_decode_error() -> None:
|
||||
"""Test HumanInTheLoopOrchestrator handles invalid JSON in tool result."""
|
||||
orchestrator = HumanInTheLoopOrchestrator()
|
||||
|
||||
input_data = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "tool",
|
||||
"content": [{"type": "text", "text": "not valid json {"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="tool",
|
||||
contents=[TextContent(text="not valid json {")],
|
||||
additional_properties={"is_tool_result": True},
|
||||
)
|
||||
]
|
||||
|
||||
context = ExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=MockAgent(),
|
||||
config=AgentConfig(),
|
||||
)
|
||||
context._messages = messages
|
||||
|
||||
assert orchestrator.can_handle(context)
|
||||
|
||||
events = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Should emit RunErrorEvent for invalid JSON
|
||||
error_events = [e for e in events if e.type == "RUN_ERROR"]
|
||||
assert len(error_events) == 1
|
||||
assert "Invalid tool result format" in error_events[0].message
|
||||
|
||||
|
||||
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
|
||||
|
||||
# Create messages that will trigger confirm_changes synthetic result injection
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="assistant",
|
||||
contents=[
|
||||
FunctionCallContent(
|
||||
name="confirm_changes",
|
||||
call_id="call_confirm_123",
|
||||
arguments='{"changes": "test"}',
|
||||
)
|
||||
],
|
||||
),
|
||||
ChatMessage(
|
||||
role="user",
|
||||
contents=[TextContent(text='{"accepted": true}')],
|
||||
),
|
||||
]
|
||||
|
||||
# The sanitize_tool_history function is internal to DefaultOrchestrator.run
|
||||
# We'll test it indirectly by checking the orchestrator processes it correctly
|
||||
orchestrator = DefaultOrchestrator()
|
||||
|
||||
# Use pre-constructed ChatMessage objects to bypass message adapter
|
||||
input_data = {"messages": []}
|
||||
|
||||
agent = MockAgent()
|
||||
context = ExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
# Override the messages property to use our pre-constructed messages
|
||||
context._messages = messages
|
||||
|
||||
events = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Agent should receive synthetic tool result
|
||||
assert len(agent.messages_received) > 0
|
||||
tool_messages = [
|
||||
msg
|
||||
for msg in agent.messages_received
|
||||
if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "tool"
|
||||
]
|
||||
assert len(tool_messages) == 1
|
||||
assert str(tool_messages[0].contents[0].call_id) == "call_confirm_123"
|
||||
assert tool_messages[0].contents[0].result == "Confirmed"
|
||||
|
||||
|
||||
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
|
||||
|
||||
# Tool result without preceding assistant tool call
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="tool",
|
||||
contents=[FunctionResultContent(call_id="orphan_123", result="orphaned data")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="user",
|
||||
contents=[TextContent(text="Hello")],
|
||||
),
|
||||
]
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
input_data = {"messages": []}
|
||||
agent = MockAgent()
|
||||
context = ExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
context._messages = messages
|
||||
|
||||
events = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Orphaned tool result should be filtered out
|
||||
tool_messages = [
|
||||
msg
|
||||
for msg in agent.messages_received
|
||||
if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "tool"
|
||||
]
|
||||
assert len(tool_messages) == 0
|
||||
|
||||
|
||||
async def test_orphaned_tool_result_sanitization() -> None:
|
||||
"""Test that orphaned tool results are filtered out."""
|
||||
orchestrator = DefaultOrchestrator()
|
||||
|
||||
input_data = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "tool",
|
||||
"content": [{"type": "tool_result", "tool_call_id": "orphan_123", "content": "result"}],
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "Hello"}],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
agent = MockAgent()
|
||||
context = ExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
|
||||
events = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Orphaned tool result should be filtered, only user message remains
|
||||
tool_messages = [
|
||||
msg
|
||||
for msg in agent.messages_received
|
||||
if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "tool"
|
||||
]
|
||||
assert len(tool_messages) == 0
|
||||
|
||||
|
||||
async def test_deduplicate_messages_empty_tool_results() -> None:
|
||||
"""Test deduplicate_messages prefers non-empty tool results."""
|
||||
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent
|
||||
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="assistant",
|
||||
contents=[FunctionCallContent(name="test_tool", call_id="call_789", arguments="{}")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="tool",
|
||||
contents=[FunctionResultContent(call_id="call_789", result="")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="tool",
|
||||
contents=[FunctionResultContent(call_id="call_789", result="real data")],
|
||||
),
|
||||
]
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
input_data = {"messages": []}
|
||||
agent = MockAgent()
|
||||
context = ExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
context._messages = messages
|
||||
|
||||
events = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Should have only one tool result with actual data
|
||||
tool_messages = [
|
||||
msg
|
||||
for msg in agent.messages_received
|
||||
if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "tool"
|
||||
]
|
||||
assert len(tool_messages) == 1
|
||||
assert tool_messages[0].contents[0].result == "real data"
|
||||
|
||||
|
||||
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
|
||||
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="assistant",
|
||||
contents=[FunctionCallContent(name="test_tool", call_id="call_abc", arguments="{}")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="assistant",
|
||||
contents=[FunctionCallContent(name="test_tool", call_id="call_abc", arguments="{}")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="tool",
|
||||
contents=[FunctionResultContent(call_id="call_abc", result="result")],
|
||||
),
|
||||
]
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
input_data = {"messages": []}
|
||||
agent = MockAgent()
|
||||
context = ExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
context._messages = messages
|
||||
|
||||
events = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Should have only one assistant message
|
||||
assistant_messages = [
|
||||
msg
|
||||
for msg in agent.messages_received
|
||||
if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "assistant"
|
||||
]
|
||||
assert len(assistant_messages) == 1
|
||||
|
||||
|
||||
async def test_deduplicate_messages_duplicate_system_messages() -> None:
|
||||
"""Test that deduplication logic is invoked for system messages."""
|
||||
from agent_framework import ChatMessage, TextContent
|
||||
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="system",
|
||||
contents=[TextContent(text="You are a helpful assistant.")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="system",
|
||||
contents=[TextContent(text="You are a helpful assistant.")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="user",
|
||||
contents=[TextContent(text="Hello")],
|
||||
),
|
||||
]
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
input_data = {"messages": []}
|
||||
agent = MockAgent()
|
||||
context = ExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
context._messages = messages
|
||||
|
||||
events = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Deduplication uses hash() which may not deduplicate identical content
|
||||
# This test verifies deduplication logic runs without errors
|
||||
system_messages = [
|
||||
msg
|
||||
for msg in agent.messages_received
|
||||
if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "system"
|
||||
]
|
||||
# At least one system message should be present
|
||||
assert len(system_messages) >= 1
|
||||
|
||||
|
||||
async def test_state_context_injection() -> None:
|
||||
"""Test state context message injection for first request."""
|
||||
orchestrator = DefaultOrchestrator()
|
||||
|
||||
input_data = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "Hello"}],
|
||||
}
|
||||
],
|
||||
"state": {"items": ["apple", "banana"]},
|
||||
}
|
||||
|
||||
agent = MockAgent()
|
||||
context = ExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(state_schema={"items": {"type": "array"}}),
|
||||
)
|
||||
|
||||
events = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Should inject system message with current state
|
||||
system_messages = [
|
||||
msg
|
||||
for msg in agent.messages_received
|
||||
if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "system"
|
||||
]
|
||||
assert len(system_messages) == 1
|
||||
assert "apple" in system_messages[0].contents[0].text
|
||||
assert "banana" in system_messages[0].contents[0].text
|
||||
|
||||
|
||||
async def test_no_state_context_injection_with_tool_calls() -> None:
|
||||
"""Test state context is NOT injected if conversation has tool calls."""
|
||||
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent, TextContent
|
||||
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="assistant",
|
||||
contents=[FunctionCallContent(name="get_weather", call_id="call_xyz", arguments="{}")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="tool",
|
||||
contents=[FunctionResultContent(call_id="call_xyz", result="sunny")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="user",
|
||||
contents=[TextContent(text="Thanks")],
|
||||
),
|
||||
]
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
input_data = {"messages": [], "state": {"weather": "sunny"}}
|
||||
agent = MockAgent()
|
||||
context = ExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(state_schema={"weather": {"type": "string"}}),
|
||||
)
|
||||
context._messages = messages
|
||||
|
||||
events = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Should NOT inject state context system message since conversation has tool calls
|
||||
system_messages = [
|
||||
msg
|
||||
for msg in agent.messages_received
|
||||
if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "system"
|
||||
]
|
||||
assert len(system_messages) == 0
|
||||
|
||||
|
||||
async def test_structured_output_processing() -> None:
|
||||
"""Test structured output extraction and state update."""
|
||||
|
||||
class RecipeState(BaseModel):
|
||||
ingredients: list[str]
|
||||
message: str
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
|
||||
input_data = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "Add tomato"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
# Agent with structured output
|
||||
agent = MockAgent(
|
||||
updates=[
|
||||
AgentRunResponseUpdate(
|
||||
contents=[TextContent(text='{"ingredients": ["tomato"], "message": "Added tomato"}')],
|
||||
role="assistant",
|
||||
)
|
||||
]
|
||||
)
|
||||
agent.chat_options.response_format = RecipeState
|
||||
|
||||
context = ExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(state_schema={"ingredients": {"type": "array"}}),
|
||||
)
|
||||
|
||||
events = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Should emit StateSnapshotEvent with ingredients
|
||||
state_events = [e for e in events if e.type == "STATE_SNAPSHOT"]
|
||||
assert len(state_events) >= 1
|
||||
|
||||
# Should emit TextMessage with message field
|
||||
text_content_events = [e for e in events if e.type == "TEXT_MESSAGE_CONTENT"]
|
||||
assert len(text_content_events) >= 1
|
||||
assert any("Added tomato" in e.delta for e in text_content_events)
|
||||
|
||||
|
||||
async def test_duplicate_client_tools_filtered() -> None:
|
||||
"""Test that client tools duplicating server tools are filtered out."""
|
||||
|
||||
@ai_function
|
||||
def get_weather(location: str) -> str:
|
||||
"""Get weather for location."""
|
||||
return f"Weather in {location}"
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
|
||||
input_data = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "Hello"}],
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"name": "get_weather",
|
||||
"description": "Client weather tool.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"location": {"type": "string"}},
|
||||
"required": ["location"],
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
agent = MockAgent()
|
||||
agent.chat_options.tools = [get_weather]
|
||||
|
||||
context = ExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
|
||||
events = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# tools parameter should not be passed since client tool duplicates server tool
|
||||
assert agent.tools_received is None
|
||||
|
||||
|
||||
async def test_unique_client_tools_merged() -> None:
|
||||
"""Test that unique client tools are merged with server tools."""
|
||||
|
||||
@ai_function
|
||||
def server_tool() -> str:
|
||||
"""Server tool."""
|
||||
return "server"
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
|
||||
input_data = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "Hello"}],
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"name": "client_tool",
|
||||
"description": "Unique client tool.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"param": {"type": "string"}},
|
||||
"required": ["param"],
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
agent = MockAgent()
|
||||
agent.chat_options.tools = [server_tool]
|
||||
|
||||
context = ExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
|
||||
events = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# tools parameter should be passed with both server and client tools
|
||||
assert agent.tools_received is not None
|
||||
tool_names = [getattr(tool, "name", None) for tool in agent.tools_received]
|
||||
assert "server_tool" in tool_names
|
||||
assert "client_tool" in tool_names
|
||||
|
||||
|
||||
async def test_empty_messages_handling() -> None:
|
||||
"""Test orchestrator handles empty message list gracefully."""
|
||||
orchestrator = DefaultOrchestrator()
|
||||
|
||||
input_data = {"messages": []}
|
||||
|
||||
agent = MockAgent()
|
||||
context = ExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
|
||||
events = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Should emit run lifecycle events but not call agent
|
||||
assert len(agent.messages_received) == 0
|
||||
run_started = [e for e in events if e.type == "RUN_STARTED"]
|
||||
run_finished = [e for e in events if e.type == "RUN_FINISHED"]
|
||||
assert len(run_started) == 1
|
||||
assert len(run_finished) == 1
|
||||
|
||||
|
||||
async def test_all_messages_filtered_handling() -> None:
|
||||
"""Test orchestrator handles case where all messages are filtered out."""
|
||||
orchestrator = DefaultOrchestrator()
|
||||
|
||||
input_data = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "tool",
|
||||
"content": [{"type": "tool_result", "tool_call_id": "orphan", "content": "data"}],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
agent = MockAgent()
|
||||
context = ExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
|
||||
events = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Should finish without calling agent
|
||||
assert len(agent.messages_received) == 0
|
||||
run_finished = [e for e in events if e.type == "RUN_FINISHED"]
|
||||
assert len(run_finished) == 1
|
||||
|
||||
|
||||
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
|
||||
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="assistant",
|
||||
contents=[
|
||||
FunctionCallContent(
|
||||
name="confirm_changes",
|
||||
call_id="call_confirm_invalid",
|
||||
arguments='{"changes": "test"}',
|
||||
)
|
||||
],
|
||||
),
|
||||
ChatMessage(
|
||||
role="user",
|
||||
contents=[TextContent(text="invalid json {")],
|
||||
),
|
||||
]
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
input_data = {"messages": []}
|
||||
agent = MockAgent()
|
||||
context = ExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
context._messages = messages
|
||||
|
||||
events = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Invalid JSON should fall back - user message should be included
|
||||
user_messages = [
|
||||
msg
|
||||
for msg in agent.messages_received
|
||||
if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "user"
|
||||
]
|
||||
assert len(user_messages) == 1
|
||||
|
||||
|
||||
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
|
||||
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="assistant",
|
||||
contents=[FunctionCallContent(name="get_data", call_id="call_match", arguments="{}")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="tool",
|
||||
contents=[FunctionResultContent(call_id="call_match", result="data")],
|
||||
),
|
||||
]
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
input_data = {"messages": []}
|
||||
agent = MockAgent()
|
||||
context = ExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
context._messages = messages
|
||||
|
||||
events = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Tool result should be kept
|
||||
tool_messages = [
|
||||
msg
|
||||
for msg in agent.messages_received
|
||||
if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "tool"
|
||||
]
|
||||
assert len(tool_messages) == 1
|
||||
assert tool_messages[0].contents[0].result == "data"
|
||||
|
||||
|
||||
async def test_agent_protocol_fallback_paths() -> None:
|
||||
"""Test fallback paths for non-ChatAgent implementations."""
|
||||
|
||||
class CustomAgent:
|
||||
"""Custom agent without ChatAgent type."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.chat_options = SimpleNamespace(tools=[], response_format=None)
|
||||
self.chat_client = SimpleNamespace(function_invocation_configuration=SimpleNamespace())
|
||||
self.messages_received: list[Any] = []
|
||||
|
||||
async def run_stream(
|
||||
self,
|
||||
messages: list[Any],
|
||||
*,
|
||||
thread: Any = None,
|
||||
tools: list[Any] | None = None,
|
||||
) -> AsyncGenerator[AgentRunResponseUpdate, None]:
|
||||
self.messages_received = messages
|
||||
yield AgentRunResponseUpdate(contents=[TextContent(text="response")], role="assistant")
|
||||
|
||||
from agent_framework import ChatMessage, TextContent
|
||||
|
||||
messages = [ChatMessage(role="user", contents=[TextContent(text="Hello")])]
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
input_data = {"messages": []}
|
||||
agent = CustomAgent()
|
||||
context = ExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent, # type: ignore
|
||||
config=AgentConfig(),
|
||||
)
|
||||
context._messages = messages
|
||||
|
||||
events = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Should work with custom agent implementation
|
||||
assert len(agent.messages_received) > 0
|
||||
|
||||
|
||||
async def test_initial_state_snapshot_with_array_schema() -> None:
|
||||
"""Test state initialization with array type schema."""
|
||||
from agent_framework import ChatMessage, TextContent
|
||||
|
||||
messages = [ChatMessage(role="user", contents=[TextContent(text="Hello")])]
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
input_data = {"messages": [], "state": {}}
|
||||
agent = MockAgent()
|
||||
context = ExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(state_schema={"items": {"type": "array"}}),
|
||||
)
|
||||
context._messages = messages
|
||||
|
||||
events = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Should emit state snapshot with empty array for items
|
||||
state_events = [e for e in events if e.type == "STATE_SNAPSHOT"]
|
||||
assert len(state_events) >= 1
|
||||
|
||||
|
||||
async def test_response_format_skip_text_content() -> None:
|
||||
"""Test that response_format causes skip_text_content to be set."""
|
||||
|
||||
class OutputModel(BaseModel):
|
||||
result: str
|
||||
|
||||
from agent_framework import ChatMessage, TextContent
|
||||
|
||||
messages = [ChatMessage(role="user", contents=[TextContent(text="Hello")])]
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
input_data = {"messages": []}
|
||||
|
||||
agent = MockAgent()
|
||||
agent.chat_options.response_format = OutputModel
|
||||
|
||||
context = ExecutionContext(
|
||||
input_data=input_data,
|
||||
agent=agent,
|
||||
config=AgentConfig(),
|
||||
)
|
||||
context._messages = messages
|
||||
|
||||
events = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
|
||||
# Test passes if no errors occur - verifies response_format code path
|
||||
assert len(events) > 0
|
||||
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251111"
|
||||
version = "1.0.0b251112"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import importlib.metadata
|
||||
|
||||
from ._chat_client import AzureAIAgentClient
|
||||
from ._client import AzureAIClient
|
||||
from ._shared import AzureAISettings
|
||||
|
||||
try:
|
||||
@@ -12,6 +13,7 @@ except importlib.metadata.PackageNotFoundError:
|
||||
|
||||
__all__ = [
|
||||
"AzureAIAgentClient",
|
||||
"AzureAIClient",
|
||||
"AzureAISettings",
|
||||
"__version__",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import sys
|
||||
from collections.abc import MutableSequence
|
||||
from typing import Any, ClassVar, TypeVar
|
||||
|
||||
from agent_framework import (
|
||||
AGENT_FRAMEWORK_USER_AGENT,
|
||||
ChatMessage,
|
||||
ChatOptions,
|
||||
HostedMCPTool,
|
||||
TextContent,
|
||||
get_logger,
|
||||
use_chat_middleware,
|
||||
use_function_invocation,
|
||||
)
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
from agent_framework.observability import use_observability
|
||||
from agent_framework.openai._responses_client import OpenAIBaseResponsesClient
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.ai.projects.models import (
|
||||
MCPTool,
|
||||
PromptAgentDefinition,
|
||||
PromptAgentDefinitionText,
|
||||
ResponseTextFormatConfigurationJsonSchema,
|
||||
)
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
from azure.core.exceptions import ResourceNotFoundError
|
||||
from openai.types.responses.parsed_response import (
|
||||
ParsedResponse,
|
||||
)
|
||||
from openai.types.responses.response import Response as OpenAIResponse
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from ._shared import AzureAISettings
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Self # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import Self # pragma: no cover
|
||||
|
||||
|
||||
logger = get_logger("agent_framework.azure")
|
||||
|
||||
|
||||
TAzureAIClient = TypeVar("TAzureAIClient", bound="AzureAIClient")
|
||||
|
||||
|
||||
@use_function_invocation
|
||||
@use_observability
|
||||
@use_chat_middleware
|
||||
class AzureAIClient(OpenAIBaseResponsesClient):
|
||||
"""Azure AI Agent client."""
|
||||
|
||||
OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai" # type: ignore[reportIncompatibleVariableOverride, misc]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
project_client: AIProjectClient | None = None,
|
||||
agent_name: str | None = None,
|
||||
agent_version: str | None = None,
|
||||
conversation_id: str | None = None,
|
||||
project_endpoint: str | None = None,
|
||||
model_deployment_name: str | None = None,
|
||||
async_credential: AsyncTokenCredential | None = None,
|
||||
use_latest_version: bool | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize an Azure AI Agent client.
|
||||
|
||||
Keyword Args:
|
||||
project_client: An existing AIProjectClient to use. If not provided, one will be created.
|
||||
agent_name: The name to use when creating new agents.
|
||||
agent_version: The version of the agent to use.
|
||||
conversation_id: Default conversation ID to use for conversations. Can be overridden by
|
||||
conversation_id property when making a request.
|
||||
project_endpoint: The Azure AI Project endpoint URL.
|
||||
Can also be set via environment variable AZURE_AI_PROJECT_ENDPOINT.
|
||||
Ignored when a project_client is passed.
|
||||
model_deployment_name: The model deployment name to use for agent creation.
|
||||
Can also be set via environment variable AZURE_AI_MODEL_DEPLOYMENT_NAME.
|
||||
async_credential: Azure async credential to use for authentication.
|
||||
use_latest_version: Boolean flag that indicates whether to use latest agent version
|
||||
if it exists in the service.
|
||||
env_file_path: Path to environment file for loading settings.
|
||||
env_file_encoding: Encoding of the environment file.
|
||||
kwargs: Additional keyword arguments passed to the parent class.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework.azure import AzureAIClient
|
||||
from azure.identity.aio import DefaultAzureCredential
|
||||
|
||||
# Using environment variables
|
||||
# Set AZURE_AI_PROJECT_ENDPOINT=https://your-project.cognitiveservices.azure.com
|
||||
# Set AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4
|
||||
credential = DefaultAzureCredential()
|
||||
client = AzureAIClient(async_credential=credential)
|
||||
|
||||
# Or passing parameters directly
|
||||
client = AzureAIClient(
|
||||
project_endpoint="https://your-project.cognitiveservices.azure.com",
|
||||
model_deployment_name="gpt-4",
|
||||
async_credential=credential,
|
||||
)
|
||||
|
||||
# Or loading from a .env file
|
||||
client = AzureAIClient(async_credential=credential, env_file_path="path/to/.env")
|
||||
"""
|
||||
try:
|
||||
azure_ai_settings = AzureAISettings(
|
||||
project_endpoint=project_endpoint,
|
||||
model_deployment_name=model_deployment_name,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
)
|
||||
except ValidationError as ex:
|
||||
raise ServiceInitializationError("Failed to create Azure AI settings.", ex) from ex
|
||||
|
||||
# If no project_client is provided, create one
|
||||
should_close_client = False
|
||||
if project_client is None:
|
||||
if not azure_ai_settings.project_endpoint:
|
||||
raise ServiceInitializationError(
|
||||
"Azure AI project endpoint is required. Set via 'project_endpoint' parameter "
|
||||
"or 'AZURE_AI_PROJECT_ENDPOINT' environment variable."
|
||||
)
|
||||
|
||||
# Use provided credential
|
||||
if not async_credential:
|
||||
raise ServiceInitializationError("Azure credential is required when project_client is not provided.")
|
||||
project_client = AIProjectClient(
|
||||
endpoint=azure_ai_settings.project_endpoint,
|
||||
credential=async_credential,
|
||||
user_agent=AGENT_FRAMEWORK_USER_AGENT,
|
||||
)
|
||||
should_close_client = True
|
||||
|
||||
# Initialize parent
|
||||
super().__init__(
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# Initialize instance variables
|
||||
self.agent_name = agent_name
|
||||
self.agent_version = agent_version
|
||||
self.use_latest_version = use_latest_version
|
||||
self.project_client = project_client
|
||||
self.credential = async_credential
|
||||
self.model_id = azure_ai_settings.model_deployment_name
|
||||
self.conversation_id = conversation_id
|
||||
self._should_close_client = should_close_client # Track whether we should close client connection
|
||||
|
||||
async def setup_azure_ai_observability(self, enable_sensitive_data: bool | None = None) -> None:
|
||||
"""Use this method to setup tracing in your Azure AI Project.
|
||||
|
||||
This will take the connection string from the project project_client.
|
||||
It will override any connection string that is set in the environment variables.
|
||||
It will disable any OTLP endpoint that might have been set.
|
||||
"""
|
||||
try:
|
||||
conn_string = await self.project_client.telemetry.get_application_insights_connection_string()
|
||||
except ResourceNotFoundError:
|
||||
logger.warning(
|
||||
"No Application Insights connection string found for the Azure AI Project, "
|
||||
"please call setup_observability() manually."
|
||||
)
|
||||
return
|
||||
from agent_framework.observability import setup_observability
|
||||
|
||||
setup_observability(
|
||||
applicationinsights_connection_string=conn_string, enable_sensitive_data=enable_sensitive_data
|
||||
)
|
||||
|
||||
async def __aenter__(self) -> "Self":
|
||||
"""Async context manager entry."""
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None:
|
||||
"""Async context manager exit."""
|
||||
await self.close()
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the project_client."""
|
||||
await self._close_client_if_needed()
|
||||
|
||||
async def _get_agent_reference_or_create(
|
||||
self, run_options: dict[str, Any], messages_instructions: str | None
|
||||
) -> dict[str, str]:
|
||||
"""Determine which agent to use and create if needed.
|
||||
|
||||
Returns:
|
||||
str: The agent_name to use
|
||||
"""
|
||||
agent_name = self.agent_name or "UnnamedAgent"
|
||||
|
||||
# If no agent_version is provided, either use latest version or create a new agent:
|
||||
if self.agent_version is None:
|
||||
# Try to use latest version if requested and agent exists
|
||||
if self.use_latest_version:
|
||||
try:
|
||||
existing_agent = await self.project_client.agents.get(agent_name)
|
||||
self.agent_name = existing_agent.name
|
||||
self.agent_version = existing_agent.versions.latest.version
|
||||
return {"name": self.agent_name, "version": self.agent_version, "type": "agent_reference"}
|
||||
except ResourceNotFoundError:
|
||||
# Agent doesn't exist, fall through to creation logic
|
||||
pass
|
||||
|
||||
if "model" not in run_options or not run_options["model"]:
|
||||
raise ServiceInitializationError(
|
||||
"Model deployment name is required for agent creation, "
|
||||
"can also be passed to the get_response methods."
|
||||
)
|
||||
|
||||
args: dict[str, Any] = {"model": run_options["model"]}
|
||||
|
||||
if "tools" in run_options:
|
||||
args["tools"] = run_options["tools"]
|
||||
|
||||
if "response_format" in run_options:
|
||||
response_format = run_options["response_format"]
|
||||
args["text"] = PromptAgentDefinitionText(
|
||||
format=ResponseTextFormatConfigurationJsonSchema(
|
||||
name=response_format.__name__,
|
||||
schema=response_format.model_json_schema(),
|
||||
)
|
||||
)
|
||||
|
||||
# Combine instructions from messages and options
|
||||
combined_instructions = [
|
||||
instructions
|
||||
for instructions in [messages_instructions, run_options.get("instructions")]
|
||||
if instructions
|
||||
]
|
||||
if combined_instructions:
|
||||
args["instructions"] = "".join(combined_instructions)
|
||||
|
||||
created_agent = await self.project_client.agents.create_version(
|
||||
agent_name=agent_name, definition=PromptAgentDefinition(**args)
|
||||
)
|
||||
|
||||
self.agent_name = created_agent.name
|
||||
self.agent_version = created_agent.version
|
||||
|
||||
return {"name": agent_name, "version": self.agent_version, "type": "agent_reference"}
|
||||
|
||||
async def _close_client_if_needed(self) -> None:
|
||||
"""Close project_client session if we created it."""
|
||||
if self._should_close_client:
|
||||
await self.project_client.close()
|
||||
|
||||
def _prepare_input(self, messages: MutableSequence[ChatMessage]) -> tuple[list[ChatMessage], str | None]:
|
||||
"""Prepare input from messages and convert system/developer messages to instructions."""
|
||||
result: list[ChatMessage] = []
|
||||
instructions_list: list[str] = []
|
||||
instructions: str | None = None
|
||||
|
||||
# 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)
|
||||
else:
|
||||
result.append(message)
|
||||
|
||||
if len(instructions_list) > 0:
|
||||
instructions = "".join(instructions_list)
|
||||
|
||||
return result, instructions
|
||||
|
||||
async def prepare_options(
|
||||
self, messages: MutableSequence[ChatMessage], chat_options: ChatOptions
|
||||
) -> dict[str, Any]:
|
||||
chat_options.store = bool(chat_options.store or chat_options.store is None)
|
||||
prepared_messages, instructions = self._prepare_input(messages)
|
||||
run_options = await super().prepare_options(prepared_messages, chat_options)
|
||||
agent_reference = await self._get_agent_reference_or_create(run_options, instructions)
|
||||
|
||||
run_options["extra_body"] = {"agent": agent_reference}
|
||||
|
||||
conversation_id = chat_options.conversation_id or self.conversation_id
|
||||
|
||||
# Handle different conversation ID formats
|
||||
if conversation_id:
|
||||
if conversation_id.startswith("resp_"):
|
||||
# For response IDs, set previous_response_id and remove conversation property
|
||||
run_options.pop("conversation", None)
|
||||
run_options["previous_response_id"] = conversation_id
|
||||
elif conversation_id.startswith("conv_"):
|
||||
# For conversation IDs, set conversation and remove previous_response_id property
|
||||
run_options.pop("previous_response_id", None)
|
||||
run_options["conversation"] = conversation_id
|
||||
|
||||
# Remove properties that are not supported on request level
|
||||
# but were configured on agent level
|
||||
exclude = ["model", "tools", "response_format"]
|
||||
|
||||
for property in exclude:
|
||||
run_options.pop(property, None)
|
||||
|
||||
return run_options
|
||||
|
||||
async def initialize_client(self) -> None:
|
||||
"""Initialize OpenAI client asynchronously."""
|
||||
self.client = await self.project_client.get_openai_client() # type: ignore
|
||||
|
||||
def _update_agent_name(self, agent_name: str | None) -> None:
|
||||
"""Update the agent name in the chat client.
|
||||
|
||||
Args:
|
||||
agent_name: The new name for the agent.
|
||||
"""
|
||||
# This is a no-op in the base class, but can be overridden by subclasses
|
||||
# to update the agent name in the client.
|
||||
if agent_name and not self.agent_name:
|
||||
self.agent_name = agent_name
|
||||
|
||||
def get_mcp_tool(self, tool: HostedMCPTool) -> Any:
|
||||
"""Get MCP tool from HostedMCPTool."""
|
||||
mcp = MCPTool(server_label=tool.name.replace(" ", "_"), server_url=str(tool.url))
|
||||
|
||||
if tool.allowed_tools:
|
||||
mcp["allowed_tools"] = list(tool.allowed_tools)
|
||||
|
||||
if tool.approval_mode:
|
||||
match tool.approval_mode:
|
||||
case str():
|
||||
mcp["require_approval"] = "always" if tool.approval_mode == "always_require" else "never"
|
||||
case _:
|
||||
if always_require_approvals := tool.approval_mode.get("always_require_approval"):
|
||||
mcp["require_approval"] = {"always": {"tool_names": list(always_require_approvals)}}
|
||||
if never_require_approvals := tool.approval_mode.get("never_require_approval"):
|
||||
mcp["require_approval"] = {"never": {"tool_names": list(never_require_approvals)}}
|
||||
|
||||
return mcp
|
||||
|
||||
def get_conversation_id(
|
||||
self, response: OpenAIResponse | ParsedResponse[BaseModel], store: bool | None
|
||||
) -> str | None:
|
||||
"""Get the conversation ID from the response if store is True."""
|
||||
if store:
|
||||
# If conversation ID exists, it means that we operate with conversation
|
||||
# so we use conversation ID as input and output.
|
||||
if response.conversation and response.conversation.id:
|
||||
return response.conversation.id
|
||||
# If conversation ID doesn't exist, we operate with responses
|
||||
# so we use response ID as input and output.
|
||||
return response.id
|
||||
return None
|
||||
@@ -4,7 +4,7 @@ description = "Azure AI Foundry integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251111"
|
||||
version = "1.0.0b251112"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -24,7 +24,7 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core",
|
||||
"azure-ai-projects >= 1.0.0b11",
|
||||
"azure-ai-projects >= 2.0.0b1",
|
||||
"azure-ai-agents == 1.2.0b5",
|
||||
"aiohttp",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,743 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import (
|
||||
ChatClientProtocol,
|
||||
ChatMessage,
|
||||
ChatOptions,
|
||||
Role,
|
||||
TextContent,
|
||||
)
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
from azure.ai.projects.models import (
|
||||
ResponseTextFormatConfigurationJsonSchema,
|
||||
)
|
||||
from openai.types.responses.parsed_response import ParsedResponse
|
||||
from openai.types.responses.response import Response as OpenAIResponse
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError
|
||||
|
||||
from agent_framework_azure_ai import AzureAIClient, AzureAISettings
|
||||
|
||||
|
||||
def create_test_azure_ai_client(
|
||||
mock_project_client: MagicMock,
|
||||
agent_name: str | None = None,
|
||||
agent_version: str | None = None,
|
||||
conversation_id: str | None = None,
|
||||
azure_ai_settings: AzureAISettings | None = None,
|
||||
should_close_client: bool = False,
|
||||
use_latest_version: bool | None = None,
|
||||
) -> AzureAIClient:
|
||||
"""Helper function to create AzureAIClient instances for testing, bypassing normal validation."""
|
||||
if azure_ai_settings is None:
|
||||
azure_ai_settings = AzureAISettings(env_file_path="test.env")
|
||||
|
||||
# Create client instance directly
|
||||
client = object.__new__(AzureAIClient)
|
||||
|
||||
# Set attributes directly
|
||||
client.project_client = mock_project_client
|
||||
client.credential = None
|
||||
client.agent_name = agent_name
|
||||
client.agent_version = agent_version
|
||||
client.use_latest_version = use_latest_version
|
||||
client.model_id = azure_ai_settings.model_deployment_name
|
||||
client.conversation_id = conversation_id
|
||||
client._should_close_client = should_close_client # type: ignore
|
||||
client.additional_properties = {}
|
||||
client.middleware = None
|
||||
|
||||
# Mock the OpenAI client attribute
|
||||
mock_openai_client = MagicMock()
|
||||
mock_openai_client.conversations = MagicMock()
|
||||
mock_openai_client.conversations.create = AsyncMock()
|
||||
client.client = mock_openai_client
|
||||
|
||||
return client
|
||||
|
||||
|
||||
def test_azure_ai_settings_init(azure_ai_unit_test_env: dict[str, str]) -> None:
|
||||
"""Test AzureAISettings initialization."""
|
||||
settings = AzureAISettings()
|
||||
|
||||
assert settings.project_endpoint == azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"]
|
||||
assert settings.model_deployment_name == azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"]
|
||||
|
||||
|
||||
def test_azure_ai_settings_init_with_explicit_values() -> None:
|
||||
"""Test AzureAISettings initialization with explicit values."""
|
||||
settings = AzureAISettings(
|
||||
project_endpoint="https://custom-endpoint.com/",
|
||||
model_deployment_name="custom-model",
|
||||
)
|
||||
|
||||
assert settings.project_endpoint == "https://custom-endpoint.com/"
|
||||
assert settings.model_deployment_name == "custom-model"
|
||||
|
||||
|
||||
def test_azure_ai_client_init_with_project_client(mock_project_client: MagicMock) -> None:
|
||||
"""Test AzureAIClient initialization with existing project_client."""
|
||||
with patch("agent_framework_azure_ai._client.AzureAISettings") as mock_settings:
|
||||
mock_settings.return_value.project_endpoint = None
|
||||
mock_settings.return_value.model_deployment_name = "test-model"
|
||||
|
||||
client = AzureAIClient(
|
||||
project_client=mock_project_client,
|
||||
agent_name="test-agent",
|
||||
agent_version="1.0",
|
||||
)
|
||||
|
||||
assert client.project_client is mock_project_client
|
||||
assert client.agent_name == "test-agent"
|
||||
assert client.agent_version == "1.0"
|
||||
assert not client._should_close_client # type: ignore
|
||||
assert isinstance(client, ChatClientProtocol)
|
||||
|
||||
|
||||
def test_azure_ai_client_init_auto_create_client(
|
||||
azure_ai_unit_test_env: dict[str, str],
|
||||
mock_azure_credential: MagicMock,
|
||||
) -> None:
|
||||
"""Test AzureAIClient initialization with auto-created project_client."""
|
||||
with patch("agent_framework_azure_ai._client.AIProjectClient") as mock_ai_project_client:
|
||||
mock_project_client = MagicMock()
|
||||
mock_ai_project_client.return_value = mock_project_client
|
||||
|
||||
client = AzureAIClient(
|
||||
project_endpoint=azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"],
|
||||
model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
|
||||
async_credential=mock_azure_credential,
|
||||
agent_name="test-agent",
|
||||
)
|
||||
|
||||
assert client.project_client is mock_project_client
|
||||
assert client.agent_name == "test-agent"
|
||||
assert client._should_close_client # type: ignore
|
||||
|
||||
# Verify AIProjectClient was called with correct parameters
|
||||
mock_ai_project_client.assert_called_once()
|
||||
|
||||
|
||||
def test_azure_ai_client_init_missing_project_endpoint() -> None:
|
||||
"""Test AzureAIClient initialization when project_endpoint is missing and no project_client provided."""
|
||||
with patch("agent_framework_azure_ai._client.AzureAISettings") as mock_settings:
|
||||
mock_settings.return_value.project_endpoint = None
|
||||
mock_settings.return_value.model_deployment_name = "test-model"
|
||||
|
||||
with pytest.raises(ServiceInitializationError, match="Azure AI project endpoint is required"):
|
||||
AzureAIClient(async_credential=MagicMock())
|
||||
|
||||
|
||||
def test_azure_ai_client_init_missing_credential(azure_ai_unit_test_env: dict[str, str]) -> None:
|
||||
"""Test AzureAIClient.__init__ when async_credential is missing and no project_client provided."""
|
||||
with pytest.raises(
|
||||
ServiceInitializationError, match="Azure credential is required when project_client is not provided"
|
||||
):
|
||||
AzureAIClient(
|
||||
project_endpoint=azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"],
|
||||
model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
|
||||
)
|
||||
|
||||
|
||||
def test_azure_ai_client_init_validation_error(mock_azure_credential: MagicMock) -> None:
|
||||
"""Test that ValidationError in AzureAISettings is properly handled."""
|
||||
with patch("agent_framework_azure_ai._client.AzureAISettings") as mock_settings:
|
||||
mock_settings.side_effect = ValidationError.from_exception_data("test", [])
|
||||
|
||||
with pytest.raises(ServiceInitializationError, match="Failed to create Azure AI settings"):
|
||||
AzureAIClient(async_credential=mock_azure_credential)
|
||||
|
||||
|
||||
async def test_azure_ai_client_get_agent_reference_or_create_existing_version(
|
||||
mock_project_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test _get_agent_reference_or_create when agent_version is already provided."""
|
||||
client = create_test_azure_ai_client(mock_project_client, agent_name="existing-agent", agent_version="1.0")
|
||||
|
||||
agent_ref = await client._get_agent_reference_or_create({}, None) # type: ignore
|
||||
|
||||
assert agent_ref == {"name": "existing-agent", "version": "1.0", "type": "agent_reference"}
|
||||
|
||||
|
||||
async def test_azure_ai_client_get_agent_reference_or_create_new_agent(
|
||||
mock_project_client: MagicMock,
|
||||
azure_ai_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test _get_agent_reference_or_create when creating a new agent."""
|
||||
azure_ai_settings = AzureAISettings(model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"])
|
||||
client = create_test_azure_ai_client(
|
||||
mock_project_client, agent_name="new-agent", azure_ai_settings=azure_ai_settings
|
||||
)
|
||||
|
||||
# Mock agent creation response
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.name = "new-agent"
|
||||
mock_agent.version = "1.0"
|
||||
mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent)
|
||||
|
||||
run_options = {"model": azure_ai_settings.model_deployment_name}
|
||||
agent_ref = await client._get_agent_reference_or_create(run_options, None) # type: ignore
|
||||
|
||||
assert agent_ref == {"name": "new-agent", "version": "1.0", "type": "agent_reference"}
|
||||
assert client.agent_name == "new-agent"
|
||||
assert client.agent_version == "1.0"
|
||||
|
||||
|
||||
async def test_azure_ai_client_get_agent_reference_missing_model(
|
||||
mock_project_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test _get_agent_reference_or_create when model is missing for agent creation."""
|
||||
client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent")
|
||||
|
||||
with pytest.raises(ServiceInitializationError, match="Model deployment name is required for agent creation"):
|
||||
await client._get_agent_reference_or_create({}, None) # type: ignore
|
||||
|
||||
|
||||
async def test_azure_ai_client_prepare_input_with_system_messages(
|
||||
mock_project_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test _prepare_input converts system/developer messages to instructions."""
|
||||
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")]),
|
||||
]
|
||||
|
||||
result_messages, instructions = client._prepare_input(messages) # type: ignore
|
||||
|
||||
assert len(result_messages) == 2
|
||||
assert result_messages[0].role == Role.USER
|
||||
assert result_messages[1].role == Role.ASSISTANT
|
||||
assert instructions == "You are a helpful assistant."
|
||||
|
||||
|
||||
async def test_azure_ai_client_prepare_input_no_system_messages(
|
||||
mock_project_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test _prepare_input with no system/developer 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!")]),
|
||||
]
|
||||
|
||||
result_messages, instructions = client._prepare_input(messages) # type: ignore
|
||||
|
||||
assert len(result_messages) == 2
|
||||
assert instructions is None
|
||||
|
||||
|
||||
async def test_azure_ai_client_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")])]
|
||||
chat_options = ChatOptions()
|
||||
|
||||
with (
|
||||
patch.object(client.__class__.__bases__[0], "prepare_options", return_value={"model": "test-model"}),
|
||||
patch.object(
|
||||
client,
|
||||
"_get_agent_reference_or_create",
|
||||
return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"},
|
||||
),
|
||||
):
|
||||
run_options = await client.prepare_options(messages, chat_options)
|
||||
|
||||
assert "extra_body" in run_options
|
||||
assert run_options["extra_body"]["agent"]["name"] == "test-agent"
|
||||
|
||||
|
||||
async def test_azure_ai_client_initialize_client(mock_project_client: MagicMock) -> None:
|
||||
"""Test initialize_client method."""
|
||||
client = create_test_azure_ai_client(mock_project_client)
|
||||
|
||||
mock_openai_client = MagicMock()
|
||||
mock_project_client.get_openai_client = AsyncMock(return_value=mock_openai_client)
|
||||
|
||||
await client.initialize_client()
|
||||
|
||||
assert client.client is mock_openai_client
|
||||
mock_project_client.get_openai_client.assert_called_once()
|
||||
|
||||
|
||||
def test_azure_ai_client_update_agent_name(mock_project_client: MagicMock) -> None:
|
||||
"""Test _update_agent_name method."""
|
||||
client = create_test_azure_ai_client(mock_project_client)
|
||||
|
||||
# Test updating agent name when current is None
|
||||
with patch.object(client, "_update_agent_name") as mock_update:
|
||||
mock_update.return_value = None
|
||||
client._update_agent_name("new-agent") # type: ignore
|
||||
mock_update.assert_called_once_with("new-agent")
|
||||
|
||||
# Test behavior when agent name is updated
|
||||
assert client.agent_name is None # Should remain None since we didn't actually update
|
||||
client.agent_name = "test-agent" # Manually set for the test
|
||||
|
||||
# Test with None input
|
||||
with patch.object(client, "_update_agent_name") as mock_update:
|
||||
mock_update.return_value = None
|
||||
client._update_agent_name(None) # type: ignore
|
||||
mock_update.assert_called_once_with(None)
|
||||
|
||||
|
||||
async def test_azure_ai_client_async_context_manager(mock_project_client: MagicMock) -> None:
|
||||
"""Test async context manager functionality."""
|
||||
client = create_test_azure_ai_client(mock_project_client, should_close_client=True)
|
||||
|
||||
mock_project_client.close = AsyncMock()
|
||||
|
||||
async with client as ctx_client:
|
||||
assert ctx_client is client
|
||||
|
||||
# Should call close after exiting context
|
||||
mock_project_client.close.assert_called_once()
|
||||
|
||||
|
||||
async def test_azure_ai_client_close_method(mock_project_client: MagicMock) -> None:
|
||||
"""Test close method."""
|
||||
client = create_test_azure_ai_client(mock_project_client, should_close_client=True)
|
||||
|
||||
mock_project_client.close = AsyncMock()
|
||||
|
||||
await client.close()
|
||||
|
||||
mock_project_client.close.assert_called_once()
|
||||
|
||||
|
||||
async def test_azure_ai_client_close_client_when_should_close_false(mock_project_client: MagicMock) -> None:
|
||||
"""Test _close_client_if_needed when should_close_client is False."""
|
||||
client = create_test_azure_ai_client(mock_project_client, should_close_client=False)
|
||||
|
||||
mock_project_client.close = AsyncMock()
|
||||
|
||||
await client._close_client_if_needed() # type: ignore
|
||||
|
||||
# Should not call close when should_close_client is False
|
||||
mock_project_client.close.assert_not_called()
|
||||
|
||||
|
||||
async def test_azure_ai_client_agent_creation_with_instructions(
|
||||
mock_project_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test agent creation with combined instructions."""
|
||||
client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent")
|
||||
|
||||
# Mock agent creation response
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.name = "test-agent"
|
||||
mock_agent.version = "1.0"
|
||||
mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent)
|
||||
|
||||
run_options = {"model": "test-model", "instructions": "Option instructions. "}
|
||||
messages_instructions = "Message instructions. "
|
||||
|
||||
await client._get_agent_reference_or_create(run_options, messages_instructions) # type: ignore
|
||||
|
||||
# Verify agent was created with combined instructions
|
||||
call_args = mock_project_client.agents.create_version.call_args
|
||||
assert call_args[1]["definition"].instructions == "Message instructions. Option instructions. "
|
||||
|
||||
|
||||
async def test_azure_ai_client_agent_creation_with_tools(
|
||||
mock_project_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test agent creation with tools."""
|
||||
client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent")
|
||||
|
||||
# Mock agent creation response
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.name = "test-agent"
|
||||
mock_agent.version = "1.0"
|
||||
mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent)
|
||||
|
||||
test_tools = [{"type": "function", "function": {"name": "test_tool"}}]
|
||||
run_options = {"model": "test-model", "tools": test_tools}
|
||||
|
||||
await client._get_agent_reference_or_create(run_options, None) # type: ignore
|
||||
|
||||
# Verify agent was created with tools
|
||||
call_args = mock_project_client.agents.create_version.call_args
|
||||
assert call_args[1]["definition"].tools == test_tools
|
||||
|
||||
|
||||
async def test_azure_ai_client_use_latest_version_existing_agent(
|
||||
mock_project_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test _get_agent_reference_or_create when use_latest_version=True and agent exists."""
|
||||
client = create_test_azure_ai_client(mock_project_client, agent_name="existing-agent", use_latest_version=True)
|
||||
|
||||
# Mock existing agent response
|
||||
mock_existing_agent = MagicMock()
|
||||
mock_existing_agent.name = "existing-agent"
|
||||
mock_existing_agent.versions.latest.version = "2.5"
|
||||
mock_project_client.agents.get = AsyncMock(return_value=mock_existing_agent)
|
||||
|
||||
run_options = {"model": "test-model"}
|
||||
agent_ref = await client._get_agent_reference_or_create(run_options, None) # type: ignore
|
||||
|
||||
# Verify existing agent was retrieved and used
|
||||
mock_project_client.agents.get.assert_called_once_with("existing-agent")
|
||||
mock_project_client.agents.create_version.assert_not_called()
|
||||
|
||||
assert agent_ref == {"name": "existing-agent", "version": "2.5", "type": "agent_reference"}
|
||||
assert client.agent_name == "existing-agent"
|
||||
assert client.agent_version == "2.5"
|
||||
|
||||
|
||||
async def test_azure_ai_client_use_latest_version_agent_not_found(
|
||||
mock_project_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test _get_agent_reference_or_create when use_latest_version=True but agent doesn't exist."""
|
||||
from azure.core.exceptions import ResourceNotFoundError
|
||||
|
||||
client = create_test_azure_ai_client(mock_project_client, agent_name="non-existing-agent", use_latest_version=True)
|
||||
|
||||
# Mock ResourceNotFoundError when trying to retrieve agent
|
||||
mock_project_client.agents.get = AsyncMock(side_effect=ResourceNotFoundError("Agent not found"))
|
||||
|
||||
# Mock agent creation response for fallback
|
||||
mock_created_agent = MagicMock()
|
||||
mock_created_agent.name = "non-existing-agent"
|
||||
mock_created_agent.version = "1.0"
|
||||
mock_project_client.agents.create_version = AsyncMock(return_value=mock_created_agent)
|
||||
|
||||
run_options = {"model": "test-model"}
|
||||
agent_ref = await client._get_agent_reference_or_create(run_options, None) # type: ignore
|
||||
|
||||
# Verify retrieval was attempted and creation was used as fallback
|
||||
mock_project_client.agents.get.assert_called_once_with("non-existing-agent")
|
||||
mock_project_client.agents.create_version.assert_called_once()
|
||||
|
||||
assert agent_ref == {"name": "non-existing-agent", "version": "1.0", "type": "agent_reference"}
|
||||
assert client.agent_name == "non-existing-agent"
|
||||
assert client.agent_version == "1.0"
|
||||
|
||||
|
||||
async def test_azure_ai_client_use_latest_version_false(
|
||||
mock_project_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test _get_agent_reference_or_create when use_latest_version=False (default behavior)."""
|
||||
client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", use_latest_version=False)
|
||||
|
||||
# Mock agent creation response
|
||||
mock_created_agent = MagicMock()
|
||||
mock_created_agent.name = "test-agent"
|
||||
mock_created_agent.version = "1.0"
|
||||
mock_project_client.agents.create_version = AsyncMock(return_value=mock_created_agent)
|
||||
|
||||
run_options = {"model": "test-model"}
|
||||
agent_ref = await client._get_agent_reference_or_create(run_options, None) # type: ignore
|
||||
|
||||
# Verify retrieval was not attempted and creation was used directly
|
||||
mock_project_client.agents.get.assert_not_called()
|
||||
mock_project_client.agents.create_version.assert_called_once()
|
||||
|
||||
assert agent_ref == {"name": "test-agent", "version": "1.0", "type": "agent_reference"}
|
||||
|
||||
|
||||
async def test_azure_ai_client_use_latest_version_with_existing_agent_version(
|
||||
mock_project_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test that use_latest_version is ignored when agent_version is already provided."""
|
||||
client = create_test_azure_ai_client(
|
||||
mock_project_client, agent_name="test-agent", agent_version="3.0", use_latest_version=True
|
||||
)
|
||||
|
||||
agent_ref = await client._get_agent_reference_or_create({}, None) # type: ignore
|
||||
|
||||
# Verify neither retrieval nor creation was attempted since version is already set
|
||||
mock_project_client.agents.get.assert_not_called()
|
||||
mock_project_client.agents.create_version.assert_not_called()
|
||||
|
||||
assert agent_ref == {"name": "test-agent", "version": "3.0", "type": "agent_reference"}
|
||||
|
||||
|
||||
class ResponseFormatModel(BaseModel):
|
||||
"""Test Pydantic model for response format testing."""
|
||||
|
||||
name: str
|
||||
value: int
|
||||
description: str
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
async def test_azure_ai_client_agent_creation_with_response_format(
|
||||
mock_project_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test agent creation with response_format configuration."""
|
||||
client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent")
|
||||
|
||||
# Mock agent creation response
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.name = "test-agent"
|
||||
mock_agent.version = "1.0"
|
||||
mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent)
|
||||
|
||||
run_options = {"model": "test-model", "response_format": ResponseFormatModel}
|
||||
|
||||
await client._get_agent_reference_or_create(run_options, None) # type: ignore
|
||||
|
||||
# Verify agent was created with response format configuration
|
||||
call_args = mock_project_client.agents.create_version.call_args
|
||||
created_definition = call_args[1]["definition"]
|
||||
|
||||
# Check that text format configuration was set
|
||||
assert hasattr(created_definition, "text")
|
||||
assert created_definition.text is not None
|
||||
|
||||
# Check that the format is a ResponseTextFormatConfigurationJsonSchema
|
||||
assert hasattr(created_definition.text, "format")
|
||||
format_config = created_definition.text.format
|
||||
assert isinstance(format_config, ResponseTextFormatConfigurationJsonSchema)
|
||||
|
||||
# Check the schema name matches the model class name
|
||||
assert format_config.name == "ResponseFormatModel"
|
||||
|
||||
# Check that schema was generated correctly
|
||||
assert format_config.schema is not None
|
||||
schema = format_config.schema
|
||||
assert "properties" in schema
|
||||
assert "name" in schema["properties"]
|
||||
assert "value" in schema["properties"]
|
||||
assert "description" in schema["properties"]
|
||||
|
||||
|
||||
async def test_azure_ai_client_prepare_options_excludes_response_format(
|
||||
mock_project_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test that prepare_options excludes response_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")])]
|
||||
chat_options = ChatOptions()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
client.__class__.__bases__[0],
|
||||
"prepare_options",
|
||||
return_value={"model": "test-model", "response_format": ResponseFormatModel},
|
||||
),
|
||||
patch.object(
|
||||
client,
|
||||
"_get_agent_reference_or_create",
|
||||
return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"},
|
||||
),
|
||||
):
|
||||
run_options = await client.prepare_options(messages, chat_options)
|
||||
|
||||
# response_format should be excluded from final run options
|
||||
assert "response_format" not in run_options
|
||||
# But extra_body should contain agent reference
|
||||
assert "extra_body" in run_options
|
||||
assert run_options["extra_body"]["agent"]["name"] == "test-agent"
|
||||
|
||||
|
||||
async def test_azure_ai_client_prepare_options_with_resp_conversation_id(
|
||||
mock_project_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test prepare_options with conversation ID starting with 'resp_'."""
|
||||
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")])]
|
||||
chat_options = ChatOptions(conversation_id="resp_12345")
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
client.__class__.__bases__[0],
|
||||
"prepare_options",
|
||||
return_value={"model": "test-model", "previous_response_id": "old_value", "conversation": "old_conv"},
|
||||
),
|
||||
patch.object(
|
||||
client,
|
||||
"_get_agent_reference_or_create",
|
||||
return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"},
|
||||
),
|
||||
):
|
||||
run_options = await client.prepare_options(messages, chat_options)
|
||||
|
||||
# Should set previous_response_id and remove conversation property
|
||||
assert run_options["previous_response_id"] == "resp_12345"
|
||||
assert "conversation" not in run_options
|
||||
|
||||
|
||||
async def test_azure_ai_client_prepare_options_with_conv_conversation_id(
|
||||
mock_project_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test prepare_options with conversation ID starting with 'conv_'."""
|
||||
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")])]
|
||||
chat_options = ChatOptions(conversation_id="conv_67890")
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
client.__class__.__bases__[0],
|
||||
"prepare_options",
|
||||
return_value={"model": "test-model", "previous_response_id": "old_value", "conversation": "old_conv"},
|
||||
),
|
||||
patch.object(
|
||||
client,
|
||||
"_get_agent_reference_or_create",
|
||||
return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"},
|
||||
),
|
||||
):
|
||||
run_options = await client.prepare_options(messages, chat_options)
|
||||
|
||||
# Should set conversation and remove previous_response_id property
|
||||
assert run_options["conversation"] == "conv_67890"
|
||||
assert "previous_response_id" not in run_options
|
||||
|
||||
|
||||
async def test_azure_ai_client_prepare_options_with_client_conversation_id(
|
||||
mock_project_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test prepare_options using client's default conversation ID when chat options don't have one."""
|
||||
client = create_test_azure_ai_client(
|
||||
mock_project_client, agent_name="test-agent", agent_version="1.0", conversation_id="resp_client_default"
|
||||
)
|
||||
|
||||
messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])]
|
||||
chat_options = ChatOptions() # No conversation_id specified
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
client.__class__.__bases__[0],
|
||||
"prepare_options",
|
||||
return_value={"model": "test-model", "previous_response_id": "old_value", "conversation": "old_conv"},
|
||||
),
|
||||
patch.object(
|
||||
client,
|
||||
"_get_agent_reference_or_create",
|
||||
return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"},
|
||||
),
|
||||
):
|
||||
run_options = await client.prepare_options(messages, chat_options)
|
||||
|
||||
# Should use client's default conversation_id and set previous_response_id
|
||||
assert run_options["previous_response_id"] == "resp_client_default"
|
||||
assert "conversation" not in run_options
|
||||
|
||||
|
||||
def test_get_conversation_id_with_store_true_and_conversation_id() -> None:
|
||||
"""Test get_conversation_id returns conversation ID when store is True and conversation exists."""
|
||||
client = create_test_azure_ai_client(MagicMock())
|
||||
|
||||
# Mock OpenAI response with conversation
|
||||
mock_response = MagicMock(spec=OpenAIResponse)
|
||||
mock_response.id = "resp_12345"
|
||||
mock_conversation = MagicMock()
|
||||
mock_conversation.id = "conv_67890"
|
||||
mock_response.conversation = mock_conversation
|
||||
|
||||
result = client.get_conversation_id(mock_response, store=True)
|
||||
|
||||
assert result == "conv_67890"
|
||||
|
||||
|
||||
def test_get_conversation_id_with_store_true_and_no_conversation() -> None:
|
||||
"""Test get_conversation_id returns response ID when store is True and no conversation exists."""
|
||||
client = create_test_azure_ai_client(MagicMock())
|
||||
|
||||
# Mock OpenAI response without conversation
|
||||
mock_response = MagicMock(spec=OpenAIResponse)
|
||||
mock_response.id = "resp_12345"
|
||||
mock_response.conversation = None
|
||||
|
||||
result = client.get_conversation_id(mock_response, store=True)
|
||||
|
||||
assert result == "resp_12345"
|
||||
|
||||
|
||||
def test_get_conversation_id_with_store_true_and_empty_conversation_id() -> None:
|
||||
"""Test get_conversation_id returns response ID when store is True and conversation ID is empty."""
|
||||
client = create_test_azure_ai_client(MagicMock())
|
||||
|
||||
# Mock OpenAI response with conversation but empty ID
|
||||
mock_response = MagicMock(spec=OpenAIResponse)
|
||||
mock_response.id = "resp_12345"
|
||||
mock_conversation = MagicMock()
|
||||
mock_conversation.id = ""
|
||||
mock_response.conversation = mock_conversation
|
||||
|
||||
result = client.get_conversation_id(mock_response, store=True)
|
||||
|
||||
assert result == "resp_12345"
|
||||
|
||||
|
||||
def test_get_conversation_id_with_store_false() -> None:
|
||||
"""Test get_conversation_id returns None when store is False."""
|
||||
client = create_test_azure_ai_client(MagicMock())
|
||||
|
||||
# Mock OpenAI response with conversation
|
||||
mock_response = MagicMock(spec=OpenAIResponse)
|
||||
mock_response.id = "resp_12345"
|
||||
mock_conversation = MagicMock()
|
||||
mock_conversation.id = "conv_67890"
|
||||
mock_response.conversation = mock_conversation
|
||||
|
||||
result = client.get_conversation_id(mock_response, store=False)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_get_conversation_id_with_parsed_response_and_store_true() -> None:
|
||||
"""Test get_conversation_id works with ParsedResponse when store is True."""
|
||||
client = create_test_azure_ai_client(MagicMock())
|
||||
|
||||
# Mock ParsedResponse with conversation
|
||||
mock_response = MagicMock(spec=ParsedResponse[BaseModel])
|
||||
mock_response.id = "resp_parsed_12345"
|
||||
mock_conversation = MagicMock()
|
||||
mock_conversation.id = "conv_parsed_67890"
|
||||
mock_response.conversation = mock_conversation
|
||||
|
||||
result = client.get_conversation_id(mock_response, store=True)
|
||||
|
||||
assert result == "conv_parsed_67890"
|
||||
|
||||
|
||||
def test_get_conversation_id_with_parsed_response_no_conversation() -> None:
|
||||
"""Test get_conversation_id returns response ID with ParsedResponse when no conversation exists."""
|
||||
client = create_test_azure_ai_client(MagicMock())
|
||||
|
||||
# Mock ParsedResponse without conversation
|
||||
mock_response = MagicMock(spec=ParsedResponse[BaseModel])
|
||||
mock_response.id = "resp_parsed_12345"
|
||||
mock_response.conversation = None
|
||||
|
||||
result = client.get_conversation_id(mock_response, store=True)
|
||||
|
||||
assert result == "resp_parsed_12345"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_project_client() -> MagicMock:
|
||||
"""Fixture that provides a mock AIProjectClient."""
|
||||
mock_client = MagicMock()
|
||||
|
||||
# Mock agents property
|
||||
mock_client.agents = MagicMock()
|
||||
mock_client.agents.create_version = AsyncMock()
|
||||
|
||||
# Mock conversations property
|
||||
mock_client.conversations = MagicMock()
|
||||
mock_client.conversations.create = AsyncMock()
|
||||
|
||||
# Mock telemetry property
|
||||
mock_client.telemetry = MagicMock()
|
||||
mock_client.telemetry.get_application_insights_connection_string = AsyncMock()
|
||||
|
||||
# Mock get_openai_client method
|
||||
mock_client.get_openai_client = AsyncMock()
|
||||
|
||||
# Mock close method
|
||||
mock_client.close = AsyncMock()
|
||||
|
||||
return mock_client
|
||||
@@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251111"
|
||||
version = "1.0.0b251112"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251111"
|
||||
version = "1.0.0b251112"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -564,10 +564,6 @@ class BaseChatClient(SerializationMixin, ABC):
|
||||
|
||||
# Validate that store is True when conversation_id is set
|
||||
if chat_options.conversation_id is not None and chat_options.store is not True:
|
||||
logger.warning(
|
||||
"When conversation_id is set, store must be True for service-managed threads. "
|
||||
"Automatically setting store=True."
|
||||
)
|
||||
chat_options.store = True
|
||||
|
||||
if chat_options.instructions:
|
||||
@@ -663,10 +659,6 @@ class BaseChatClient(SerializationMixin, ABC):
|
||||
|
||||
# Validate that store is True when conversation_id is set
|
||||
if chat_options.conversation_id is not None and chat_options.store is not True:
|
||||
logger.warning(
|
||||
"When conversation_id is set, store must be True for service-managed threads. "
|
||||
"Automatically setting store=True."
|
||||
)
|
||||
chat_options.store = True
|
||||
|
||||
if chat_options.instructions:
|
||||
|
||||
@@ -1636,7 +1636,7 @@ def _handle_function_calls_response(
|
||||
# this runs in every but the first run
|
||||
# we need to keep track of all function call messages
|
||||
fcc_messages.extend(response.messages)
|
||||
if getattr(kwargs.get("chat_options"), "store", False):
|
||||
if response.conversation_id is not None:
|
||||
prepped_messages.clear()
|
||||
prepped_messages.append(result_message)
|
||||
else:
|
||||
@@ -1839,7 +1839,7 @@ def _handle_function_calls_streaming_response(
|
||||
# this runs in every but the first run
|
||||
# we need to keep track of all function call messages
|
||||
fcc_messages.extend(response.messages)
|
||||
if getattr(kwargs.get("chat_options"), "store", False):
|
||||
if response.conversation_id is not None:
|
||||
prepped_messages.clear()
|
||||
prepped_messages.append(result_message)
|
||||
else:
|
||||
|
||||
@@ -9,6 +9,7 @@ _IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"AgentFunctionApp": ("agent_framework_azurefunctions", "azurefunctions"),
|
||||
"AgentResponseCallbackProtocol": ("agent_framework_azurefunctions", "azurefunctions"),
|
||||
"AzureAIAgentClient": ("agent_framework_azure_ai", "azure-ai"),
|
||||
"AzureAIClient": ("agent_framework_azure_ai", "azure-ai"),
|
||||
"AzureOpenAIAssistantsClient": ("agent_framework.azure._assistants_client", "core"),
|
||||
"AzureOpenAIChatClient": ("agent_framework.azure._chat_client", "core"),
|
||||
"AzureAISettings": ("agent_framework_azure_ai", "azure-ai"),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from agent_framework_azure_ai import AzureAIAgentClient, AzureAISettings
|
||||
from agent_framework_azure_ai import AzureAIAgentClient, AzureAIClient, AzureAISettings
|
||||
from agent_framework_azurefunctions import (
|
||||
AgentCallbackContext,
|
||||
AgentFunctionApp,
|
||||
@@ -19,6 +19,7 @@ __all__ = [
|
||||
"AgentFunctionApp",
|
||||
"AgentResponseCallbackProtocol",
|
||||
"AzureAIAgentClient",
|
||||
"AzureAIClient",
|
||||
"AzureAISettings",
|
||||
"AzureOpenAIAssistantsClient",
|
||||
"AzureOpenAIChatClient",
|
||||
|
||||
@@ -161,7 +161,8 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
|
||||
async def close(self) -> None:
|
||||
"""Clean up any assistants we created."""
|
||||
if self._should_delete_assistant and self.assistant_id is not None:
|
||||
await self.client.beta.assistants.delete(self.assistant_id)
|
||||
client = await self.ensure_client()
|
||||
await client.beta.assistants.delete(self.assistant_id)
|
||||
object.__setattr__(self, "assistant_id", None)
|
||||
object.__setattr__(self, "_should_delete_assistant", False)
|
||||
|
||||
@@ -215,7 +216,11 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
|
||||
"""
|
||||
# If no assistant is provided, create a temporary assistant
|
||||
if self.assistant_id is None:
|
||||
created_assistant = await self.client.beta.assistants.create(name=self.assistant_name, model=self.model_id)
|
||||
if not self.model_id:
|
||||
raise ServiceInitializationError("Parameter 'model_id' is required for assistant creation.")
|
||||
|
||||
client = await self.ensure_client()
|
||||
created_assistant = await client.beta.assistants.create(name=self.assistant_name, model=self.model_id)
|
||||
self.assistant_id = created_assistant.id
|
||||
self._should_delete_assistant = True
|
||||
|
||||
@@ -233,6 +238,7 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
|
||||
Returns:
|
||||
tuple: (stream, final_thread_id)
|
||||
"""
|
||||
client = await self.ensure_client()
|
||||
# Get any active run for this thread
|
||||
thread_run = await self._get_active_thread_run(thread_id)
|
||||
|
||||
@@ -240,7 +246,7 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
|
||||
|
||||
if thread_run is not None and tool_run_id is not None and tool_run_id == thread_run.id and tool_outputs:
|
||||
# There's an active run and we have tool results to submit, so submit the results.
|
||||
stream = self.client.beta.threads.runs.submit_tool_outputs_stream( # type: ignore[reportDeprecated]
|
||||
stream = client.beta.threads.runs.submit_tool_outputs_stream( # type: ignore[reportDeprecated]
|
||||
run_id=tool_run_id, thread_id=thread_run.thread_id, tool_outputs=tool_outputs
|
||||
)
|
||||
final_thread_id = thread_run.thread_id
|
||||
@@ -249,7 +255,7 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
|
||||
final_thread_id = await self._prepare_thread(thread_id, thread_run, run_options)
|
||||
|
||||
# Now create a new run and stream the results.
|
||||
stream = self.client.beta.threads.runs.stream( # type: ignore[reportDeprecated]
|
||||
stream = client.beta.threads.runs.stream( # type: ignore[reportDeprecated]
|
||||
assistant_id=assistant_id, thread_id=final_thread_id, **run_options
|
||||
)
|
||||
|
||||
@@ -257,19 +263,21 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
|
||||
|
||||
async def _get_active_thread_run(self, thread_id: str | None) -> Run | None:
|
||||
"""Get any active run for the given thread."""
|
||||
client = await self.ensure_client()
|
||||
if thread_id is None:
|
||||
return None
|
||||
|
||||
async for run in self.client.beta.threads.runs.list(thread_id=thread_id, limit=1, order="desc"): # type: ignore[reportDeprecated]
|
||||
async for run in client.beta.threads.runs.list(thread_id=thread_id, limit=1, order="desc"): # type: ignore[reportDeprecated]
|
||||
if run.status not in ["completed", "cancelled", "failed", "expired"]:
|
||||
return run
|
||||
return None
|
||||
|
||||
async def _prepare_thread(self, thread_id: str | None, thread_run: Run | None, run_options: dict[str, Any]) -> str:
|
||||
"""Prepare the thread for a new run, creating or cleaning up as needed."""
|
||||
client = await self.ensure_client()
|
||||
if thread_id is None:
|
||||
# No thread ID was provided, so create a new thread.
|
||||
thread = await self.client.beta.threads.create( # type: ignore[reportDeprecated]
|
||||
thread = await client.beta.threads.create( # type: ignore[reportDeprecated]
|
||||
messages=run_options["additional_messages"],
|
||||
tool_resources=run_options.get("tool_resources"),
|
||||
metadata=run_options.get("metadata"),
|
||||
@@ -280,7 +288,7 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
|
||||
|
||||
if thread_run is not None:
|
||||
# There was an active run; we need to cancel it before starting a new run.
|
||||
await self.client.beta.threads.runs.cancel(run_id=thread_run.id, thread_id=thread_id) # type: ignore[reportDeprecated]
|
||||
await client.beta.threads.runs.cancel(run_id=thread_run.id, thread_id=thread_id) # type: ignore[reportDeprecated]
|
||||
|
||||
return thread_id
|
||||
|
||||
|
||||
@@ -69,10 +69,11 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
|
||||
chat_options: ChatOptions,
|
||||
**kwargs: Any,
|
||||
) -> ChatResponse:
|
||||
client = await self.ensure_client()
|
||||
options_dict = self._prepare_options(messages, chat_options)
|
||||
try:
|
||||
return self._create_chat_response(
|
||||
await self.client.chat.completions.create(stream=False, **options_dict), chat_options
|
||||
await client.chat.completions.create(stream=False, **options_dict), chat_options
|
||||
)
|
||||
except BadRequestError as ex:
|
||||
if ex.code == "content_filter":
|
||||
@@ -97,10 +98,11 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
|
||||
chat_options: ChatOptions,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterable[ChatResponseUpdate]:
|
||||
client = await self.ensure_client()
|
||||
options_dict = self._prepare_options(messages, chat_options)
|
||||
options_dict["stream_options"] = {"include_usage": True}
|
||||
try:
|
||||
async for chunk in await self.client.chat.completions.create(stream=True, **options_dict):
|
||||
async for chunk in await client.chat.completions.create(stream=True, **options_dict):
|
||||
if len(chunk.choices) == 0 and chunk.usage is None:
|
||||
continue
|
||||
yield self._create_chat_response_update(chunk)
|
||||
|
||||
@@ -89,23 +89,24 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
chat_options: ChatOptions,
|
||||
**kwargs: Any,
|
||||
) -> ChatResponse:
|
||||
options_dict = self._prepare_options(messages, chat_options)
|
||||
client = await self.ensure_client()
|
||||
run_options = await self.prepare_options(messages, chat_options)
|
||||
try:
|
||||
if not chat_options.response_format:
|
||||
response = await self.client.responses.create(
|
||||
response_format = run_options.pop("response_format", None)
|
||||
if not response_format:
|
||||
response = await client.responses.create(
|
||||
stream=False,
|
||||
**options_dict,
|
||||
**run_options,
|
||||
)
|
||||
chat_options.conversation_id = response.id if chat_options.store is True else None
|
||||
chat_options.conversation_id = self.get_conversation_id(response, chat_options.store)
|
||||
return self._create_response_content(response, chat_options=chat_options)
|
||||
# create call does not support response_format, so we need to handle it via parse call
|
||||
resp_format = chat_options.response_format
|
||||
parsed_response: ParsedResponse[BaseModel] = await self.client.responses.parse(
|
||||
text_format=resp_format,
|
||||
parsed_response: ParsedResponse[BaseModel] = await client.responses.parse(
|
||||
text_format=response_format,
|
||||
stream=False,
|
||||
**options_dict,
|
||||
**run_options,
|
||||
)
|
||||
chat_options.conversation_id = parsed_response.id if chat_options.store is True else None
|
||||
chat_options.conversation_id = self.get_conversation_id(parsed_response, chat_options.store)
|
||||
return self._create_response_content(parsed_response, chat_options=chat_options)
|
||||
except BadRequestError as ex:
|
||||
if ex.code == "content_filter":
|
||||
@@ -130,13 +131,15 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
chat_options: ChatOptions,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterable[ChatResponseUpdate]:
|
||||
options_dict = self._prepare_options(messages, chat_options)
|
||||
client = await self.ensure_client()
|
||||
run_options = await self.prepare_options(messages, chat_options)
|
||||
function_call_ids: dict[int, tuple[str, str]] = {} # output_index: (call_id, name)
|
||||
try:
|
||||
if not chat_options.response_format:
|
||||
response = await self.client.responses.create(
|
||||
response_format = run_options.pop("response_format", None)
|
||||
if not response_format:
|
||||
response = await client.responses.create(
|
||||
stream=True,
|
||||
**options_dict,
|
||||
**run_options,
|
||||
)
|
||||
async for chunk in response:
|
||||
update = self._create_streaming_response_content(
|
||||
@@ -145,9 +148,9 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
yield update
|
||||
return
|
||||
# create call does not support response_format, so we need to handle it via stream call
|
||||
async with self.client.responses.stream(
|
||||
text_format=chat_options.response_format,
|
||||
**options_dict,
|
||||
async with client.responses.stream(
|
||||
text_format=response_format,
|
||||
**run_options,
|
||||
) as response:
|
||||
async for chunk in response:
|
||||
update = self._create_streaming_response_content(
|
||||
@@ -170,6 +173,12 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
inner_exception=ex,
|
||||
) from ex
|
||||
|
||||
def get_conversation_id(
|
||||
self, response: OpenAIResponse | ParsedResponse[BaseModel], store: bool | None
|
||||
) -> str | None:
|
||||
"""Get the conversation ID from the response if store is True."""
|
||||
return response.id if store else None
|
||||
|
||||
# region Prep methods
|
||||
|
||||
def _tools_to_response_tools(
|
||||
@@ -180,31 +189,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
if isinstance(tool, ToolProtocol):
|
||||
match tool:
|
||||
case HostedMCPTool():
|
||||
mcp: Mcp = {
|
||||
"type": "mcp",
|
||||
"server_label": tool.name.replace(" ", "_"),
|
||||
"server_url": str(tool.url),
|
||||
"server_description": tool.description,
|
||||
"headers": tool.headers,
|
||||
}
|
||||
if tool.allowed_tools:
|
||||
mcp["allowed_tools"] = list(tool.allowed_tools)
|
||||
if tool.approval_mode:
|
||||
match tool.approval_mode:
|
||||
case str():
|
||||
mcp["require_approval"] = (
|
||||
"always" if tool.approval_mode == "always_require" else "never"
|
||||
)
|
||||
case _:
|
||||
if always_require_approvals := tool.approval_mode.get("always_require_approval"):
|
||||
mcp["require_approval"] = {
|
||||
"always": {"tool_names": list(always_require_approvals)}
|
||||
}
|
||||
if never_require_approvals := tool.approval_mode.get("never_require_approval"):
|
||||
mcp["require_approval"] = {
|
||||
"never": {"tool_names": list(never_require_approvals)}
|
||||
}
|
||||
response_tools.append(mcp)
|
||||
response_tools.append(self.get_mcp_tool(tool))
|
||||
case HostedCodeInterpreterTool():
|
||||
tool_args: CodeInterpreterContainerCodeInterpreterToolAuto = {"type": "auto"}
|
||||
if tool.inputs:
|
||||
@@ -306,12 +291,36 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
response_tools.append(tool_dict)
|
||||
return response_tools
|
||||
|
||||
def _prepare_options(self, messages: MutableSequence[ChatMessage], chat_options: ChatOptions) -> dict[str, Any]:
|
||||
def get_mcp_tool(self, tool: HostedMCPTool) -> Any:
|
||||
"""Get MCP tool from HostedMCPTool."""
|
||||
mcp: Mcp = {
|
||||
"type": "mcp",
|
||||
"server_label": tool.name.replace(" ", "_"),
|
||||
"server_url": str(tool.url),
|
||||
"server_description": tool.description,
|
||||
"headers": tool.headers,
|
||||
}
|
||||
if tool.allowed_tools:
|
||||
mcp["allowed_tools"] = list(tool.allowed_tools)
|
||||
if tool.approval_mode:
|
||||
match tool.approval_mode:
|
||||
case str():
|
||||
mcp["require_approval"] = "always" if tool.approval_mode == "always_require" else "never"
|
||||
case _:
|
||||
if always_require_approvals := tool.approval_mode.get("always_require_approval"):
|
||||
mcp["require_approval"] = {"always": {"tool_names": list(always_require_approvals)}}
|
||||
if never_require_approvals := tool.approval_mode.get("never_require_approval"):
|
||||
mcp["require_approval"] = {"never": {"tool_names": list(never_require_approvals)}}
|
||||
|
||||
return mcp
|
||||
|
||||
async def prepare_options(
|
||||
self, messages: MutableSequence[ChatMessage], chat_options: ChatOptions
|
||||
) -> dict[str, Any]:
|
||||
"""Take ChatOptions and create the specific options for Responses API."""
|
||||
options_dict: dict[str, Any] = chat_options.to_dict(
|
||||
run_options: dict[str, Any] = chat_options.to_dict(
|
||||
exclude={
|
||||
"type",
|
||||
"response_format", # handled in inner get methods
|
||||
"presence_penalty", # not supported
|
||||
"frequency_penalty", # not supported
|
||||
"logit_bias", # not supported
|
||||
@@ -320,6 +329,10 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
"instructions", # already added as system message
|
||||
}
|
||||
)
|
||||
|
||||
if chat_options.response_format:
|
||||
run_options["response_format"] = chat_options.response_format
|
||||
|
||||
translations = {
|
||||
"model_id": "model",
|
||||
"allow_multiple_tool_calls": "parallel_tool_calls",
|
||||
@@ -327,35 +340,37 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
"max_tokens": "max_output_tokens",
|
||||
}
|
||||
for old_key, new_key in translations.items():
|
||||
if old_key in options_dict and old_key != new_key:
|
||||
options_dict[new_key] = options_dict.pop(old_key)
|
||||
if old_key in run_options and old_key != new_key:
|
||||
run_options[new_key] = run_options.pop(old_key)
|
||||
|
||||
# tools
|
||||
if chat_options.tools is None:
|
||||
options_dict.pop("parallel_tool_calls", None)
|
||||
run_options.pop("parallel_tool_calls", None)
|
||||
else:
|
||||
options_dict["tools"] = self._tools_to_response_tools(chat_options.tools)
|
||||
run_options["tools"] = self._tools_to_response_tools(chat_options.tools)
|
||||
|
||||
# model id
|
||||
if not options_dict.get("model"):
|
||||
options_dict["model"] = self.model_id
|
||||
if not run_options.get("model"):
|
||||
if not self.model_id:
|
||||
raise ValueError("model_id must be a non-empty string")
|
||||
run_options["model"] = self.model_id
|
||||
|
||||
# messages
|
||||
request_input = self._prepare_chat_messages_for_request(messages)
|
||||
if not request_input:
|
||||
raise ServiceInvalidRequestError("Messages are required for chat completions")
|
||||
options_dict["input"] = request_input
|
||||
run_options["input"] = request_input
|
||||
|
||||
# additional provider specific settings
|
||||
if additional_properties := options_dict.pop("additional_properties", None):
|
||||
if additional_properties := run_options.pop("additional_properties", None):
|
||||
for key, value in additional_properties.items():
|
||||
if value is not None:
|
||||
options_dict[key] = value
|
||||
if "store" not in options_dict:
|
||||
options_dict["store"] = False
|
||||
if (tool_choice := options_dict.get("tool_choice")) and len(tool_choice.keys()) == 1:
|
||||
options_dict["tool_choice"] = tool_choice["mode"]
|
||||
return options_dict
|
||||
run_options[key] = value
|
||||
if "store" not in run_options:
|
||||
run_options["store"] = False
|
||||
if (tool_choice := run_options.get("tool_choice")) and len(tool_choice.keys()) == 1:
|
||||
run_options["tool_choice"] = tool_choice["mode"]
|
||||
return run_options
|
||||
|
||||
def _prepare_chat_messages_for_request(self, chat_messages: Sequence[ChatMessage]) -> list[dict[str, Any]]:
|
||||
"""Prepare the chat messages for a request.
|
||||
@@ -504,7 +519,6 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
# 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,
|
||||
"id": call_id_to_id.get(content.call_id),
|
||||
"type": "function_call_output",
|
||||
}
|
||||
if content.result:
|
||||
@@ -734,7 +748,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
"raw_representation": response,
|
||||
}
|
||||
if chat_options.store:
|
||||
args["conversation_id"] = response.id
|
||||
args["conversation_id"] = self.get_conversation_id(response, chat_options.store)
|
||||
if response.usage and (usage_details := self._usage_details_from_openai(response.usage)):
|
||||
args["usage_details"] = usage_details
|
||||
if structured_response:
|
||||
@@ -834,7 +848,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
contents.append(TextReasoningContent(text=event.text, raw_representation=event))
|
||||
metadata.update(self._get_metadata_from_response(event))
|
||||
case "response.completed":
|
||||
conversation_id = event.response.id if chat_options.store is True else None
|
||||
conversation_id = self.get_conversation_id(event.response, chat_options.store)
|
||||
model = event.response.model
|
||||
if event.response.usage:
|
||||
usage = self._usage_details_from_openai(event.response.usage)
|
||||
|
||||
@@ -127,18 +127,18 @@ class OpenAIBase(SerializationMixin):
|
||||
|
||||
INJECTABLE: ClassVar[set[str]] = {"client"}
|
||||
|
||||
def __init__(self, *, client: AsyncOpenAI, model_id: str, **kwargs: Any) -> None:
|
||||
def __init__(self, *, model_id: str | None = None, client: AsyncOpenAI | None = None, **kwargs: Any) -> None:
|
||||
"""Initialize OpenAIBase.
|
||||
|
||||
Keyword Args:
|
||||
client: The AsyncOpenAI client instance.
|
||||
model_id: The AI model ID to use (non-empty, whitespace stripped).
|
||||
model_id: The AI model ID to use.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
if not model_id or not model_id.strip():
|
||||
raise ValueError("model_id must be a non-empty string")
|
||||
self.client = client
|
||||
self.model_id = model_id.strip()
|
||||
self.model_id = None
|
||||
if model_id:
|
||||
self.model_id = model_id.strip()
|
||||
|
||||
# Call super().__init__() to continue MRO chain (e.g., BaseChatClient)
|
||||
# Extract known kwargs that belong to other base classes
|
||||
@@ -162,6 +162,21 @@ class OpenAIBase(SerializationMixin):
|
||||
for key, value in kwargs.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
async def initialize_client(self) -> None:
|
||||
"""Initialize OpenAI client asynchronously.
|
||||
|
||||
Override in subclasses to initialize the OpenAI client asynchronously.
|
||||
"""
|
||||
pass
|
||||
|
||||
async def ensure_client(self) -> AsyncOpenAI:
|
||||
"""Ensure OpenAI client is initialized."""
|
||||
await self.initialize_client()
|
||||
if self.client is None:
|
||||
raise ServiceInitializationError("OpenAI client is not initialized")
|
||||
|
||||
return self.client
|
||||
|
||||
def _get_api_key(
|
||||
self, api_key: str | SecretStr | Callable[[], str | Awaitable[str]] | None
|
||||
) -> str | Callable[[], str | Awaitable[str]] | None:
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251111"
|
||||
version = "1.0.0b251112"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -1407,27 +1407,27 @@ def test_create_response_content_image_generation_fallback():
|
||||
assert f"data:image/png;base64,{unrecognized_base64}" == content.uri
|
||||
|
||||
|
||||
def test_prepare_options_store_parameter_handling() -> None:
|
||||
async def test_prepare_options_store_parameter_handling() -> None:
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
messages = [ChatMessage(role="user", text="Test message")]
|
||||
|
||||
test_conversation_id = "test-conversation-123"
|
||||
chat_options = ChatOptions(store=True, conversation_id=test_conversation_id)
|
||||
options = client._prepare_options(messages, chat_options) # type: ignore
|
||||
options = await client.prepare_options(messages, chat_options)
|
||||
assert options["store"] is True
|
||||
assert options["previous_response_id"] == test_conversation_id
|
||||
|
||||
chat_options = ChatOptions(store=False, conversation_id="")
|
||||
options = client._prepare_options(messages, chat_options) # type: ignore
|
||||
options = await client.prepare_options(messages, chat_options)
|
||||
assert options["store"] is False
|
||||
|
||||
chat_options = ChatOptions(store=None, conversation_id=None)
|
||||
options = client._prepare_options(messages, chat_options) # type: ignore
|
||||
options = await client.prepare_options(messages, chat_options)
|
||||
assert options["store"] is False
|
||||
assert "previous_response_id" not in options
|
||||
|
||||
chat_options = ChatOptions()
|
||||
options = client._prepare_options(messages, chat_options) # type: ignore
|
||||
options = await client.prepare_options(messages, chat_options)
|
||||
assert options["store"] is False
|
||||
assert "previous_response_id" not in options
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
+13
-13
@@ -94,14 +94,14 @@ export function AgentDetailsModal({
|
||||
{/* Grid Layout for Metadata */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
{/* Model & Client */}
|
||||
{(agent.model || agent.chat_client_type) && (
|
||||
{(agent.model_id || agent.chat_client_type) && (
|
||||
<DetailCard
|
||||
title="Model & Client"
|
||||
icon={<Bot className="h-4 w-4 text-muted-foreground" />}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{agent.model && (
|
||||
<div className="font-mono text-foreground">{agent.model}</div>
|
||||
{agent.model_id && (
|
||||
<div className="font-mono text-foreground">{agent.model_id}</div>
|
||||
)}
|
||||
{agent.chat_client_type && (
|
||||
<div className="text-xs">({agent.chat_client_type})</div>
|
||||
@@ -136,7 +136,9 @@ export function AgentDetailsModal({
|
||||
>
|
||||
<div
|
||||
className={
|
||||
agent.has_env ? "text-orange-600 dark:text-orange-400" : "text-green-600 dark:text-green-400"
|
||||
agent.has_env
|
||||
? "text-orange-600 dark:text-orange-400"
|
||||
: "text-green-600 dark:text-green-400"
|
||||
}
|
||||
>
|
||||
{agent.has_env
|
||||
@@ -162,11 +164,11 @@ export function AgentDetailsModal({
|
||||
{/* Tools and Middleware Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Tools */}
|
||||
<DetailCard
|
||||
title={`Tools (${agent.tools.length})`}
|
||||
icon={<Package className="h-4 w-4 text-muted-foreground" />}
|
||||
>
|
||||
{agent.tools.length > 0 ? (
|
||||
{agent.tools && agent.tools.length > 0 && (
|
||||
<DetailCard
|
||||
title={`Tools (${agent.tools.length})`}
|
||||
icon={<Package className="h-4 w-4 text-muted-foreground" />}
|
||||
>
|
||||
<ul className="space-y-1">
|
||||
{agent.tools.map((tool, index) => (
|
||||
<li key={index} className="font-mono text-xs text-foreground">
|
||||
@@ -174,10 +176,8 @@ export function AgentDetailsModal({
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="text-muted-foreground">No tools configured</div>
|
||||
)}
|
||||
</DetailCard>
|
||||
</DetailCard>
|
||||
)}
|
||||
|
||||
{/* Middleware */}
|
||||
{agent.middleware && agent.middleware.length > 0 && (
|
||||
|
||||
+4
-3
@@ -64,8 +64,8 @@ export function WorkflowDetailsModal({
|
||||
workflow.source === "directory"
|
||||
? "Local"
|
||||
: workflow.source === "in_memory"
|
||||
? "In-Memory"
|
||||
: "Gallery";
|
||||
? "In-Memory"
|
||||
: "Gallery";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -151,7 +151,8 @@ export function WorkflowDetailsModal({
|
||||
{workflow.executors.map((executor, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="font-mono text-xs text-foreground bg-muted px-2 py-1 rounded"
|
||||
className="font-mono text-xs text-foreground bg-muted px-2 py-1 rounded truncate"
|
||||
title={executor}
|
||||
>
|
||||
{executor}
|
||||
</div>
|
||||
|
||||
@@ -33,12 +33,13 @@ interface BackendEntityInfo {
|
||||
tools?: (string | Record<string, unknown>)[];
|
||||
metadata: Record<string, unknown>;
|
||||
source?: string;
|
||||
required_env_vars?: import("@/types").EnvVarRequirement[];
|
||||
// Deployment support
|
||||
deployment_supported?: boolean;
|
||||
deployment_reason?: string;
|
||||
// Agent-specific fields (present when type === "agent")
|
||||
instructions?: string;
|
||||
model?: string;
|
||||
model_id?: string;
|
||||
chat_client_type?: string;
|
||||
context_providers?: string[];
|
||||
middleware?: string[];
|
||||
@@ -205,41 +206,51 @@ class ApiClient {
|
||||
tools: (entity.tools || []).map((tool) =>
|
||||
typeof tool === "string" ? tool : JSON.stringify(tool)
|
||||
),
|
||||
has_env: false, // Default value
|
||||
has_env: !!(entity.required_env_vars && entity.required_env_vars.length > 0),
|
||||
module_path:
|
||||
typeof entity.metadata?.module_path === "string"
|
||||
? entity.metadata.module_path
|
||||
: undefined,
|
||||
required_env_vars: entity.required_env_vars,
|
||||
metadata: entity.metadata, // Preserve metadata including lazy_loaded flag
|
||||
// Deployment support
|
||||
deployment_supported: entity.deployment_supported,
|
||||
deployment_reason: entity.deployment_reason,
|
||||
// Agent-specific fields
|
||||
instructions: entity.instructions,
|
||||
model: entity.model,
|
||||
model_id: entity.model_id,
|
||||
chat_client_type: entity.chat_client_type,
|
||||
context_providers: entity.context_providers,
|
||||
middleware: entity.middleware,
|
||||
};
|
||||
} else {
|
||||
// Workflow
|
||||
const firstTool = entity.tools?.[0];
|
||||
const startExecutorId = typeof firstTool === "string" ? firstTool : "";
|
||||
|
||||
// Workflow - prefer executors field, fall back to tools for backward compatibility
|
||||
const executorList = entity.executors || entity.tools || [];
|
||||
|
||||
// Determine start_executor_id: use entity value, or first executor if it's a string
|
||||
let startExecutorId = entity.start_executor_id || "";
|
||||
if (!startExecutorId && executorList.length > 0) {
|
||||
const firstExecutor = executorList[0];
|
||||
if (typeof firstExecutor === "string") {
|
||||
startExecutorId = firstExecutor;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
description: entity.description,
|
||||
type: "workflow" as const,
|
||||
source: (entity.source as AgentSource) || "directory",
|
||||
executors: (entity.tools || []).map((tool) =>
|
||||
typeof tool === "string" ? tool : JSON.stringify(tool)
|
||||
executors: executorList.map((executor) =>
|
||||
typeof executor === "string" ? executor : JSON.stringify(executor)
|
||||
),
|
||||
has_env: false,
|
||||
has_env: !!(entity.required_env_vars && entity.required_env_vars.length > 0),
|
||||
module_path:
|
||||
typeof entity.metadata?.module_path === "string"
|
||||
? entity.metadata.module_path
|
||||
: undefined,
|
||||
required_env_vars: entity.required_env_vars,
|
||||
metadata: entity.metadata, // Preserve metadata including lazy_loaded flag
|
||||
// Deployment support
|
||||
deployment_supported: entity.deployment_supported,
|
||||
@@ -250,6 +261,7 @@ class ApiClient {
|
||||
}, // Default schema
|
||||
input_type_name: entity.input_type_name || "Input",
|
||||
start_executor_id: startExecutorId,
|
||||
tools: [],
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ export interface AgentInfo {
|
||||
deployment_reason?: string;
|
||||
// Agent-specific fields
|
||||
instructions?: string;
|
||||
model?: string;
|
||||
model_id?: string;
|
||||
chat_client_type?: string;
|
||||
context_providers?: string[];
|
||||
middleware?: string[];
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251111"
|
||||
version = "1.0.0b251112"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://github.com/microsoft/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Experimental modules for Microsoft Agent Framework"
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251111"
|
||||
version = "1.0.0b251112"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Mem0 integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251111"
|
||||
version = "1.0.0b251112"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Purview (Graph dataSecurityAndGovernance) integration f
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251111"
|
||||
version = "1.0.0b251112"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://github.com/microsoft/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Redis integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b251111"
|
||||
version = "1.0.0b251112"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
Reference in New Issue
Block a user