Python: Fix Python pyright package scoping and typing remediation (#4426)

* Fix Python pyright package scoping and typing remediation

Implements issue #4407 by removing the root pyright include, adding package-level pyright includes, and resolving pyright/mypy typing issues across Python packages. Also cleans unnecessary casts and applies line-level, rule-specific ignores where external libraries are too dynamic.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Reduce pyright cost in handoff cloning

Simplify cloned_options construction in HandoffAgentExecutor to avoid expensive TypedDict narrowing/inference in _handoff.py, which was causing pyright to spend a long time in orchestrations.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix types

* Fix lint and type-check regressions

Resolve current Python package check failures across lint, pyright, and mypy after recent code changes, including purview/declarative pyright issues and multiple ruff simplification findings.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fixed hooks

* Stabilize package tests and test tasks

Resolve cross-package non-integration test failures, simplify streaming type flow, harden locale/culture handling, and standardize package test poe tasks to exclude integration tests where applicable.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* lots of small fixes

* Fix current Python test regressions

Address current failing unit tests in azure-ai, bedrock, and azure-cosmos while keeping Bedrock parsing logic inline (no new static helper methods).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* small fixes

* small fixes

* removed pydantic from json

* final updates

* fix core

* fix tests

* fix obser

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Eduard van Valkenburg
2026-03-05 15:32:24 +00:00
committed by GitHub
co-authored by Copilot
parent 4a043c6c66
commit 55ddd841b7
122 changed files with 2328 additions and 2407 deletions
@@ -7,7 +7,7 @@ import json
import re
import uuid
from collections.abc import AsyncIterable, Awaitable, Sequence
from typing import Any, Final, Literal, overload
from typing import Any, Final, Literal, TypeAlias, overload
import httpx
from a2a.client import Client, ClientConfig, ClientFactory, minimal_agent_card
@@ -19,9 +19,11 @@ from a2a.types import (
FileWithBytes,
FileWithUri,
Task,
TaskArtifactUpdateEvent,
TaskIdParams,
TaskQueryParams,
TaskState,
TaskStatusUpdateEvent,
TextPart,
TransportProtocol,
)
@@ -70,6 +72,9 @@ IN_PROGRESS_TASK_STATES = [
TaskState.auth_required,
]
A2AClientEvent: TypeAlias = tuple[Task, TaskStatusUpdateEvent | TaskArtifactUpdateEvent | None]
A2AStreamItem: TypeAlias = A2AMessage | A2AClientEvent
def _get_uri_data(uri: str) -> str:
match = URI_PATTERN.match(uri)
@@ -260,7 +265,9 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
When stream=True: A ResponseStream of AgentResponseUpdate items.
"""
if continuation_token is not None:
a2a_stream: AsyncIterable[Any] = self.client.resubscribe(TaskIdParams(id=continuation_token["task_id"]))
a2a_stream: AsyncIterable[A2AStreamItem] = self.client.resubscribe(
TaskIdParams(id=continuation_token["task_id"])
)
else:
normalized_messages = normalize_messages(messages)
a2a_message = self._prepare_message_for_a2a(normalized_messages[-1])
@@ -276,7 +283,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
async def _map_a2a_stream(
self,
a2a_stream: AsyncIterable[Any],
a2a_stream: AsyncIterable[A2AStreamItem],
*,
background: bool = False,
) -> AsyncIterable[AgentResponseUpdate]:
@@ -300,14 +307,12 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
response_id=str(getattr(item, "message_id", uuid.uuid4())),
raw_representation=item,
)
elif isinstance(item, tuple) and len(item) == 2: # ClientEvent = (Task, UpdateEvent)
elif isinstance(item, tuple) and len(item) == 2 and isinstance(item[0], Task):
task, _update_event = item
if isinstance(task, Task):
for update in self._updates_from_task(task, background=background):
yield update
for update in self._updates_from_task(task, background=background):
yield update
else:
msg = f"Only Message and Task responses are supported from A2A agents. Received: {type(item)}"
raise NotImplementedError(msg)
raise NotImplementedError("Only Message and Task responses are supported")
# ------------------------------------------------------------------
# Task helpers
@@ -396,6 +401,8 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
for content in message.contents:
match content.type:
case "text":
if content.text is None:
raise ValueError("Text content requires a non-null text value")
parts.append(
A2APart(
root=TextPart(
@@ -414,6 +421,8 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
)
)
case "uri":
if content.uri is None:
raise ValueError("URI content requires a non-null uri value")
parts.append(
A2APart(
root=FilePart(
@@ -426,11 +435,13 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
)
)
case "data":
if content.uri is None:
raise ValueError("Data content requires a non-null uri value")
parts.append(
A2APart(
root=FilePart(
file=FileWithBytes(
bytes=_get_uri_data(content.uri), # type: ignore[arg-type]
bytes=_get_uri_data(content.uri),
mime_type=content.media_type,
),
metadata=content.additional_properties,
@@ -438,6 +449,8 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
)
)
case "hosted_file":
if content.file_id is None:
raise ValueError("Hosted file content requires a non-null file_id value")
parts.append(
A2APart(
root=FilePart(
+2 -1
View File
@@ -61,6 +61,7 @@ omit = [
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_a2a"]
[tool.mypy]
plugins = ['pydantic.mypy']
@@ -86,7 +87,7 @@ include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_a2a"
test = "pytest --cov=agent_framework_a2a --cov-report=term-missing:skip-covered tests"
test = "pytest -m \"not integration\" --cov=agent_framework_a2a --cov-report=term-missing:skip-covered tests"
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
+2 -1
View File
@@ -64,6 +64,7 @@ warn_unused_configs = true
disallow_untyped_defs = false
[tool.pyright]
include = ["agent_framework_ag_ui"]
exclude = ["tests", "tests/ag_ui", "examples"]
typeCheckingMode = "basic"
@@ -73,4 +74,4 @@ include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_ag_ui"
test = "pytest --cov=agent_framework_ag_ui --cov-report=term-missing:skip-covered -n auto --dist worksteal tests/ag_ui"
test = "pytest -m \"not integration\" --cov=agent_framework_ag_ui --cov-report=term-missing:skip-covered -n auto --dist worksteal tests/ag_ui"
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging
import sys
from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, MutableMapping, Sequence
from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, Sequence
from typing import Any, ClassVar, Final, Generic, Literal, TypedDict
from agent_framework import (
@@ -302,15 +302,18 @@ class AnthropicClient(
env_file_encoding=env_file_encoding,
)
api_key_secret = anthropic_settings.get("api_key")
model_id_setting = anthropic_settings.get("chat_model_id")
if anthropic_client is None:
if not anthropic_settings["api_key"]:
if api_key_secret is None:
raise ValueError(
"Anthropic API key is required. Set via 'api_key' parameter "
"or 'ANTHROPIC_API_KEY' environment variable."
)
anthropic_client = AsyncAnthropic(
api_key=anthropic_settings["api_key"].get_secret_value(),
api_key=api_key_secret.get_secret_value(),
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
)
@@ -324,7 +327,7 @@ class AnthropicClient(
# Initialize instance variables
self.anthropic_client = anthropic_client
self.additional_beta_flags = additional_beta_flags or []
self.model_id = anthropic_settings["chat_model_id"]
self.model_id = model_id_setting
# streaming requires tracking the last function call ID, name, and content type
self._last_call_id_name: tuple[str, str] | None = None
self._last_call_content_type: str | None = None
@@ -785,18 +788,22 @@ class AnthropicClient(
"description": tool.description,
"input_schema": tool.parameters(),
})
elif isinstance(tool, MutableMapping) and tool.get("type") == "mcp":
elif isinstance(tool, Mapping) and tool.get("type") == "mcp": # type: ignore[reportUnknownMemberType]
# MCP servers must be routed to separate mcp_servers parameter
server_def: dict[str, Any] = {
"type": "url",
"name": tool.get("server_label", ""),
"url": tool.get("server_url", ""),
"name": tool.get("server_label", ""), # type: ignore[reportUnknownMemberType]
"url": tool.get("server_url", ""), # type: ignore[reportUnknownMemberType]
}
if allowed_tools := tool.get("allowed_tools"):
server_def["tool_configuration"] = {"allowed_tools": list(allowed_tools)}
headers = tool.get("headers")
if isinstance(headers, dict) and (auth := headers.get("authorization")):
server_def["authorization_token"] = auth
allowed_tools = tool.get("allowed_tools") # type: ignore[reportUnknownMemberType]
if isinstance(allowed_tools, Sequence) and not isinstance(allowed_tools, str):
server_def["tool_configuration"] = {
"allowed_tools": [str(item) for item in allowed_tools] # pyright: ignore[reportUnknownArgumentType,reportUnknownVariableType]
}
headers = tool.get("headers") # type: ignore[reportUnknownMemberType]
authorization = headers.get("authorization") if isinstance(headers, Mapping) else None # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType]
if isinstance(authorization, str):
server_def["authorization_token"] = authorization
mcp_server_list.append(server_def)
else:
# Pass through all other tools (dicts, SDK types) unchanged
+1 -1
View File
@@ -87,7 +87,7 @@ include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_anthropic"
test = "pytest --cov=agent_framework_anthropic --cov-report=term-missing:skip-covered -n auto --dist worksteal tests"
test = "pytest -m \"not integration\" --cov=agent_framework_anthropic --cov-report=term-missing:skip-covered -n auto --dist worksteal tests"
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
@@ -456,10 +456,10 @@ class AzureAISearchContextProvider(BaseContextProvider):
elif self.embedding_function:
if isinstance(self.embedding_function, SupportsGetEmbeddings):
embeddings = await self.embedding_function.get_embeddings([query]) # type: ignore[reportUnknownVariableType]
query_vector: list[float] = embeddings[0].vector # type: ignore[reportUnknownVariableType]
query_vector = embeddings[0].vector # type: ignore[reportUnknownVariableType]
else:
query_vector = await self.embedding_function(query)
vector_queries = [VectorizedQuery(vector=query_vector, k=vector_k, fields=self.vector_field_name)]
query_vector = await self.embedding_function(query) # type: ignore[reportUnknownVariableType]
vector_queries = [VectorizedQuery(vector=query_vector, k=vector_k, fields=self.vector_field_name)] # type: ignore[reportUnknownArgumentType]
search_params: dict[str, Any] = {"search_text": query, "top": self.top_k}
if vector_queries:
@@ -632,6 +632,8 @@ class AzureAISearchContextProvider(BaseContextProvider):
image=KnowledgeBaseMessageImageContentImage(url=content.uri),
)
)
case _:
pass
elif msg.text:
kb_content.append(KnowledgeBaseMessageTextContent(text=msg.text))
if kb_content:
@@ -62,6 +62,7 @@ omit = [
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_azure_ai_search"]
exclude = ['tests']
[tool.mypy]
@@ -88,7 +89,7 @@ include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_ai_search"
test = "pytest --cov=agent_framework_azure_ai_search --cov-report=term-missing:skip-covered tests"
test = "pytest -m \"not integration\" --cov=agent_framework_azure_ai_search --cov-report=term-missing:skip-covered tests"
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
@@ -9,7 +9,7 @@ import os
import re
import sys
from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, MutableMapping, Sequence
from typing import Any, ClassVar, Generic, TypedDict
from typing import Any, ClassVar, Generic, TypedDict, cast
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
@@ -77,9 +77,9 @@ from azure.ai.agents.models import (
RunStatus,
RunStep,
RunStepDeltaChunk,
RunStepDeltaCodeInterpreterDetailItemObject,
RunStepDeltaCodeInterpreterImageOutput,
RunStepDeltaCodeInterpreterLogOutput,
RunStepDeltaToolCall,
SubmitToolApprovalAction,
SubmitToolOutputsAction,
ThreadMessageOptions,
@@ -704,7 +704,7 @@ class AzureAIAgentClient(
args["tool_approvals"] = tool_approvals
await self.agents_client.runs.submit_tool_outputs_stream(**args) # type: ignore[reportUnknownMemberType]
# Pass the handler to the stream to continue processing
stream = handler # type: ignore
stream = handler
final_thread_id = thread_run.thread_id
else:
# Handle thread creation or cancellation
@@ -881,7 +881,7 @@ class AzureAIAgentClient(
azure_search_tool_calls: list[dict[str, Any]] = []
response_stream = await stream.__aenter__() if isinstance(stream, AsyncAgentRunStream) else stream # type: ignore[no-untyped-call]
try:
async for event_type, event_data, _ in response_stream: # type: ignore
async for event_type, event_data, _ in response_stream:
match event_data:
case MessageDeltaChunk():
# only one event_type: AgentStreamEvent.THREAD_MESSAGE_DELTA
@@ -997,21 +997,16 @@ class AzureAIAgentClient(
role="assistant",
)
case RunStepDeltaChunk(): # type: ignore
if (
event_data.delta.step_details is not None
and event_data.delta.step_details.type == "tool_calls"
and event_data.delta.step_details.tool_calls is not None # type: ignore[attr-defined]
):
for tool_call in event_data.delta.step_details.tool_calls: # type: ignore[attr-defined]
if tool_call.type == "code_interpreter" and isinstance(
tool_call.code_interpreter,
RunStepDeltaCodeInterpreterDetailItemObject,
):
step_details = event_data.delta.step_details
if step_details is not None and step_details.type == "tool_calls":
tool_calls = cast(list[RunStepDeltaToolCall], step_details.tool_calls) # type: ignore
for tool_call in tool_calls:
if tool_call.type == "code_interpreter" and tool_call.code_interpreter is not None: # type: ignore[attr-defined, reportUnknownMemberType]
code_contents: list[Content] = []
if tool_call.code_interpreter.input is not None:
logger.debug(f"Code Interpreter Input: {tool_call.code_interpreter.input}")
if tool_call.code_interpreter.outputs is not None:
for output in tool_call.code_interpreter.outputs:
if tool_call.code_interpreter.input is not None: # type: ignore[attr-defined, reportUnknownMemberType]
logger.debug(f"Code Interpreter Input: {tool_call.code_interpreter.input}") # type: ignore[attr-defined, reportUnknownMemberType]
if tool_call.code_interpreter.outputs is not None: # type: ignore[attr-defined, reportUnknownMemberType]
for output in tool_call.code_interpreter.outputs: # type: ignore[attr-defined, reportUnknownMemberType]
if isinstance(output, RunStepDeltaCodeInterpreterLogOutput) and output.logs:
code_contents.append(Content.from_text(text=output.logs))
if (
@@ -1027,7 +1022,7 @@ class AzureAIAgentClient(
contents=code_contents,
conversation_id=thread_id,
message_id=response_id,
raw_representation=tool_call.code_interpreter,
raw_representation=tool_call.code_interpreter, # type: ignore[attr-defined, reportUnknownMemberType]
response_id=response_id,
)
case _: # ThreadMessage or string
@@ -1056,17 +1051,15 @@ class AzureAIAgentClient(
) -> None:
"""Capture Azure AI Search tool call data from completed steps."""
try:
if (
hasattr(step_data, "step_details")
and hasattr(step_data.step_details, "tool_calls")
and step_data.step_details.tool_calls
):
for tool_call in step_data.step_details.tool_calls:
if hasattr(tool_call, "type") and tool_call.type == "azure_ai_search":
step_details = getattr(step_data, "step_details", None)
tool_calls = getattr(step_details, "tool_calls", None) if step_details is not None else None
if isinstance(tool_calls, list):
for tool_call in cast(list[object], tool_calls):
if getattr(tool_call, "type", None) == "azure_ai_search":
# Store the complete tool call as a dictionary
tool_call_dict = {
"id": getattr(tool_call, "id", None),
"type": tool_call.type,
"type": getattr(tool_call, "type", None),
"azure_ai_search": getattr(tool_call, "azure_ai_search", None),
}
azure_search_tool_calls.append(tool_call_dict)
@@ -1219,19 +1212,18 @@ class AzureAIAgentClient(
self, options: Mapping[str, Any]
) -> AgentsToolChoiceOptionMode | AgentsNamedToolChoice | None:
"""Prepare the tool choice mode for Azure AI Agents API."""
tool_choice = options.get("tool_choice")
tool_choice = cast(str | dict[str, str] | None, options.get("tool_choice"))
if tool_choice is None:
return None
if tool_choice == "none":
return AgentsToolChoiceOptionMode.NONE
if tool_choice == "auto":
return AgentsToolChoiceOptionMode.AUTO
if isinstance(tool_choice, Mapping) and tool_choice.get("mode") == "required":
if isinstance(tool_choice, str) and tool_choice in {"none", "auto"}:
return AgentsToolChoiceOptionMode(tool_choice)
if isinstance(tool_choice, dict):
mode = tool_choice.get("mode")
req_fn = tool_choice.get("required_function_name")
if req_fn:
if mode == "required" and req_fn is not None:
return AgentsNamedToolChoice(
type=AgentsNamedToolChoiceType.FUNCTION,
function=FunctionName(name=str(req_fn)),
function=FunctionName(name=req_fn),
)
return None
@@ -1369,14 +1361,9 @@ class AzureAIAgentClient(
# SDK Tool wrappers (McpTool, FileSearchTool, BingGroundingTool, etc.)
tool_definitions.extend(tool.definitions)
# Handle tool resources (MCP resources handled separately by _prepare_mcp_resources)
if (
run_options is not None
and hasattr(tool, "resources")
and tool.resources
and "mcp" not in tool.resources
):
if "tool_resources" not in run_options:
run_options["tool_resources"] = {}
resources = getattr(tool, "resources", None)
if run_options is not None and resources and isinstance(resources, Mapping) and "mcp" not in resources:
run_options.setdefault("tool_resources", {})
run_options["tool_resources"].update(tool.resources)
else:
# Pass through ToolDefinition, dict, and other types unchanged
@@ -6,7 +6,7 @@ import json
import logging
import re
import sys
from collections.abc import Awaitable, Callable, Mapping, Sequence
from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequence
from contextlib import suppress
from typing import Any, ClassVar, Generic, Literal, TypedDict, TypeVar, cast
@@ -304,7 +304,7 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
# Import Azure Monitor with proper error handling
try:
from azure.monitor.opentelemetry import configure_azure_monitor
from azure.monitor.opentelemetry import configure_azure_monitor # type: ignore[import]
except ImportError as exc:
raise ImportError(
"azure-monitor-opentelemetry is required for Azure Monitor integration. "
@@ -433,31 +433,36 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
"""Extract comparable tool names from runtime tool payloads."""
if not isinstance(tools, Sequence) or isinstance(tools, str | bytes):
return set()
return {self._get_tool_name(tool) for tool in tools}
tool_names: set[str] = set()
for tool_item in cast(Sequence[object], tools):
tool_names.add(self._get_tool_name(tool_item))
return tool_names
def _get_tool_name(self, tool: Any) -> str:
"""Get a stable name for a tool for runtime comparison."""
if isinstance(tool, FunctionTool):
return tool.name
if isinstance(tool, Mapping):
tool_type = tool.get("type")
tool_type = tool.get("type") # type: ignore[reportUnknownMemberType]
if tool_type == "function":
if isinstance(function_data := tool.get("function"), Mapping) and function_data.get("name"):
return str(function_data["name"])
if tool.get("name"):
return str(tool["name"])
if tool.get("name"):
return str(tool["name"])
if tool.get("server_label"):
return f"mcp:{tool['server_label']}"
function_data = tool.get("function") # type: ignore[reportUnknownMemberType]
if isinstance(function_data, Mapping) and (function_name := function_data.get("name")): # type: ignore[assignment]
return function_name # type: ignore[no-any-return]
if tool_name := tool.get("name"): # type: ignore[reportUnknownMemberType]
return tool_name # type: ignore[no-any-return]
if server_label := tool.get("server_label"): # type: ignore[reportUnknownMemberType]
return f"mcp:{server_label}"
if tool_type:
return str(tool_type)
if getattr(tool, "name", None):
return str(tool.name)
if getattr(tool, "server_label", None):
return f"mcp:{tool.server_label}"
if getattr(tool, "type", None):
return str(tool.type)
return tool_type # type: ignore[no-any-return]
raise ValueError("Dict based tool definitions must include a 'name' property for runtime comparison.")
if name_value := getattr(tool, "name", None):
return name_value # type: ignore[no-any-return]
if server_label_value := getattr(tool, "server_label", None):
return f"mcp:{server_label_value}"
if tool_type_value := getattr(tool, "type", None):
return tool_type_value # type: ignore[no-any-return]
return type(tool).__name__
def _get_structured_output_signature(self, chat_options: Mapping[str, Any] | None) -> str | None:
@@ -545,14 +550,14 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
return run_options
@override
def _check_model_presence(self, run_options: dict[str, Any]) -> None:
def _check_model_presence(self, options: dict[str, Any]) -> None:
# Skip model check for application endpoints - model is pre-configured on server
if self._is_application_endpoint:
return
if not run_options.get("model"):
if not options.get("model"):
if not self.model_id:
raise ValueError("model_deployment_name must be a non-empty string")
run_options["model"] = self.model_id
options["model"] = self.model_id
def _transform_input_for_azure_ai(self, input_items: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Transform input items to match Azure AI Projects expected schema.
@@ -575,15 +580,14 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
# Add 'annotations' only to output_text content items (assistant messages)
# User messages (input_text) do NOT support annotations in Azure AI
if "content" in new_item and isinstance(new_item["content"], list):
new_content: list[dict[str, Any] | Any] = []
for content_item in new_item["content"]:
if isinstance(content_item, dict):
new_content_item: dict[str, Any] = dict(content_item)
if (content := new_item.get("content")) and isinstance(content, list):
new_content: list[Any] = []
for content_item in content: # type: ignore[list-item]
if isinstance(content_item, MutableMapping):
# Only add annotations to output_text (assistant content)
if new_content_item.get("type") == "output_text" and "annotations" not in new_content_item:
new_content_item["annotations"] = []
new_content.append(new_content_item)
if content_item.get("type") == "output_text" and "annotations" not in content_item: # type: ignore[reportUnknownMemberType]
content_item["annotations"] = []
new_content.append(content_item)
else:
new_content.append(content_item)
new_item["content"] = new_content
@@ -721,9 +725,13 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
# Streaming "added" events send output as an empty list; skip.
continue
if output is not None:
urls = output.get("get_urls") if isinstance(output, dict) else output.get_urls
if urls and isinstance(urls, list):
get_urls.extend(urls)
urls = output.get("get_urls") if isinstance(output, Mapping) else getattr(output, "get_urls", None) # type: ignore
if isinstance(urls, list):
string_urls: list[str] = []
for url_item in urls: # type: ignore[list-item]
if isinstance(url_item, str):
string_urls.append(url_item)
get_urls.extend(string_urls)
return get_urls
def _get_search_doc_url(self, citation_title: str | None, get_urls: list[str]) -> str | None:
@@ -878,7 +886,7 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
contents=contents_list,
conversation_id=update.conversation_id,
response_id=update.response_id,
role=update.role,
role=update.role, # type: ignore[union-attr]
model_id=update.model_id,
continuation_token=update.continuation_token,
additional_properties=update.additional_properties,
@@ -186,7 +186,7 @@ class RawAzureAIInferenceEmbeddingClient(
values: Sequence[Content | str],
*,
options: AzureAIInferenceEmbeddingOptionsT | None = None,
) -> GeneratedEmbeddings[list[float]]:
) -> GeneratedEmbeddings[list[float], AzureAIInferenceEmbeddingOptionsT]:
"""Generate embeddings for text and/or image inputs.
Text inputs (``str`` or ``Content`` with ``type="text"``) are sent to the
@@ -224,7 +224,7 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
if isinstance(tool, MCPTool):
mcp_tools.append(tool)
elif isinstance(tool, (FunctionTool, MutableMapping)):
non_mcp_tools.append(tool)
non_mcp_tools.append(tool) # type: ignore[reportUnknownArgumentType]
# Connect MCP tools and discover their functions BEFORE creating the agent
# This is required because Azure AI Responses API doesn't accept tools at request time
@@ -79,7 +79,7 @@ class AzureAISettings(TypedDict, total=False):
model_deployment_name: str | None
def _extract_project_connection_id(additional_properties: dict[str, Any] | None) -> str | None:
def _extract_project_connection_id(additional_properties: Mapping[str, Any] | None) -> str | None:
"""Extract project_connection_id from tool additional_properties.
Checks for both direct 'project_connection_id' key (programmatic usage)
@@ -95,17 +95,18 @@ def _extract_project_connection_id(additional_properties: dict[str, Any] | None)
return None
# Check for direct project_connection_id (programmatic usage)
project_connection_id = additional_properties.get("project_connection_id")
if isinstance(project_connection_id, str):
return project_connection_id
if (proj_conn_id := additional_properties.get("project_connection_id")) and isinstance(proj_conn_id, str):
return proj_conn_id # type: ignore[no-any-return]
# Check for connection.name structure (declarative/YAML usage)
if "connection" in additional_properties:
conn = additional_properties["connection"]
if isinstance(conn, dict):
name = conn.get("name")
if isinstance(name, str):
return name
if (
(connection := additional_properties.get("connection"))
and isinstance(connection, Mapping)
and (name := connection.get("name")) # type: ignore
and isinstance(name, str)
):
return name # type: ignore[no-any-return]
return None
@@ -189,9 +190,9 @@ def to_azure_ai_agent_tools(
and tool.resources
and "mcp" not in tool.resources
):
if "tool_resources" not in run_options:
run_options["tool_resources"] = {}
run_options["tool_resources"].update(tool.resources)
run_options.setdefault("tool_resources", {})
if isinstance(tool.resources, Mapping):
run_options["tool_resources"].update(tool.resources)
elif isinstance(tool, (dict, MutableMapping)):
# Handle dict-based tools - pass through directly
tool_dict = tool if isinstance(tool, dict) else dict(tool)
@@ -422,9 +423,16 @@ def to_azure_ai_tools(
elif isinstance(tool, Tool):
# Pass through SDK Tool types directly (CodeInterpreterTool, FileSearchTool, etc.)
azure_tools.append(tool)
elif isinstance(tool, MutableMapping):
# Convert mutable mappings into plain dicts for stable typing.
tool_dict: dict[str, Any] = dict(tool)
if tool_dict.get("type") == "mcp":
azure_tools.append(_prepare_mcp_tool_dict_for_azure_ai(tool_dict))
else:
azure_tools.append(tool_dict)
else:
# Pass through dict-based tools directly
azure_tools.append(dict(tool) if isinstance(tool, MutableMapping) else tool) # type: ignore[arg-type]
# Pass through any other supported tool objects unchanged.
azure_tools.append(tool)
return azure_tools
@@ -446,7 +454,16 @@ def _prepare_mcp_tool_dict_for_azure_ai(tool_dict: dict[str, Any]) -> MCPTool:
mcp["server_description"] = description
# Check for project_connection_id
if project_connection_id := tool_dict.get("project_connection_id"):
project_connection_id = tool_dict.get("project_connection_id")
if not isinstance(project_connection_id, str):
additional_properties = tool_dict.get("additional_properties")
project_connection_id = (
_extract_project_connection_id(additional_properties) # pyright: ignore[reportUnknownArgumentType]
if isinstance(additional_properties, Mapping)
else None
)
if project_connection_id:
mcp["project_connection_id"] = project_connection_id
elif headers := tool_dict.get("headers"):
mcp["headers"] = headers
+2 -1
View File
@@ -61,6 +61,7 @@ omit = [
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_azure_ai"]
[tool.mypy]
plugins = ['pydantic.mypy']
@@ -86,7 +87,7 @@ include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_ai"
test = "pytest --cov=agent_framework_azure_ai --cov-report=term-missing:skip-covered tests"
test = "pytest -m \"not integration\" --cov=agent_framework_azure_ai --cov-report=term-missing:skip-covered tests"
[tool.poe.tasks.integration-tests]
cmd = """
@@ -124,7 +124,6 @@ class CosmosHistoryProvider(BaseHistoryProvider):
self._database_client = self._cosmos_client.get_database_client(self.database_name)
async def get_messages(self, session_id: str | None, **kwargs: Any) -> list[Message]:
"""Retrieve stored messages for this session from Azure Cosmos DB."""
await self._ensure_container_proxy()
@@ -146,8 +145,15 @@ class CosmosHistoryProvider(BaseHistoryProvider):
messages: list[Message] = []
async for item in items:
message_payload = item.get("message")
if isinstance(message_payload, dict):
messages.append(Message.from_dict(message_payload))
if not isinstance(message_payload, dict):
logger.warning("Skipping Cosmos DB item with non-mapping message payload.")
continue
try:
msg = Message.from_dict(message_payload) # pyright: ignore[reportUnknownArgumentType]
except ValueError as e:
logger.warning("Failed to deserialize message from Cosmos DB item: %s", e)
continue
messages.append(msg)
return messages
@@ -205,12 +211,8 @@ class CosmosHistoryProvider(BaseHistoryProvider):
async def list_sessions(self) -> list[str]:
"""List all session IDs stored in this provider's Cosmos container."""
await self._ensure_container_proxy()
query = (
"SELECT DISTINCT VALUE c.session_id FROM c WHERE c.source_id = @source_id"
)
parameters: list[dict[str, object]] = [
{"name": "@source_id", "value": self.source_id}
]
query = "SELECT DISTINCT VALUE c.session_id FROM c WHERE c.source_id = @source_id"
parameters: list[dict[str, object]] = [{"name": "@source_id", "value": self.source_id}]
# without a partition key, it is automatically a cross-partition query
items = self._container_proxy.query_items(query=query, parameters=parameters) # type: ignore[union-attr]
@@ -249,11 +251,9 @@ class CosmosHistoryProvider(BaseHistoryProvider):
if self._database_client is None:
raise RuntimeError("Cosmos database client is not initialized.")
self._container_proxy = (
await self._database_client.create_container_if_not_exists(
id=self.container_name,
partition_key=PartitionKey(path="/session_id"),
)
self._container_proxy = await self._database_client.create_container_if_not_exists(
id=self.container_name,
partition_key=PartitionKey(path="/session_id"),
)
@staticmethod
+2 -1
View File
@@ -61,6 +61,7 @@ omit = [
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_azure_cosmos"]
[tool.mypy]
plugins = ['pydantic.mypy']
@@ -85,7 +86,7 @@ executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_cosmos"
test = "pytest --cov=agent_framework_azure_cosmos --cov-report=term-missing:skip-covered tests"
test = "pytest -m \"not integration\" --cov=agent_framework_azure_cosmos --cov-report=term-missing:skip-covered tests"
integration-tests = "pytest tests/test_cosmos_history_provider.py -m integration"
[build-system]
@@ -5,10 +5,11 @@ import asyncio
import os
from agent_framework.azure import AzureOpenAIResponsesClient
from agent_framework_azure_cosmos import CosmosHistoryProvider
from azure.identity.aio import AzureCliCredential
from dotenv import load_dotenv
from agent_framework_azure_cosmos import CosmosHistoryProvider
# Load environment variables from .env file.
load_dotenv()
@@ -31,7 +32,6 @@ Optional:
"""
async def main() -> None:
"""Run the Cosmos history provider sample with an Agent."""
project_endpoint = os.getenv("AZURE_AI_PROJECT_ENDPOINT")
@@ -9,15 +9,16 @@ from contextlib import suppress
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import agent_framework_azure_cosmos._history_provider as history_provider_module
import pytest
from agent_framework import AgentResponse, Message
from agent_framework._sessions import AgentSession, SessionContext
from agent_framework.exceptions import SettingNotFoundError
from agent_framework_azure_cosmos._history_provider import CosmosHistoryProvider
from azure.cosmos.aio import CosmosClient
from azure.cosmos.exceptions import CosmosResourceNotFoundError
import agent_framework_azure_cosmos._history_provider as history_provider_module
from agent_framework_azure_cosmos._history_provider import CosmosHistoryProvider
skip_if_cosmos_integration_tests_disabled = pytest.mark.skipif(
any(
os.getenv(name, "") == ""
@@ -357,9 +358,10 @@ class TestCosmosHistoryProviderClose:
async def test_async_context_manager_preserves_original_exception(self, mock_container: MagicMock) -> None:
provider = CosmosHistoryProvider(source_id="mem", container_client=mock_container)
with patch.object(
provider, "close", AsyncMock(side_effect=RuntimeError("close failed"))
), pytest.raises(ValueError, match="inner error"):
with (
patch.object(provider, "close", AsyncMock(side_effect=RuntimeError("close failed"))),
pytest.raises(ValueError, match="inner error"),
):
async with provider:
raise ValueError("inner error")
@@ -274,10 +274,14 @@ class AgentFunctionApp(DFAppBase):
"""
from agent_framework._workflows._state import State
data = json.loads(inputData)
message_data = data["message"]
data_obj = json.loads(inputData)
if not isinstance(data_obj, dict):
raise ValueError("Activity inputData must decode to a JSON object")
data = cast(dict[str, Any], data_obj)
message_data = data.get("message")
shared_state_snapshot = data.get("shared_state_snapshot", {})
source_executor_ids = data.get("source_executor_ids", [SOURCE_ORCHESTRATOR])
source_executor_ids = cast(list[str], data.get("source_executor_ids", [SOURCE_ORCHESTRATOR]))
if not self.workflow:
raise RuntimeError("Workflow not initialized in AgentFunctionApp")
@@ -299,15 +303,20 @@ class AgentFunctionApp(DFAppBase):
shared_state = State()
# Deserialize shared state values to reconstruct dataclasses/Pydantic models
deserialized_state = {k: deserialize_value(v) for k, v in (shared_state_snapshot or {}).items()}
original_snapshot = dict(deserialized_state)
deserialized_state: dict[str, Any] = {
str(k): deserialize_value(v) for k, v in shared_state_snapshot.items()
}
original_snapshot: dict[str, Any] = dict(deserialized_state)
shared_state.import_state(deserialized_state)
if is_hitl_response:
# Handle HITL response by calling the executor's @response_handler
if not isinstance(message_data, dict):
raise ValueError("HITL message payload must be a JSON object")
await execute_hitl_response_handler(
executor=executor,
hitl_message=message_data,
hitl_message=cast(dict[str, Any], message_data),
shared_state=shared_state,
runner_context=runner_context,
)
@@ -323,11 +332,11 @@ class AgentFunctionApp(DFAppBase):
# Commit pending state changes and export
shared_state.commit()
current_state = shared_state.export_state()
original_keys = set(original_snapshot.keys())
current_keys = set(current_state.keys())
original_keys: set[str] = set(original_snapshot.keys())
current_keys: set[str] = set(current_state.keys())
# Deleted = was in original, not in current
deletes = original_keys - current_keys
deletes: set[str] = original_keys - current_keys
# Updates = keys in current that are new or have different values
updates = {
@@ -348,7 +357,7 @@ class AgentFunctionApp(DFAppBase):
pending_request_info_events = await runner_context.get_pending_request_info_events()
# Serialize pending request info events for orchestrator
serialized_pending_requests = []
serialized_pending_requests: list[dict[str, Any]] = []
for _request_id, event in pending_request_info_events.items():
serialized_pending_requests.append({
"request_id": event.request_id,
@@ -361,7 +370,7 @@ class AgentFunctionApp(DFAppBase):
})
# Serialize messages for JSON compatibility
serialized_sent_messages = []
serialized_sent_messages: list[dict[str, Any]] = []
for _source_id, msg_list in sent_messages.items():
for msg in msg_list:
serialized_sent_messages.append({
@@ -441,6 +450,9 @@ class AgentFunctionApp(DFAppBase):
) -> func.HttpResponse:
"""HTTP endpoint to get workflow status."""
instance_id = req.route_params.get("instanceId")
if not instance_id:
return self._build_error_response("Instance ID is required", status_code=400)
status = await client.get_status(instance_id)
if not status:
@@ -457,17 +469,23 @@ class AgentFunctionApp(DFAppBase):
}
# Add pending HITL requests info if available
custom_status = status.custom_status or {}
if isinstance(custom_status, dict) and custom_status.get("pending_requests"):
if (
(custom_status := status.custom_status)
and isinstance(custom_status, dict)
and (pending_requests_dict := custom_status.get("pending_requests")) # type: ignore
and isinstance(pending_requests_dict, dict)
):
base_url = self._build_base_url(req.url)
pending_requests = []
for req_id, req_data in custom_status["pending_requests"].items():
pending_requests: list[dict[str, Any]] = []
for req_id, req_data in pending_requests_dict.items(): # type: ignore
if not isinstance(req_data, dict):
continue
pending_requests.append({
"requestId": req_id,
"sourceExecutor": req_data.get("source_executor_id"),
"requestData": req_data.get("data"),
"requestType": req_data.get("request_type"),
"responseType": req_data.get("response_type"),
"sourceExecutor": req_data.get("source_executor_id"), # type: ignore[reportUnknownMemberType]
"requestData": req_data.get("data"), # type: ignore[reportUnknownMemberType]
"requestType": req_data.get("request_type"), # type: ignore[reportUnknownMemberType]
"responseType": req_data.get("response_type"), # type: ignore[reportUnknownMemberType]
"respondUrl": f"{base_url}/api/workflow/respond/{instance_id}/{req_id}",
})
response["pendingHumanInputRequests"] = pending_requests
@@ -515,6 +533,11 @@ class AgentFunctionApp(DFAppBase):
mimetype="application/json",
)
# Ensure route handlers are registered (prevents unused function warnings)
_ = start_workflow_orchestration
_ = get_workflow_status
_ = send_hitl_response
def _build_status_url(self, request_url: str, instance_id: str) -> str:
"""Build the status URL for a workflow instance."""
base_url = self._build_base_url(request_url)
@@ -13,22 +13,24 @@ This module adds:
- serialize_value / deserialize_value: convenience aliases for encode/decode
- reconstruct_to_type: for HITL responses where external data (without type markers)
needs to be reconstructed to a known type
- _resolve_type: resolves 'module:class' type keys to Python types
- resolve_type: resolves 'module:class' type keys to Python types
"""
from __future__ import annotations
import importlib
import logging
from contextlib import suppress
from dataclasses import is_dataclass
from typing import Any
from agent_framework._workflows._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
from pydantic import BaseModel
logger = logging.getLogger(__name__)
def _resolve_type(type_key: str) -> type | None:
def resolve_type(type_key: str) -> type | None:
"""Resolve a 'module:class' type key to its Python type.
Args:
@@ -108,11 +110,9 @@ def reconstruct_to_type(value: Any, target_type: type) -> Any:
if value is None:
return None
try:
with suppress(TypeError):
if isinstance(value, target_type):
return value
except TypeError:
pass
if not isinstance(value, dict):
return value
@@ -123,17 +123,18 @@ def reconstruct_to_type(value: Any, target_type: type) -> Any:
return decoded
# Try Pydantic model validation (for unmarked dicts, e.g., external HITL data)
if hasattr(target_type, "model_validate"):
if issubclass(target_type, BaseModel):
try:
return target_type.model_validate(value)
except Exception:
logger.debug("Could not validate Pydantic model %s", target_type)
return value # type: ignore[return-value]
# Try dataclass construction (for unmarked dicts, e.g., external HITL data)
if is_dataclass(target_type) and isinstance(target_type, type):
if is_dataclass(target_type) and isinstance(target_type, type): # type: ignore
try:
return target_type(**value)
except Exception:
logger.debug("Could not construct dataclass %s", target_type)
return value
return value # type: ignore[return-value]
@@ -44,12 +44,13 @@ from agent_framework._workflows._edge import (
SingleEdgeGroup,
SwitchCaseEdgeGroup,
)
from agent_framework._workflows._state import State
from agent_framework_durabletask import AgentSessionId, DurableAgentSession, DurableAIAgent
from azure.durable_functions import DurableOrchestrationContext
from ._context import CapturingRunnerContext
from ._orchestration import AzureFunctionsAgentExecutor
from ._serialization import _resolve_type, deserialize_value, reconstruct_to_type, serialize_value
from ._serialization import deserialize_value, reconstruct_to_type, resolve_type, serialize_value
logger = logging.getLogger(__name__)
@@ -148,7 +149,7 @@ def _evaluate_edge_condition_sync(edge: Edge, message: Any) -> bool:
True if the edge should be traversed, False otherwise
"""
# Access the internal condition directly since should_route is async
condition = edge._condition
condition = edge._condition # pyright: ignore[reportPrivateUsage]
if condition is None:
return True
result = condition(message)
@@ -322,7 +323,8 @@ def _prepare_activity_task(
activity_input_json = json.dumps(activity_input)
# Use the prefixed activity name that matches the registered function
activity_name = f"dafx-{executor_id}"
return context.call_activity(activity_name, activity_input_json)
orchestration_context: Any = context
return orchestration_context.call_activity(activity_name, activity_input_json)
# ============================================================================
@@ -346,13 +348,16 @@ def _process_agent_response(
ExecutorResult containing the processed response
"""
response_text = agent_response.text if agent_response else None
structured_response = None
structured_response: dict[str, Any] | None = None
if agent_response and agent_response.value is not None:
if hasattr(agent_response.value, "model_dump"):
structured_response = agent_response.value.model_dump()
model_dump = getattr(agent_response.value, "model_dump", None)
if callable(model_dump):
dumped = model_dump()
if isinstance(dumped, dict):
structured_response = dumped # type: ignore[assignment]
elif isinstance(agent_response.value, dict):
structured_response = agent_response.value
structured_response = agent_response.value # type: ignore[assignment]
output_message = build_agent_executor_response(
executor_id=executor_id,
@@ -726,7 +731,7 @@ def run_workflow_orchestrator(
if winner == approval_task:
# Cancel the timeout
timeout_task.cancel()
timeout_task.cancel() # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue]
# Get the response
raw_response = approval_task.result
@@ -756,7 +761,7 @@ def run_workflow_orchestrator(
)
else:
# Timeout occurred — cancel the dangling external event listener
approval_task.cancel()
approval_task.cancel() # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue]
logger.warning("HITL request %s timed out after %s hours", request_id, hitl_timeout_hours)
raise TimeoutError(
f"Human-in-the-loop request '{request_id}' timed out after {hitl_timeout_hours} hours."
@@ -864,7 +869,8 @@ def _extract_message_content(message: Any) -> str:
# Extract text from the last message in the request
message_content = message.messages[-1].text or ""
elif isinstance(message, dict):
logger.warning("Unexpected dict message in _extract_message_content. Keys: %s", list(message.keys()))
key_names = list(message.keys()) # type: ignore[union-attr]
logger.warning("Unexpected dict message in _extract_message_content. Keys: %s", key_names) # type: ignore
elif isinstance(message, str):
message_content = message
@@ -879,7 +885,7 @@ def _extract_message_content(message: Any) -> str:
async def execute_hitl_response_handler(
executor: Any,
hitl_message: dict[str, Any],
shared_state: Any,
shared_state: State,
runner_context: CapturingRunnerContext,
) -> None:
"""Execute a HITL response handler on an executor.
@@ -910,7 +916,7 @@ async def execute_hitl_response_handler(
response = _deserialize_hitl_response(response_data, response_type_str)
# Find the matching response handler
handler = executor._find_response_handler(original_request, response)
handler = executor._find_response_handler(original_request, response) # pyright: ignore[reportPrivateUsage]
if handler is None:
logger.warning(
@@ -965,7 +971,7 @@ def _deserialize_hitl_response(response_data: Any, response_type_str: str | None
# Try to deserialize using the type hint
if response_type_str:
response_type = _resolve_type(response_type_str)
response_type = resolve_type(response_type_str)
if response_type:
logger.debug("Found response type %s, attempting reconstruction", response_type)
result = reconstruct_to_type(response_data, response_type)
@@ -67,6 +67,7 @@ omit = [
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_azurefunctions"]
[tool.mypy]
plugins = ['pydantic.mypy']
@@ -92,7 +93,7 @@ include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azurefunctions"
test = "pytest --cov=agent_framework_azurefunctions --cov-report=term-missing:skip-covered tests"
test = "pytest -m \"not integration\" --cov=agent_framework_azurefunctions --cov-report=term-missing:skip-covered tests"
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
@@ -2,8 +2,8 @@
import importlib.metadata
from ._chat_client import BedrockChatClient, BedrockChatOptions, BedrockGuardrailConfig, BedrockSettings
from ._embedding_client import BedrockEmbeddingClient, BedrockEmbeddingOptions, BedrockEmbeddingSettings
from ._chat_client import BedrockChatClient, BedrockChatOptions, BedrockGuardrailConfig, BedrockSettings # type: ignore
from ._embedding_client import BedrockEmbeddingClient, BedrockEmbeddingOptions, BedrockEmbeddingSettings # type: ignore
try:
__version__ = importlib.metadata.version(__name__)
@@ -1,5 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.
# type: ignore
# Because the Bedrock client does not have typing, we are ignoring type issues in this module.
from __future__ import annotations
import asyncio
@@ -288,14 +289,16 @@ class BedrockChatClient(
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
if not settings.get("region"):
settings["region"] = DEFAULT_REGION
region = settings.get("region") or DEFAULT_REGION
chat_model_id = settings.get("chat_model_id")
if client is None:
if client:
self._bedrock_client = client
else:
session = boto3_session or self._create_session(settings)
client = session.client(
self._bedrock_client = session.client(
"bedrock-runtime",
region_name=settings["region"],
region_name=region,
config=BotoConfig(user_agent_extra=AGENT_FRAMEWORK_USER_AGENT),
)
@@ -304,20 +307,28 @@ class BedrockChatClient(
function_invocation_configuration=function_invocation_configuration,
**kwargs,
)
self._bedrock_client = client
self.model_id = settings["chat_model_id"]
self.region = settings["region"]
self.model_id = chat_model_id
self.region = region
@staticmethod
def _create_session(settings: BedrockSettings) -> Boto3Session:
session_kwargs: dict[str, Any] = {"region_name": settings.get("region") or DEFAULT_REGION}
if settings.get("access_key") and settings.get("secret_key"):
session_kwargs["aws_access_key_id"] = settings["access_key"].get_secret_value() # type: ignore[union-attr]
session_kwargs["aws_secret_access_key"] = settings["secret_key"].get_secret_value() # type: ignore[union-attr]
if settings.get("session_token"):
session_kwargs["aws_session_token"] = settings["session_token"].get_secret_value() # type: ignore[union-attr]
access_key = settings.get("access_key")
secret_key = settings.get("secret_key")
session_token = settings.get("session_token")
if access_key is not None and secret_key is not None:
session_kwargs["aws_access_key_id"] = access_key.get_secret_value()
session_kwargs["aws_secret_access_key"] = secret_key.get_secret_value()
if session_token is not None:
session_kwargs["aws_session_token"] = session_token.get_secret_value()
return Boto3Session(**session_kwargs)
def _invoke_converse(self, request: Mapping[str, Any]) -> dict[str, Any]:
response = self._bedrock_client.converse(**request)
if not isinstance(response, Mapping):
raise ChatClientInvalidResponseException("Bedrock converse response must be a mapping.")
return response
@override
def _inner_get_response(
self,
@@ -332,16 +343,20 @@ class BedrockChatClient(
if stream:
# Streaming mode - simulate streaming by yielding a single update
async def _stream() -> AsyncIterable[ChatResponseUpdate]:
response = await asyncio.to_thread(self._bedrock_client.converse, **request)
response = await asyncio.to_thread(self._invoke_converse, request)
parsed_response = self._process_converse_response(response)
contents = list(parsed_response.messages[0].contents if parsed_response.messages else [])
if parsed_response.usage_details:
contents.append(Content.from_usage(usage_details=parsed_response.usage_details)) # type: ignore[arg-type]
raw_finish_reason = (
parsed_response.finish_reason if isinstance(parsed_response.finish_reason, str) else None
)
finish_reason = self._map_finish_reason(raw_finish_reason)
yield ChatResponseUpdate(
response_id=parsed_response.response_id,
contents=contents,
model_id=parsed_response.model_id,
finish_reason=parsed_response.finish_reason,
finish_reason=finish_reason,
raw_representation=parsed_response.raw_representation,
)
@@ -349,7 +364,7 @@ class BedrockChatClient(
# Non-streaming mode
async def _get_response() -> ChatResponse:
raw_response = await asyncio.to_thread(self._bedrock_client.converse, **request)
raw_response = await asyncio.to_thread(self._invoke_converse, request)
return self._process_converse_response(raw_response)
return _get_response()
@@ -529,25 +544,25 @@ class BedrockChatClient(
def _convert_tool_result_to_blocks(self, result: Any) -> list[dict[str, Any]]:
prepared_result = result if isinstance(result, str) else FunctionTool.parse_result(result)
try:
parsed_result = json.loads(prepared_result)
parsed_result: object = json.loads(prepared_result)
except json.JSONDecodeError:
return [{"text": prepared_result}]
return self._convert_prepared_tool_result_to_blocks(parsed_result)
def _convert_prepared_tool_result_to_blocks(self, value: Any) -> list[dict[str, Any]]:
if isinstance(value, list):
def _convert_prepared_tool_result_to_blocks(self, value: object) -> list[dict[str, Any]]:
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
blocks: list[dict[str, Any]] = []
for item in value:
blocks.extend(self._convert_prepared_tool_result_to_blocks(item))
return blocks or [{"text": ""}]
return [self._normalize_tool_result_value(value)]
def _normalize_tool_result_value(self, value: Any) -> dict[str, Any]:
def _normalize_tool_result_value(self, value: object) -> dict[str, Any]:
if isinstance(value, dict):
return {"json": value}
if isinstance(value, (list, tuple)):
return {"json": list(value)}
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
return {"json": [item for item in value]}
if isinstance(value, str):
return {"text": value}
if isinstance(value, (int, float, bool)) or value is None:
@@ -586,12 +601,14 @@ class BedrockChatClient(
return f"tool-call-{uuid4().hex}"
def _process_converse_response(self, response: dict[str, Any]) -> ChatResponse:
output = response.get("output", {})
message = output.get("message", {})
content_blocks = message.get("content", []) or []
"""Convert Bedrock Converse API response to ChatResponse."""
output = response.get("output") or {}
message = output.get("message") or {}
content_blocks = message.get("content") or []
contents = self._parse_message_contents(content_blocks)
chat_message = Message(role="assistant", contents=contents, raw_representation=message)
usage_details = self._parse_usage(response.get("usage") or output.get("usage"))
usage_source = response.get("usage") or output.get("usage")
usage_details = self._parse_usage(usage_source)
finish_reason = self._map_finish_reason(output.get("completionReason") or response.get("stopReason"))
response_id = response.get("responseId") or message.get("id")
model_id = response.get("modelId") or output.get("modelId") or self.model_id
@@ -616,7 +633,7 @@ class BedrockChatClient(
details["total_token_count"] = total_tokens
return details
def _parse_message_contents(self, content_blocks: Sequence[MutableMapping[str, Any]]) -> list[Any]:
def _parse_message_contents(self, content_blocks: Sequence[dict[str, Any]]) -> list[Any]:
contents: list[Any] = []
for block in content_blocks:
if text_value := block.get("text"):
@@ -625,32 +642,50 @@ class BedrockChatClient(
if (json_value := block.get("json")) is not None:
contents.append(Content.from_text(text=json.dumps(json_value), raw_representation=block))
continue
tool_use = block.get("toolUse")
if isinstance(tool_use, MutableMapping):
tool_name = tool_use.get("name")
tool_use_value = block.get("toolUse")
tool_use = (
tool_use_value
if isinstance(tool_use_value, dict)
else dict(tool_use_value)
if isinstance(tool_use_value, Mapping)
else None
)
if tool_use is not None:
tool_name_value = tool_use.get("name")
tool_name = tool_name_value if isinstance(tool_name_value, str) else None
if not tool_name:
raise ChatClientInvalidResponseException(
"Bedrock response missing required tool name in toolUse block."
)
tool_use_id = tool_use.get("toolUseId")
contents.append(
Content.from_function_call(
call_id=tool_use.get("toolUseId") or self._generate_tool_call_id(),
call_id=tool_use_id if isinstance(tool_use_id, str) else self._generate_tool_call_id(),
name=tool_name,
arguments=tool_use.get("input"),
raw_representation=block,
)
)
continue
tool_result = block.get("toolResult")
if isinstance(tool_result, MutableMapping):
status = (tool_result.get("status") or "success").lower()
tool_result_value = block.get("toolResult")
tool_result = (
tool_result_value
if isinstance(tool_result_value, dict)
else dict(tool_result_value)
if isinstance(tool_result_value, Mapping)
else None
)
if tool_result is not None:
status_value = tool_result.get("status")
status = (status_value if isinstance(status_value, str) else "success").lower()
exception = None
if status not in {"success", "ok"}:
exception = RuntimeError(f"Bedrock tool result status: {status}")
result_value = self._convert_bedrock_tool_result_to_value(tool_result.get("content"))
tool_use_id = tool_result.get("toolUseId")
contents.append(
Content.from_function_result(
call_id=tool_result.get("toolUseId") or self._generate_tool_call_id(),
call_id=tool_use_id if isinstance(tool_use_id, str) else self._generate_tool_call_id(),
result=result_value,
exception=str(exception) if exception else None, # type: ignore[arg-type]
raw_representation=block,
@@ -673,24 +708,28 @@ class BedrockChatClient(
"""
return f"https://bedrock-runtime.{self.region}.amazonaws.com"
def _convert_bedrock_tool_result_to_value(self, content: Any) -> Any:
def _convert_bedrock_tool_result_to_value(self, content: object) -> object:
if not content:
return None
if isinstance(content, Sequence) and not isinstance(content, (str, bytes, bytearray)):
values: list[Any] = []
values: list[object] = []
for item in content:
if isinstance(item, MutableMapping):
if (text_value := item.get("text")) is not None:
item_dict = item if isinstance(item, dict) else dict(item) if isinstance(item, Mapping) else None
if item_dict is not None:
text_value = item_dict.get("text")
if isinstance(text_value, str):
values.append(text_value)
continue
if "json" in item:
values.append(item["json"])
if "json" in item_dict:
values.append(item_dict["json"])
continue
values.append(item)
return values[0] if len(values) == 1 else values
if isinstance(content, MutableMapping):
if (text_value := content.get("text")) is not None:
content_dict = content if isinstance(content, dict) else dict(content) if isinstance(content, Mapping) else None
if content_dict is not None:
text_value = content_dict.get("text")
if isinstance(text_value, str):
return text_value
if "json" in content:
return content["json"]
if "json" in content_dict:
return content_dict["json"]
return content
@@ -1,5 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.
# type: ignore
# Because the Bedrock client does not have typing, we are ignoring type issues in this module.
from __future__ import annotations
import asyncio
@@ -122,25 +123,27 @@ class RawBedrockEmbeddingClient(
)
resolved_region = settings.get("region") or DEFAULT_REGION
if client is None:
if client:
self._bedrock_client = client
else:
if not boto3_session:
session_kwargs: dict[str, Any] = {}
if region := settings.get("region"):
session_kwargs["region_name"] = region
if (access_key := settings.get("access_key")) and (secret_key := settings.get("secret_key")):
session_kwargs["aws_access_key_id"] = access_key.get_secret_value() # type: ignore[union-attr]
session_kwargs["aws_secret_access_key"] = secret_key.get_secret_value() # type: ignore[union-attr]
session_kwargs["aws_access_key_id"] = access_key.get_secret_value()
session_kwargs["aws_secret_access_key"] = secret_key.get_secret_value()
if session_token := settings.get("session_token"):
session_kwargs["aws_session_token"] = session_token.get_secret_value() # type: ignore[union-attr]
session_kwargs["aws_session_token"] = session_token.get_secret_value()
boto3_session = Boto3Session(**session_kwargs)
client = boto3_session.client(
region_name = boto3_session.region_name
self._bedrock_client = boto3_session.client(
"bedrock-runtime",
region_name=boto3_session.region_name or resolved_region,
region_name=region_name or resolved_region,
config=BotoConfig(user_agent_extra=AGENT_FRAMEWORK_USER_AGENT),
)
self._bedrock_client = client
self.model_id = settings["embedding_model_id"] # type: ignore[assignment]
self.model_id: str = settings["embedding_model_id"] # type: ignore[assignment] # pyright: ignore[reportTypedDictNotRequiredAccess]
self.region = resolved_region
super().__init__(**kwargs)
@@ -153,7 +156,7 @@ class RawBedrockEmbeddingClient(
values: Sequence[str],
*,
options: BedrockEmbeddingOptionsT | None = None,
) -> GeneratedEmbeddings[list[float]]:
) -> GeneratedEmbeddings[list[float], BedrockEmbeddingOptionsT]:
"""Call the Bedrock invoke_model API for embeddings.
Uses the Amazon Titan Embeddings model format. Each value is embedded
@@ -211,7 +214,6 @@ class RawBedrockEmbeddingClient(
accept="application/json",
body=json.dumps(body),
)
response_body = json.loads(response["body"].read())
embedding = Embedding(
vector=response_body["embedding"],
+3 -2
View File
@@ -60,6 +60,7 @@ omit = [
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_bedrock"]
[tool.mypy]
plugins = ['pydantic.mypy']
@@ -85,8 +86,8 @@ include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_bedrock"
test = "pytest --cov=agent_framework_bedrock --cov-report=term-missing:skip-covered tests"
test = "pytest -m \"not integration\" --cov=agent_framework_bedrock --cov-report=term-missing:skip-covered tests"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
build-backend = "hatchling.build"
+2 -1
View File
@@ -61,6 +61,7 @@ omit = [
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_chatkit"]
exclude = ['tests', 'chatkit-python', 'openai-chatkit-advanced-samples']
[tool.mypy]
@@ -87,7 +88,7 @@ include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_chatkit"
test = "pytest --cov=agent_framework_chatkit --cov-report=term-missing:skip-covered tests"
test = "pytest -m \"not integration\" --cov=agent_framework_chatkit --cov-report=term-missing:skip-covered tests"
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
@@ -225,11 +225,7 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
description: str | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
middleware: Sequence[AgentMiddlewareTypes] | None = None,
tools: ToolTypes
| Callable[..., Any]
| str
| Sequence[ToolTypes | Callable[..., Any] | str]
| None = None,
tools: ToolTypes | Callable[..., Any] | str | Sequence[ToolTypes | Callable[..., Any] | str] | None = None,
default_options: OptionsT | MutableMapping[str, Any] | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
@@ -305,11 +301,7 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
def _normalize_tools(
self,
tools: ToolTypes
| Callable[..., Any]
| str
| Sequence[ToolTypes | Callable[..., Any] | str]
| None,
tools: ToolTypes | Callable[..., Any] | str | Sequence[ToolTypes | Callable[..., Any] | str] | None,
) -> None:
"""Separate built-in tools (strings) from custom tools.
@@ -319,21 +311,17 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
if tools is None:
return
# Normalize to sequence
if isinstance(tools, str):
tools_list: Sequence[Any] = [tools]
elif isinstance(tools, Sequence):
tools_list = list(tools)
else:
tools_list = [tools]
for tool in tools_list:
non_builtin_tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] = []
if not isinstance(tools, list):
tools = [tools] # type: ignore[assignment, reportUnknownVariableType]
for tool in tools: # type: ignore[reportUnknownVariableType]
if isinstance(tool, str):
self._builtin_tools.append(tool)
else:
# Use normalize_tools for custom tools
normalized = normalize_tools(tool)
self._custom_tools.extend(normalized)
non_builtin_tools.append(tool) # type: ignore[union-attr, reportUnknownArgumentType]
if not non_builtin_tools:
return
self._custom_tools.extend(normalize_tools(non_builtin_tools)) # type: ignore[reportUnknownVariableType]
async def __aenter__(self) -> RawClaudeAgent[OptionsT]:
"""Start the agent when entering async context."""
@@ -378,9 +366,7 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
session_id: The session ID to use, or None for a new session.
"""
needs_new_client = (
not self._started
or self._client is None
or (session_id and session_id != self._current_session_id)
not self._started or self._client is None or (session_id and session_id != self._current_session_id)
)
if needs_new_client:
@@ -403,9 +389,7 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
self._client = None
raise AgentException(f"Failed to start Claude SDK client: {ex}") from ex
def _prepare_client_options(
self, resume_session_id: str | None = None
) -> SDKOptions:
def _prepare_client_options(self, resume_session_id: str | None = None) -> SDKOptions:
"""Prepare SDK options for client initialization.
Args:
@@ -445,9 +429,7 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
# Prepare custom tools (FunctionTool instances)
custom_tools_server, custom_tool_names = (
self._prepare_tools(self._custom_tools)
if self._custom_tools
else (None, [])
self._prepare_tools(self._custom_tools) if self._custom_tools else (None, [])
)
# MCP servers - merge user-provided servers with custom tools server
@@ -494,13 +476,9 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
if not sdk_tools:
return None, []
return create_sdk_mcp_server(
name=TOOLS_MCP_SERVER_NAME, tools=sdk_tools
), tool_names
return create_sdk_mcp_server(name=TOOLS_MCP_SERVER_NAME, tools=sdk_tools), tool_names
def _function_tool_to_sdk_mcp_tool(
self, func_tool: FunctionTool
) -> SdkMcpTool[Any]:
def _function_tool_to_sdk_mcp_tool(self, func_tool: FunctionTool) -> SdkMcpTool[Any]:
"""Convert a FunctionTool to an SDK MCP tool.
Args:
@@ -523,9 +501,7 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
return {"content": [{"type": "text", "text": f"Error: {e}"}]}
# Get JSON schema from pydantic model
schema: dict[str, Any] = (
func_tool.input_model.model_json_schema() if func_tool.input_model else {}
)
schema: dict[str, Any] = func_tool.input_model.model_json_schema() if func_tool.input_model else {}
input_schema: dict[str, Any] = {
"type": "object",
"properties": schema.get("properties", {}),
@@ -586,9 +562,7 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
opts["instructions"] = system_prompt
return opts
def _finalize_response(
self, updates: Sequence[AgentResponseUpdate]
) -> AgentResponse[Any]:
def _finalize_response(self, updates: Sequence[AgentResponseUpdate]) -> AgentResponse[Any]:
"""Build AgentResponse and propagate structured_output as value.
Args:
@@ -627,10 +601,7 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
stream: bool = False,
session: AgentSession | None = None,
**kwargs: Any,
) -> (
Awaitable[AgentResponse[Any]]
| ResponseStream[AgentResponseUpdate, AgentResponse[Any]]
):
) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]:
"""Run the agent with the given messages.
Args:
@@ -696,11 +667,7 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
if text:
yield AgentResponseUpdate(
role="assistant",
contents=[
Content.from_text(
text=text, raw_representation=message
)
],
contents=[Content.from_text(text=text, raw_representation=message)],
raw_representation=message,
)
elif delta_type == "thinking_delta":
@@ -708,11 +675,7 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
if thinking:
yield AgentResponseUpdate(
role="assistant",
contents=[
Content.from_text_reasoning(
text=thinking, raw_representation=message
)
],
contents=[Content.from_text_reasoning(text=thinking, raw_representation=message)],
raw_representation=message,
)
elif isinstance(message, AssistantMessage):
@@ -729,9 +692,7 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
"server_error": "Claude API server error",
"unknown": "Unknown error from Claude API",
}
error_msg = error_messages.get(
message.error, f"Claude API error: {message.error}"
)
error_msg = error_messages.get(message.error, f"Claude API error: {message.error}")
# Extract any error details from content blocks
if message.content:
for block in message.content:
+2 -1
View File
@@ -61,6 +61,7 @@ omit = [
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_claude"]
exclude = ['tests']
[tool.mypy]
@@ -87,7 +88,7 @@ include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_claude"
test = "pytest --cov=agent_framework_claude --cov-report=term-missing:skip-covered tests"
test = "pytest -m \"not integration\" --cov=agent_framework_claude --cov-report=term-missing:skip-covered tests"
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
@@ -133,43 +133,47 @@ class CopilotStudioAgent(BaseAgent):
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
resolved_environment_id = copilot_studio_settings.get("environmentid")
resolved_agent_identifier = copilot_studio_settings.get("schemaname")
resolved_client_id = copilot_studio_settings.get("agentappid")
resolved_tenant_id = copilot_studio_settings.get("tenantid")
if not settings:
if not copilot_studio_settings["environmentid"]:
if not resolved_environment_id:
raise ValueError(
"Copilot Studio environment ID is required. Set via 'environment_id' parameter "
"or 'COPILOTSTUDIOAGENT__ENVIRONMENTID' environment variable."
)
if not copilot_studio_settings["schemaname"]:
if not resolved_agent_identifier:
raise ValueError(
"Copilot Studio agent identifier/schema name is required. Set via 'agent_identifier' parameter "
"or 'COPILOTSTUDIOAGENT__SCHEMANAME' environment variable."
)
settings = ConnectionSettings(
environment_id=copilot_studio_settings["environmentid"],
agent_identifier=copilot_studio_settings["schemaname"],
environment_id=resolved_environment_id,
agent_identifier=resolved_agent_identifier,
cloud=cloud,
copilot_agent_type=agent_type,
custom_power_platform_cloud=custom_power_platform_cloud,
)
if not token:
if not copilot_studio_settings["agentappid"]:
if not resolved_client_id:
raise ValueError(
"Copilot Studio client ID is required. Set via 'client_id' parameter "
"or 'COPILOTSTUDIOAGENT__AGENTAPPID' environment variable."
)
if not copilot_studio_settings["tenantid"]:
if not resolved_tenant_id:
raise ValueError(
"Copilot Studio tenant ID is required. Set via 'tenant_id' parameter "
"or 'COPILOTSTUDIOAGENT__TENANTID' environment variable."
)
token = acquire_token(
client_id=copilot_studio_settings["agentappid"],
tenant_id=copilot_studio_settings["tenantid"],
client_id=resolved_client_id,
tenant_id=resolved_tenant_id,
username=username,
token_cache=token_cache,
scopes=scopes,
+2 -1
View File
@@ -61,6 +61,7 @@ omit = [
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_copilotstudio"]
[tool.mypy]
plugins = ['pydantic.mypy']
@@ -86,7 +87,7 @@ include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_copilotstudio"
test = "pytest --cov=agent_framework_copilotstudio --cov-report=term-missing:skip-covered tests"
test = "pytest -m \"not integration\" --cov=agent_framework_copilotstudio --cov-report=term-missing:skip-covered tests"
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
@@ -205,9 +205,6 @@ __all__ = [
"AgentResponseUpdate",
"AgentRunInputs",
"AgentSession",
"Skill",
"SkillResource",
"SkillsProvider",
"Annotation",
"BaseAgent",
"BaseChatClient",
@@ -272,6 +269,9 @@ __all__ = [
"SecretString",
"SessionContext",
"SingleEdgeGroup",
"Skill",
"SkillResource",
"SkillsProvider",
"SubWorkflowRequestMessage",
"SubWorkflowResponseMessage",
"SupportsAgentRun",
+36 -27
View File
@@ -83,10 +83,13 @@ OptionsCoT = TypeVar(
def _get_tool_name(tool: Any) -> str | None:
"""Extract a tool's name from either an object with a .name attribute or a dict tool definition."""
if isinstance(tool, dict):
func = tool.get("function")
if isinstance(func, dict):
return func.get("name")
if isinstance(tool, Mapping):
tool_mapping = cast(Mapping[str, Any], tool)
func = tool_mapping.get("function")
if isinstance(func, Mapping):
func_mapping = cast(Mapping[str, Any], func)
name = func_mapping.get("name")
return name if isinstance(name, str) else None
return None
return getattr(tool, "name", None)
@@ -164,12 +167,12 @@ def _sanitize_agent_name(agent_name: str | None) -> str | None:
class _RunContext(TypedDict):
session: AgentSession | None
session_context: SessionContext
input_messages: list[Message]
session_messages: list[Message]
input_messages: Sequence[Message]
session_messages: Sequence[Message]
agent_name: str
chat_options: dict[str, Any]
filtered_kwargs: dict[str, Any]
finalize_kwargs: dict[str, Any]
chat_options: MutableMapping[str, Any]
filtered_kwargs: Mapping[str, Any]
finalize_kwargs: Mapping[str, Any]
# region Agent Protocol
@@ -770,10 +773,9 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
should check if there is already an agent name defined, and if not
set it to this value.
"""
if hasattr(self.client, "_update_agent_name_and_description") and callable(
self.client._update_agent_name_and_description
): # type: ignore[reportAttributeAccessIssue, attr-defined]
self.client._update_agent_name_and_description(self.name, self.description) # type: ignore[reportAttributeAccessIssue, attr-defined]
update_fn = getattr(self.client, "_update_agent_name_and_description", None)
if callable(update_fn):
update_fn(self.name, self.description)
@overload
def run(
@@ -860,11 +862,14 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
options=options,
kwargs=kwargs,
)
response = await self.client.get_response( # type: ignore[call-overload]
messages=ctx["session_messages"],
stream=False,
options=ctx["chat_options"],
**ctx["filtered_kwargs"],
response = cast(
ChatResponse[Any],
await self.client.get_response( # type: ignore
messages=ctx["session_messages"],
stream=False,
options=ctx["chat_options"], # type: ignore[reportArgumentType]
**ctx["filtered_kwargs"],
),
)
if not response:
@@ -930,7 +935,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
)
await self._run_after_providers(session=ctx["session"], context=session_context)
async def _get_stream() -> ResponseStream[ChatResponseUpdate, ChatResponse]:
async def _get_stream() -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]:
ctx_holder["ctx"] = await self._prepare_run_context(
messages=messages,
session=session,
@@ -942,7 +947,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
return self.client.get_response( # type: ignore[call-overload, no-any-return]
messages=ctx["session_messages"],
stream=True,
options=ctx["chat_options"],
options=ctx["chat_options"], # type: ignore[reportArgumentType]
**ctx["filtered_kwargs"],
)
@@ -965,12 +970,12 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
rf = (
ctx.get("chat_options", {}).get("response_format")
if ctx
else (options.get("response_format") if options else None)
else (options.get("response_format") if options else None) # type: ignore[union-attr]
)
return self._finalize_response_updates(updates, response_format=rf)
return (
ResponseStream
ResponseStream # type: ignore[reportUnknownMemberType]
.from_awaitable(_get_stream())
.map(
transform=partial(
@@ -988,10 +993,13 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
updates: Sequence[AgentResponseUpdate],
*,
response_format: Any | None = None,
) -> AgentResponse:
) -> AgentResponse[Any]:
"""Finalize response updates into a single AgentResponse."""
output_format_type = response_format if isinstance(response_format, type) else None
return AgentResponse.from_updates(updates, output_format_type=output_format_type)
return AgentResponse.from_updates( # pyright: ignore[reportUnknownVariableType]
updates,
output_format_type=output_format_type,
)
@staticmethod
def _extract_conversation_id_from_streaming_response(response: AgentResponse[Any]) -> str | None:
@@ -1000,10 +1008,11 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
if raw is None:
return None
raw_items: list[Any] = raw if isinstance(raw, list) else [raw]
raw_items: list[Any] = list(cast(Any, raw)) if isinstance(raw, list) else [raw]
for item in reversed(raw_items):
if isinstance(item, Mapping):
value = item.get("conversation_id")
mapped_item = cast(Mapping[str, Any], item)
value = mapped_item.get("conversation_id")
if isinstance(value, str) and value:
return value
continue
@@ -1074,7 +1083,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
# Merge runtime kwargs into additional_function_arguments so they're available
# in function middleware context and tool invocation.
existing_additional_args = opts.pop("additional_function_arguments", None) or {}
existing_additional_args: dict[str, Any] = opts.pop("additional_function_arguments", None) or {}
additional_function_arguments = {**kwargs, **existing_additional_args}
# Include session so as_tool() wrappers with propagate_session=True can access it.
if active_session is not None:
@@ -317,10 +317,13 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]):
updates: Sequence[ChatResponseUpdate],
*,
response_format: Any | None = None,
) -> ChatResponse:
) -> ChatResponse[Any]:
"""Finalize response updates into a single ChatResponse."""
output_format_type = response_format if isinstance(response_format, type) else None
return ChatResponse.from_updates(updates, output_format_type=output_format_type)
return ChatResponse.from_updates( # pyright: ignore[reportUnknownVariableType]
updates,
output_format_type=output_format_type,
)
def _build_response_stream(
self,
@@ -782,7 +785,7 @@ class BaseEmbeddingClient(SerializationMixin, ABC, Generic[EmbeddingInputT, Embe
values: Sequence[EmbeddingInputT],
*,
options: EmbeddingOptionsT | None = None,
) -> GeneratedEmbeddings[EmbeddingT]:
) -> GeneratedEmbeddings[EmbeddingT, EmbeddingOptionsT]:
"""Generate embeddings for the given values.
Args:
@@ -8,7 +8,7 @@ import sys
from abc import ABC, abstractmethod
from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, Sequence
from enum import Enum
from typing import TYPE_CHECKING, Any, Generic, Literal, TypeAlias, overload
from typing import TYPE_CHECKING, Any, Generic, Literal, TypeAlias, cast, overload
from ._clients import SupportsChatGetResponse
from ._types import (
@@ -170,9 +170,9 @@ class AgentContext:
self.session = session
self.options = options
self.stream = stream
self.metadata = metadata if metadata is not None else {}
self.metadata: dict[str, Any] = dict(metadata) if metadata is not None else {}
self.result = result
self.kwargs = kwargs if kwargs is not None else {}
self.kwargs: dict[str, Any] = dict(kwargs) if kwargs is not None else {}
self.stream_transform_hooks = list(stream_transform_hooks or [])
self.stream_result_hooks = list(stream_result_hooks or [])
self.stream_cleanup_hooks = list(stream_cleanup_hooks or [])
@@ -231,9 +231,9 @@ class FunctionInvocationContext:
"""
self.function = function
self.arguments = arguments
self.metadata = metadata if metadata is not None else {}
self.metadata: dict[str, Any] = dict(metadata) if metadata is not None else {}
self.result = result
self.kwargs = kwargs if kwargs is not None else {}
self.kwargs: dict[str, Any] = dict(kwargs) if kwargs is not None else {}
class ChatContext:
@@ -314,9 +314,9 @@ class ChatContext:
self.messages = messages
self.options = options
self.stream = stream
self.metadata = metadata if metadata is not None else {}
self.metadata: dict[str, Any] = dict(metadata) if metadata is not None else {}
self.result = result
self.kwargs = kwargs if kwargs is not None else {}
self.kwargs: dict[str, Any] = dict(kwargs) if kwargs is not None else {}
self.stream_transform_hooks = list(stream_transform_hooks or [])
self.stream_result_hooks = list(stream_result_hooks or [])
self.stream_cleanup_hooks = list(stream_cleanup_hooks or [])
@@ -754,9 +754,11 @@ class AgentMiddlewarePipeline(BaseMiddlewarePipeline):
if index >= len(self._middleware):
async def final_wrapper() -> None:
context.result = final_handler(context) # type: ignore[assignment]
if inspect.isawaitable(context.result):
context.result = await context.result
result = final_handler(context)
if inspect.isawaitable(result):
context.result = await cast(Awaitable[AgentResponse], result)
else:
context.result = result
return final_wrapper
@@ -893,12 +895,17 @@ class ChatMiddlewarePipeline(BaseMiddlewarePipeline):
The chat response after processing through all middleware.
"""
if not self._middleware:
context.result = final_handler(context) # type: ignore[assignment]
if isinstance(context.result, Awaitable):
context.result = await context.result
if context.stream and not isinstance(context.result, ResponseStream):
result = final_handler(context)
if inspect.isawaitable(result):
resolved_result: ChatResponse | ResponseStream[ChatResponseUpdate, ChatResponse] = await cast(
Awaitable[ChatResponse], result
)
else:
resolved_result = result
context.result = resolved_result
if context.stream and not isinstance(resolved_result, ResponseStream):
raise ValueError("Streaming agent middleware requires a ResponseStream result.")
return context.result
return resolved_result
def create_next_handler(index: int) -> Callable[[], Awaitable[None]]:
if index >= len(self._middleware):
@@ -1038,7 +1045,10 @@ class ChatMiddlewareLayer(Generic[OptionsCoT]):
# If result is ChatResponse (shouldn't happen for streaming), raise error
raise ValueError("Expected ResponseStream for streaming, got ChatResponse")
return ResponseStream.from_awaitable(_execute_stream())
return cast(
ResponseStream[ChatResponseUpdate, ChatResponse[Any]],
cast(Any, ResponseStream).from_awaitable(_execute_stream()),
)
# For non-streaming, return the coroutine directly
return _execute() # type: ignore[return-value]
@@ -1120,7 +1130,10 @@ class AgentMiddlewareLayer:
) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]:
"""MiddlewareTypes-enabled unified run method."""
# Re-categorize self.middleware at runtime to support dynamic changes
base_middleware = getattr(self, "middleware", None) or []
base_middleware_attr = getattr(self, "middleware", None)
base_middleware: Sequence[MiddlewareTypes] = (
cast(Sequence[MiddlewareTypes], base_middleware_attr) if isinstance(base_middleware_attr, Sequence) else []
)
base_middleware_list = categorize_middleware(base_middleware)
run_middleware_list = categorize_middleware(middleware)
pipeline = AgentMiddlewarePipeline(*base_middleware_list["agent"], *run_middleware_list["agent"])
@@ -1166,7 +1179,10 @@ class AgentMiddlewareLayer:
# If result is AgentResponse (shouldn't happen for streaming), convert to stream
raise ValueError("Expected ResponseStream for streaming, got AgentResponse")
return ResponseStream.from_awaitable(_execute_stream())
return cast(
ResponseStream[AgentResponseUpdate, AgentResponse[Any]],
cast(Any, ResponseStream).from_awaitable(_execute_stream()),
)
# For non-streaming, return the coroutine directly
return _execute() # type: ignore[return-value]
@@ -303,7 +303,7 @@ class SerializationMixin:
# Handle lists containing SerializationProtocol objects
if isinstance(value, list):
value_as_list: list[Any] = []
for item in value:
for item in value: # pyright: ignore[reportUnknownVariableType]
if isinstance(item, SerializationProtocol):
value_as_list.append(item.to_dict(exclude=exclude, exclude_none=exclude_none))
continue
@@ -311,7 +311,7 @@ class SerializationMixin:
value_as_list.append(item)
continue
logger.debug(
f"Skipping non-serializable item in list attribute '{key}' of type {type(item).__name__}"
f"Skipping non-serializable item in list attribute '{key}' of type {type(item).__name__}" # pyright: ignore[reportUnknownArgumentType]
)
result[key] = value_as_list
continue
@@ -320,21 +320,22 @@ class SerializationMixin:
from datetime import date, datetime, time
serialized_dict: dict[str, Any] = {}
for k, v in value.items():
for raw_key, v in value.items(): # pyright: ignore[reportUnknownVariableType]
dict_key = str(raw_key) # pyright: ignore[reportUnknownArgumentType]
if isinstance(v, SerializationProtocol):
serialized_dict[k] = v.to_dict(exclude=exclude, exclude_none=exclude_none)
serialized_dict[dict_key] = v.to_dict(exclude=exclude, exclude_none=exclude_none)
continue
# Convert datetime objects to strings
if isinstance(v, (datetime, date, time)):
serialized_dict[k] = str(v)
serialized_dict[dict_key] = str(v)
continue
# Check if the value is JSON serializable
if is_serializable(v):
serialized_dict[k] = v
serialized_dict[dict_key] = v
continue
logger.debug(
f"Skipping non-serializable value for key '{k}' in dict attribute '{key}' "
f"of type {type(v).__name__}"
f"Skipping non-serializable value for key '{dict_key}' in dict attribute '{key}' "
f"of type {type(v).__name__}" # pyright: ignore[reportUnknownArgumentType]
)
result[key] = serialized_dict
continue
@@ -505,7 +506,8 @@ class SerializationMixin:
# Only apply if the instance matches
if kwargs.get(field) == name and isinstance(dep_value, dict):
# Apply instance-specific dependencies
for param_name, param_value in dep_value.items():
for raw_param_name, param_value in dep_value.items(): # pyright: ignore[reportUnknownVariableType]
param_name = str(raw_param_name) # pyright: ignore[reportUnknownArgumentType]
if param_name not in cls.INJECTABLE:
logger.debug(
f"Dependency '{param_name}' for type '{type_id}' is not in INJECTABLE set. "
@@ -16,7 +16,7 @@ import copy
import uuid
from abc import abstractmethod
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, ClassVar
from typing import TYPE_CHECKING, Any, ClassVar, cast
from ._types import AgentResponse, Message
@@ -92,7 +92,7 @@ def _deserialize_value(value: Any) -> Any:
from pydantic import BaseModel
if issubclass(cls, BaseModel):
data = {k: v for k, v in value.items() if k != "type"}
data: dict[str, Any] = {str(k): v for k, v in value.items() if k != "type"} # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType]
return cls.model_validate(data)
except ImportError:
pass
@@ -229,8 +229,11 @@ class SessionContext:
tools: The tools to add.
"""
for tool in tools:
if hasattr(tool, "additional_properties") and isinstance(tool.additional_properties, dict):
tool.additional_properties["context_source"] = source_id
if hasattr(tool, "additional_properties"):
additional_properties_obj = tool.additional_properties
if isinstance(additional_properties_obj, dict):
additional_properties = cast(dict[str, Any], additional_properties_obj)
additional_properties["context_source"] = source_id
self.tools.extend(tools)
def get_messages(
@@ -215,9 +215,7 @@ def load_settings(
raise FileNotFoundError(env_file_path)
raw_dotenv_values = dotenv_values(dotenv_path=env_file_path, encoding=encoding)
loaded_dotenv_values = {
key: value for key, value in raw_dotenv_values.items() if key is not None and value is not None
}
loaded_dotenv_values = {key: value for key, value in raw_dotenv_values.items() if value is not None}
# Filter out None overrides so defaults / env vars are preserved
overrides = {k: v for k, v in overrides.items() if v is not None}
@@ -151,6 +151,7 @@ class Skill:
content="Use this skill for DB tasks.",
)
@skill.resource
def get_schema() -> str:
return "CREATE TABLE ..."
@@ -972,9 +973,7 @@ def _load_skills(
if skills:
for code_skill in skills:
error = _validate_skill_metadata(
code_skill.name, code_skill.description, "code skill"
)
error = _validate_skill_metadata(code_skill.name, code_skill.description, "code skill")
if error:
logger.warning(error)
continue
+102 -335
View File
@@ -27,7 +27,7 @@ from typing import (
Literal,
TypeAlias,
TypedDict,
Union,
cast,
get_args,
get_origin,
overload,
@@ -77,6 +77,7 @@ else:
logger = logging.getLogger("agent_framework")
DEFAULT_MAX_ITERATIONS: Final[int] = 40
DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST: Final[int] = 3
SHELL_TOOL_KIND_VALUE: Final[str] = "shell"
@@ -84,7 +85,7 @@ ChatClientT = TypeVar("ChatClientT", bound="SupportsChatGetResponse[Any]")
# region Helpers
def _parse_inputs(
def _parse_inputs( # pyright: ignore[reportUnusedFunction]
inputs: Content | dict[str, Any] | str | list[Content | dict[str, Any] | str] | None,
) -> list[Content]:
"""Parse the inputs for a tool, ensuring they are of type Content.
@@ -352,7 +353,8 @@ class FunctionTool(SerializationMixin):
def declaration_only(self) -> bool:
"""Indicate whether the function is declaration only (i.e., has no implementation)."""
# Check for explicit _declaration_only attribute first (used in tests)
if hasattr(self, "_declaration_only") and self._declaration_only:
declaration_flag = getattr(self, "_declaration_only", False)
if isinstance(declaration_flag, bool) and declaration_flag:
return True
return self.func is None
@@ -430,10 +432,13 @@ class FunctionTool(SerializationMixin):
)
self.invocation_count += 1
try:
func = self.func
if func is None:
raise ToolException(f"Function '{self.name}' has no implementation.")
# If we have a bound instance, call the function with self
if self._instance is not None:
return self.func(self._instance, *args, **kwargs)
return self.func(*args, **kwargs) # type:ignore[misc]
return func(self._instance, *args, **kwargs)
return func(*args, **kwargs)
except Exception:
self.invocation_exception_count += 1
raise
@@ -600,9 +605,11 @@ class FunctionTool(SerializationMixin):
from ._types import Content
if isinstance(value, list):
return [FunctionTool._make_dumpable(item) for item in value]
list_value = cast(list[object], value)
return [FunctionTool._make_dumpable(item) for item in list_value]
if isinstance(value, dict):
return {k: FunctionTool._make_dumpable(v) for k, v in value.items()}
dict_value = cast(dict[object, object], value)
return {key: FunctionTool._make_dumpable(item) for key, item in dict_value.items()}
if isinstance(value, Content):
return value.to_dict(exclude={"raw_representation", "additional_properties"})
if isinstance(value, BaseModel):
@@ -661,7 +668,7 @@ class FunctionTool(SerializationMixin):
return as_dict
ToolTypes: TypeAlias = FunctionTool | MCPTool | Mapping[str, Any] | Any
ToolTypes: TypeAlias = FunctionTool | MCPTool | Mapping[str, Any] | object
def normalize_tools(
@@ -679,27 +686,31 @@ def normalize_tools(
if not tools:
return []
tool_items = (
list(tools)
if isinstance(tools, Sequence) and not isinstance(tools, (str, bytes, bytearray, Mapping))
else [tools]
)
if isinstance(tools, (str, bytes, bytearray, Mapping)) or not isinstance(tools, Sequence):
tools = cast(list[ToolTypes | Callable[..., Any]], [tools])
from ._mcp import MCPTool
normalized: list[ToolTypes] = []
for tool_item in tool_items:
for tool_item in tools: # type: ignore[reportUnknownVariableType]
# check known types, these are also callable, so we need to do that first
if isinstance(tool_item, (FunctionTool, Mapping, MCPTool)):
if isinstance(tool_item, FunctionTool):
normalized.append(tool_item)
continue
if callable(tool_item):
if isinstance(tool_item, dict):
normalized.append(tool_item) # type: ignore[reportUnknownArgumentType]
continue
if isinstance(tool_item, MCPTool):
normalized.append(tool_item)
continue
if callable(tool_item): # type: ignore[reportUnknownArgumentType]
normalized.append(tool(tool_item))
continue
normalized.append(tool_item)
normalized.append(tool_item) # type: ignore[reportUnknownArgumentType]
return normalized
def _tools_to_dict(
def _tools_to_dict( # pyright: ignore[reportUnusedFunction]
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None,
) -> list[str | dict[str, Any]] | None:
"""Parse the tools to a dict.
@@ -722,8 +733,8 @@ def _tools_to_dict(
if isinstance(tool_item, SerializationMixin):
results.append(tool_item.to_dict())
continue
if isinstance(tool_item, Mapping):
results.append(dict(tool_item))
if isinstance(tool_item, dict):
results.append(tool_item) # type: ignore[reportUnknownArgumentType]
continue
logger.warning("Can't parse tool.")
return results
@@ -795,32 +806,28 @@ def _validate_arguments_against_schema(
"""Run lightweight argument checks for schema-supplied tools."""
parsed_arguments = dict(arguments)
required_raw = schema.get("required", [])
required_fields = [field for field in required_raw if isinstance(field, str)]
required_fields = [field for field in schema.get("required", []) if isinstance(field, str)]
missing_fields = [field for field in required_fields if field not in parsed_arguments]
if missing_fields:
raise TypeError(f"Missing required argument(s) for '{tool_name}': {', '.join(sorted(missing_fields))}")
properties_raw = schema.get("properties")
properties = properties_raw if isinstance(properties_raw, Mapping) else {}
properties: Mapping[str, Any] = schema.get("properties", {})
if schema.get("additionalProperties") is False:
unexpected_fields = sorted(field for field in parsed_arguments if field not in properties)
if unexpected_fields:
raise TypeError(f"Unexpected argument(s) for '{tool_name}': {', '.join(unexpected_fields)}")
for field_name, field_value in parsed_arguments.items():
field_schema = properties.get(field_name)
if not isinstance(field_schema, Mapping):
if not isinstance(properties.get(field_name), dict):
continue
enum_values = field_schema.get("enum")
enum_values = properties.get(field_name, {}).get("enum") # type: ignore
if isinstance(enum_values, list) and enum_values and field_value not in enum_values:
raise TypeError(
f"Invalid value for '{field_name}' in '{tool_name}': {field_value!r} is not in {enum_values!r}"
)
schema_type = field_schema.get("type")
schema_type = properties.get(field_name, {}).get("type") # type: ignore
if isinstance(schema_type, str):
if not _matches_json_schema_type(field_value, schema_type):
raise TypeError(
@@ -830,7 +837,7 @@ def _validate_arguments_against_schema(
continue
if isinstance(schema_type, list):
allowed_types = [item for item in schema_type if isinstance(item, str)]
allowed_types: list[str] = [item for item in schema_type if isinstance(item, str)] # type: ignore[reportUnknownVariableType]
if allowed_types and not any(_matches_json_schema_type(field_value, item) for item in allowed_types):
raise TypeError(
f"Invalid type for '{field_name}' in '{tool_name}': expected one of "
@@ -840,240 +847,6 @@ def _validate_arguments_against_schema(
return parsed_arguments
# Map JSON Schema types to Pydantic types
TYPE_MAPPING = {
"string": str,
"integer": int,
"number": float,
"boolean": bool,
"array": list,
"object": dict,
"null": type(None),
}
def _build_pydantic_model_from_json_schema(
model_name: str,
schema: Mapping[str, Any],
) -> type[BaseModel]:
"""Creates a Pydantic model from JSON Schema with support for $refs, nested objects, and typed arrays.
Args:
model_name: The name of the model to be created.
schema: The JSON Schema definition (should contain 'properties', 'required', '$defs', etc.).
Returns:
The dynamically created Pydantic model class.
"""
properties = schema.get("properties")
required = schema.get("required", [])
definitions = schema.get("$defs", {})
# Check if 'properties' is missing or not a dictionary
if not properties:
return create_model(f"{model_name}_input")
def _resolve_literal_type(prop_details: dict[str, Any]) -> type | None:
"""Check if property should be a Literal type (const or enum).
Args:
prop_details: The JSON Schema property details
Returns:
Literal type if const or enum is present, None otherwise
"""
# const → Literal["value"]
if "const" in prop_details:
return Literal[prop_details["const"]] # type: ignore
# enum → Literal["a", "b", ...]
if "enum" in prop_details and isinstance(prop_details["enum"], list):
enum_values = prop_details["enum"]
if enum_values:
return Literal[tuple(enum_values)] # type: ignore
return None
def _resolve_type(prop_details: dict[str, Any], parent_name: str = "") -> type:
"""Resolve JSON Schema type to Python type, handling $ref, nested objects, and typed arrays.
Args:
prop_details: The JSON Schema property details
parent_name: Name to use for creating nested models (for uniqueness)
Returns:
Python type annotation (could be int, str, list[str], or a nested Pydantic model)
"""
# Handle oneOf + discriminator (polymorphic objects)
if "oneOf" in prop_details and "discriminator" in prop_details:
discriminator = prop_details["discriminator"]
disc_field = discriminator.get("propertyName")
variants = []
for variant in prop_details["oneOf"]:
if "$ref" in variant:
ref = variant["$ref"]
if ref.startswith("#/$defs/"):
def_name = ref.split("/")[-1]
resolved = definitions.get(def_name)
if resolved:
variant_model = _resolve_type(
resolved,
parent_name=f"{parent_name}_{def_name}",
)
variants.append(variant_model)
if variants and disc_field:
return Annotated[
Union[tuple(variants)], # type: ignore
Field(discriminator=disc_field),
]
# Handle $ref by resolving the reference
if "$ref" in prop_details:
ref = prop_details["$ref"]
# Extract the reference path (e.g., "#/$defs/CustomerIdParam" -> "CustomerIdParam")
if ref.startswith("#/$defs/"):
def_name = ref.split("/")[-1]
if def_name in definitions:
# Resolve the reference and use its type
resolved = definitions[def_name]
return _resolve_type(resolved, def_name)
# If we can't resolve the ref, default to dict for safety
return dict
# Map JSON Schema types to Python types
json_type = prop_details.get("type", "string")
match json_type:
case "integer":
return int
case "number":
return float
case "boolean":
return bool
case "array":
# Handle typed arrays
items_schema = prop_details.get("items")
if items_schema and isinstance(items_schema, dict):
# Recursively resolve the item type
item_type = _resolve_type(items_schema, f"{parent_name}_item")
# Return list[ItemType] instead of bare list
return list[item_type] # type: ignore
# If no items schema or invalid, return bare list
return list
case "object":
# Handle nested objects by creating a nested Pydantic model
nested_properties = prop_details.get("properties")
nested_required = prop_details.get("required", [])
if nested_properties and isinstance(nested_properties, dict):
# Create the name for the nested model
nested_model_name = f"{parent_name}_nested" if parent_name else "NestedModel"
# Recursively build field definitions for the nested model
nested_field_definitions: dict[str, Any] = {}
for nested_prop_name, nested_prop_details in nested_properties.items():
nested_prop_details = (
json.loads(nested_prop_details)
if isinstance(nested_prop_details, str)
else nested_prop_details
)
# Check for Literal types first (const/enum)
literal_type = _resolve_literal_type(nested_prop_details)
if literal_type is not None:
nested_python_type = literal_type
else:
nested_python_type = _resolve_type(
nested_prop_details,
f"{nested_model_name}_{nested_prop_name}",
)
nested_description = nested_prop_details.get("description", "")
# Build field kwargs for nested property
nested_field_kwargs: dict[str, Any] = {}
if nested_description:
nested_field_kwargs["description"] = nested_description
# Create field definition
if nested_prop_name in nested_required:
nested_field_definitions[nested_prop_name] = (
(
nested_python_type,
Field(**nested_field_kwargs),
)
if nested_field_kwargs
else (nested_python_type, ...)
)
else:
nested_field_kwargs["default"] = nested_prop_details.get("default", None)
nested_field_definitions[nested_prop_name] = (
nested_python_type,
Field(**nested_field_kwargs),
)
# Create and return the nested Pydantic model
return create_model(nested_model_name, **nested_field_definitions) # type: ignore
# If no properties defined, return bare dict
return dict
case _:
return str # default
field_definitions: dict[str, Any] = {}
for prop_name, prop_details in properties.items():
prop_details = json.loads(prop_details) if isinstance(prop_details, str) else prop_details
# Check for Literal types first (const/enum)
literal_type = _resolve_literal_type(prop_details)
if literal_type is not None:
python_type = literal_type
else:
python_type = _resolve_type(prop_details, f"{model_name}_{prop_name}")
description = prop_details.get("description", "")
# Build field kwargs (description, etc.)
field_kwargs: dict[str, Any] = {}
if description:
field_kwargs["description"] = description
# Create field definition for create_model
if prop_name in required:
if field_kwargs:
field_definitions[prop_name] = (python_type, Field(**field_kwargs))
else:
field_definitions[prop_name] = (python_type, ...)
else:
default_value = prop_details.get("default", None)
field_kwargs["default"] = default_value
if field_kwargs and any(k != "default" for k in field_kwargs):
field_definitions[prop_name] = (python_type, Field(**field_kwargs))
else:
field_definitions[prop_name] = (python_type, default_value)
return create_model(f"{model_name}_input", **field_definitions)
def _create_model_from_json_schema(tool_name: str, schema_json: Mapping[str, Any]) -> type[BaseModel]:
"""Creates a Pydantic model from a given JSON Schema.
Args:
tool_name: The name of the model to be created.
schema_json: The JSON Schema definition.
Returns:
The dynamically created Pydantic model class.
"""
# Validate that 'properties' exists and is a dict
if "properties" not in schema_json or not isinstance(schema_json["properties"], dict):
raise ValueError(
f"JSON schema for tool '{tool_name}' must contain a 'properties' key of type dict. "
f"Got: {schema_json.get('properties', None)}"
)
return _build_pydantic_model_from_json_schema(tool_name, schema_json)
@overload
def tool(
func: Callable[..., Any],
@@ -1348,8 +1121,6 @@ def normalize_function_invocation_configuration(
raise ValueError("max_function_calls must be at least 1 or None.")
if normalized["max_consecutive_errors_per_request"] < 0:
raise ValueError("max_consecutive_errors_per_request must be 0 or more.")
if normalized["additional_tools"] is None:
normalized["additional_tools"] = []
return normalized
@@ -1424,7 +1195,7 @@ async def _auto_invoke_function(
if key not in {"_function_middleware_pipeline", "middleware", "conversation_id"}
}
try:
if not tool._schema_supplied and tool.input_model is not None:
if not cast(bool, getattr(tool, "_schema_supplied", False)) and tool.input_model is not None:
args = tool.input_model.model_validate(parsed_args).model_dump(exclude_none=True)
else:
args = dict(parsed_args)
@@ -1435,7 +1206,7 @@ async def _auto_invoke_function(
)
except (TypeError, ValidationError) as exc:
message = "Error: Argument parsing failed."
if config["include_detailed_errors"]:
if config.get("include_detailed_errors", False):
message = f"{message} Exception: {exc}"
return Content.from_function_result(
call_id=function_call_content.call_id, # type: ignore[arg-type]
@@ -1459,7 +1230,7 @@ async def _auto_invoke_function(
)
except Exception as exc:
message = "Error: Function failed."
if config["include_detailed_errors"]:
if config.get("include_detailed_errors", False):
message = f"{message} Exception: {exc}"
return Content.from_function_result(
call_id=function_call_content.call_id, # type: ignore[arg-type]
@@ -1505,7 +1276,7 @@ async def _auto_invoke_function(
raise
except Exception as exc:
message = "Error: Function failed."
if config["include_detailed_errors"]:
if config.get("include_detailed_errors", False):
message = f"{message} Exception: {exc}"
return Content.from_function_result(
call_id=function_call_content.call_id, # type: ignore[arg-type]
@@ -1560,7 +1331,8 @@ async def _try_execute_function_calls(
approval_tools,
)
declaration_only = [tool_name for tool_name, tool in tool_map.items() if tool.declaration_only]
additional_tool_names = [tool.name for tool in config["additional_tools"]] if config["additional_tools"] else []
configured_additional_tools = config.get("additional_tools") or []
additional_tool_names = [tool.name for tool in configured_additional_tools]
# check if any are calling functions that need approval
# if so, we return approval request for all
approval_needed = False
@@ -1581,7 +1353,7 @@ async def _try_execute_function_calls(
declaration_only_flag = True
break
if (
config["terminate_on_unknown_calls"] and fcc.type == "function_call" and fcc.name not in tool_map # type: ignore[attr-defined]
config.get("terminate_on_unknown_calls", False) and fcc.type == "function_call" and fcc.name not in tool_map # type: ignore[attr-defined]
):
raise KeyError(f'Error: Requested function "{fcc.name}" not found.') # type: ignore[attr-defined]
if approval_needed:
@@ -1598,7 +1370,7 @@ async def _try_execute_function_calls(
if declaration_only_flag:
# return the declaration only tools to the user, since we cannot execute them.
# Mark as user_input_request so AgentExecutor emits request_info events and pauses the workflow.
declaration_only_calls = []
declaration_only_calls: list[Content] = []
for fcc in function_calls:
if fcc.type == "function_call":
fcc.user_input_request = True
@@ -1695,19 +1467,6 @@ def _update_conversation_id(
options["conversation_id"] = conversation_id
async def _ensure_response_stream(
stream_like: ResponseStream[Any, Any] | Awaitable[ResponseStream[Any, Any]],
) -> ResponseStream[Any, Any]:
from ._types import ResponseStream
stream = await stream_like if isinstance(stream_like, Awaitable) else stream_like
if not isinstance(stream, ResponseStream):
raise ValueError("Streaming function invocation requires a ResponseStream result.")
if getattr(stream, "_stream", None) is None:
await stream
return stream
def _extract_tools(
options: dict[str, Any] | None,
) -> ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None:
@@ -1776,7 +1535,7 @@ def _replace_approval_contents_with_results(
}
# Track approval requests that should be removed (duplicates)
contents_to_remove = []
contents_to_remove: list[int] = []
for content_idx, content in enumerate(msg.contents):
if content.type == "function_approval_request":
@@ -2097,7 +1856,9 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
function_middleware_pipeline = FunctionMiddlewarePipeline(
*(self.function_middleware), *(function_middleware or [])
)
max_errors: int = self.function_invocation_configuration["max_consecutive_errors_per_request"] # type: ignore[assignment]
max_errors = self.function_invocation_configuration.get(
"max_consecutive_errors_per_request", DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST
)
additional_function_arguments: dict[str, Any] = {}
if options and (additional_opts := options.get("additional_function_arguments")): # type: ignore[attr-defined]
additional_function_arguments = additional_opts # type: ignore
@@ -2122,7 +1883,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
if not stream:
async def _get_response() -> ChatResponse:
async def _get_response() -> ChatResponse[Any]:
nonlocal mutable_options
nonlocal filtered_kwargs
errors_in_a_row: int = 0
@@ -2130,13 +1891,11 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
max_function_calls: int | None = self.function_invocation_configuration.get("max_function_calls")
prepped_messages = list(messages)
fcc_messages: list[Message] = []
response: ChatResponse | None = None
response: ChatResponse[Any] | None = None
for attempt_idx in range(
self.function_invocation_configuration["max_iterations"]
if self.function_invocation_configuration["enabled"]
else 0
):
loop_enabled = self.function_invocation_configuration.get("enabled", True)
max_iterations = self.function_invocation_configuration.get("max_iterations", DEFAULT_MAX_ITERATIONS)
for attempt_idx in range(max_iterations if loop_enabled else 0):
approval_result = await _process_function_requests(
response=None,
prepped_messages=prepped_messages,
@@ -2147,17 +1906,20 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
max_errors=max_errors,
execute_function_calls=execute_function_calls,
)
if approval_result["action"] == "stop":
if approval_result.get("action") == "stop":
response = ChatResponse(messages=prepped_messages)
break
errors_in_a_row = approval_result["errors_in_a_row"]
errors_in_a_row = approval_result.get("errors_in_a_row", errors_in_a_row)
total_function_calls += approval_result.get("function_call_count", 0)
response = await super_get_response(
messages=prepped_messages,
stream=False,
options=mutable_options,
**filtered_kwargs,
response = cast(
ChatResponse[Any],
await super_get_response(
messages=prepped_messages,
stream=False,
options=mutable_options,
**filtered_kwargs,
),
)
if response.conversation_id is not None:
@@ -2174,10 +1936,10 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
max_errors=max_errors,
execute_function_calls=execute_function_calls,
)
if result["action"] == "return":
if result.get("action") == "return":
return response
total_function_calls += result.get("function_call_count", 0)
if result["action"] == "stop":
if result.get("action") == "stop":
# Error threshold reached: force a final non-tool turn so
# function_call_output items are submitted before exit.
mutable_options["tool_choice"] = "none"
@@ -2190,7 +1952,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
max_function_calls,
)
mutable_options["tool_choice"] = "none"
errors_in_a_row = result["errors_in_a_row"]
errors_in_a_row = result.get("errors_in_a_row", errors_in_a_row)
# When tool_choice is 'required', reset tool_choice after one iteration to avoid infinite loops
if mutable_options.get("tool_choice") == "required" or (
@@ -2213,17 +1975,20 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
# Make a final model call with tool_choice="none" so the model
# produces a plain text answer instead of leaving orphaned
# function_call items without matching results.
if response is not None and self.function_invocation_configuration["enabled"]:
if response is not None and self.function_invocation_configuration.get("enabled", True):
logger.info(
"Maximum iterations reached (%d). Requesting final response without tools.",
self.function_invocation_configuration["max_iterations"],
self.function_invocation_configuration.get("max_iterations", DEFAULT_MAX_ITERATIONS),
)
mutable_options["tool_choice"] = "none"
response = await super_get_response(
messages=prepped_messages,
stream=False,
options=mutable_options,
**filtered_kwargs,
response = cast(
ChatResponse[Any],
await super_get_response(
messages=prepped_messages,
stream=False,
options=mutable_options,
**filtered_kwargs,
),
)
if fcc_messages:
for msg in reversed(fcc_messages):
@@ -2233,7 +1998,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
return _get_response()
response_format = mutable_options.get("response_format") if mutable_options else None
output_format_type = response_format if isinstance(response_format, type) else None
output_format_type: type[BaseModel] | None = response_format if isinstance(response_format, type) else None
stream_result_hooks: list[Callable[[ChatResponse], Any]] = []
async def _stream() -> AsyncIterable[ChatResponseUpdate]:
@@ -2245,13 +2010,11 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
max_function_calls: int | None = self.function_invocation_configuration.get("max_function_calls")
prepped_messages = list(messages)
fcc_messages: list[Message] = []
response: ChatResponse | None = None
response: ChatResponse[Any] | None = None
for attempt_idx in range(
self.function_invocation_configuration["max_iterations"]
if self.function_invocation_configuration["enabled"]
else 0
):
loop_enabled = self.function_invocation_configuration.get("enabled", True)
max_iterations = self.function_invocation_configuration.get("max_iterations", DEFAULT_MAX_ITERATIONS)
for attempt_idx in range(max_iterations if loop_enabled else 0):
approval_result = await _process_function_requests(
response=None,
prepped_messages=prepped_messages,
@@ -2262,20 +2025,22 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
max_errors=max_errors,
execute_function_calls=execute_function_calls,
)
errors_in_a_row = approval_result["errors_in_a_row"]
errors_in_a_row = approval_result.get("errors_in_a_row", errors_in_a_row)
total_function_calls += approval_result.get("function_call_count", 0)
if approval_result["action"] == "stop":
if approval_result.get("action") == "stop":
mutable_options["tool_choice"] = "none"
return
inner_stream = await _ensure_response_stream(
inner_stream = cast(
ResponseStream[ChatResponseUpdate, ChatResponse[Any]],
super_get_response(
messages=prepped_messages,
stream=True,
options=mutable_options,
**filtered_kwargs,
)
),
)
await inner_stream
# Collect result hooks from the inner stream to run later
stream_result_hooks[:] = _get_result_hooks_from_stream(inner_stream)
@@ -2308,18 +2073,18 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
max_errors=max_errors,
execute_function_calls=execute_function_calls,
)
errors_in_a_row = result["errors_in_a_row"]
errors_in_a_row = result.get("errors_in_a_row", errors_in_a_row)
total_function_calls += result.get("function_call_count", 0)
if role := result["update_role"]:
if role := result.get("update_role"):
yield ChatResponseUpdate(
contents=result["function_call_results"] or [],
contents=result.get("function_call_results") or [],
role=role,
)
if result["action"] == "stop":
if result.get("action") == "stop":
# Error threshold reached: submit collected function_call_output
# items once more with tools disabled.
mutable_options["tool_choice"] = "none"
elif result["action"] != "continue":
elif result.get("action") != "continue":
return
elif max_function_calls is not None and total_function_calls >= max_function_calls:
# Best-effort limit: checked after each batch of parallel calls completes,
@@ -2352,26 +2117,28 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
# Make a final model call with tool_choice="none" so the model
# produces a plain text answer instead of leaving orphaned
# function_call items without matching results.
if response is not None and self.function_invocation_configuration["enabled"]:
if response is not None and self.function_invocation_configuration.get("enabled", True):
logger.info(
"Maximum iterations reached (%d). Requesting final response without tools.",
self.function_invocation_configuration["max_iterations"],
self.function_invocation_configuration.get("max_iterations", DEFAULT_MAX_ITERATIONS),
)
mutable_options["tool_choice"] = "none"
inner_stream = await _ensure_response_stream(
final_inner_stream = cast(
ResponseStream[ChatResponseUpdate, ChatResponse[Any]],
super_get_response(
messages=prepped_messages,
stream=True,
options=mutable_options,
**filtered_kwargs,
)
),
)
async for update in inner_stream:
await final_inner_stream
async for update in final_inner_stream:
yield update
# Finalize the inner stream to trigger its hooks
await inner_stream.get_final_response()
await final_inner_stream.get_final_response()
def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse:
def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse[Any]:
# Note: stream_result_hooks are already run via inner stream's get_final_response()
# We don't need to run them again here
return ChatResponse.from_updates(updates, output_format_type=output_format_type)
+145 -202
View File
@@ -17,12 +17,15 @@ from collections.abc import (
Mapping,
MutableMapping,
Sequence,
Sized,
)
from copy import deepcopy
from datetime import datetime
from inspect import isawaitable
from typing import TYPE_CHECKING, Any, ClassVar, Final, Generic, Literal, NewType, cast, overload
from pydantic import BaseModel
from typing_extensions import TypedDict
from ._serialization import SerializationMixin
from ._tools import ToolTypes
@@ -33,10 +36,6 @@ if sys.version_info >= (3, 13):
from typing import TypeVar # pragma: no cover
else:
from typing_extensions import TypeVar # pragma: no cover
if sys.version_info >= (3, 11):
from typing import TypedDict # type: ignore # pragma: no cover
else:
from typing_extensions import TypedDict # type: ignore # pragma: no cover
logger = logging.getLogger("agent_framework")
@@ -194,7 +193,7 @@ def _get_data_bytes_as_str(content: Content) -> str | None:
return data # type: ignore[return-value, no-any-return]
def _get_data_bytes(content: Content) -> bytes | None:
def _get_data_bytes(content: Content) -> bytes | None: # pyright: ignore[reportUnusedFunction]
"""Extract and decode binary data from data URI.
Args:
@@ -270,9 +269,9 @@ def _serialize_value(value: Any, exclude_none: bool) -> Any:
if isinstance(value, Content):
return value.to_dict(exclude_none=exclude_none)
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
return [_serialize_value(item, exclude_none) for item in value]
return [_serialize_value(item, exclude_none) for item in cast(Iterable[Any], value)]
if isinstance(value, Mapping):
return {k: _serialize_value(v, exclude_none) for k, v in value.items()}
return {k: _serialize_value(v, exclude_none) for k, v in value.items()} # type: ignore[reportUnknownVariableType]
if hasattr(value, "to_dict"):
return value.to_dict() # type: ignore[call-arg]
return value
@@ -376,7 +375,7 @@ ContentT = TypeVar("ContentT", bound="Content")
# endregion
class UsageDetails(TypedDict, total=False):
class UsageDetails(TypedDict, total=False, extra_items=int): # type: ignore[call-arg]
"""A dictionary representing usage details.
This is a non-closed dictionary, so any specific provider fields can be added as needed.
@@ -397,6 +396,9 @@ class UsageDetails(TypedDict, total=False):
def add_usage_details(usage1: UsageDetails | None, usage2: UsageDetails | None) -> UsageDetails:
"""Add two UsageDetails dictionaries by summing all numeric values.
If any of the two usage details contains a key with a non-int value, it will be skipped,
even if the other contains a int-value on that key.
Args:
usage1: First usage details dictionary.
usage2: Second usage details dictionary.
@@ -420,22 +422,15 @@ def add_usage_details(usage1: UsageDetails | None, usage2: UsageDetails | None)
return usage1
result = UsageDetails()
# Combine all keys from both dictionaries
all_keys = set(usage1.keys()) | set(usage2.keys())
for key in all_keys:
val1 = usage1.get(key)
val2 = usage2.get(key)
# Sum if both present, otherwise use the non-None value
if val1 is not None and val2 is not None:
result[key] = val1 + val2 # type: ignore[literal-required, operator]
elif val1 is not None:
result[key] = val1 # type: ignore[literal-required]
elif val2 is not None:
result[key] = val2 # type: ignore[literal-required]
if not isinstance((val1 := usage1.get(key, 0)), (int | None)) or not isinstance(
(val2 := usage2.get(key, 0)), (int | None)
):
logger.warning("Non `int` value found in usage details, skipping.")
continue
result[key] = (val1 or 0) + (val2 or 0) # type: ignore[literal-required]
return result
@@ -465,7 +460,7 @@ class Content:
error_code: str | None = None,
error_details: str | None = None,
# Usage content fields
usage_details: dict[str, Any] | UsageDetails | None = None,
usage_details: UsageDetails | None = None,
# Function call/result fields
call_id: str | None = None,
name: str | None = None,
@@ -1264,19 +1259,14 @@ class Content:
return cls.from_data(remaining["data"], remaining["media_type"])
# Handle nested Content objects (e.g., function_call in function_approval_request)
if "function_call" in remaining and isinstance(remaining["function_call"], dict):
remaining["function_call"] = cls.from_dict(remaining["function_call"])
if (function_call := remaining.get("function_call")) and isinstance(function_call, dict):
remaining["function_call"] = cls.from_dict(function_call) # type: ignore[reportUnknownArgumentType]
# Handle list of Content objects (e.g., inputs in code_interpreter_tool_call)
if "inputs" in remaining and isinstance(remaining["inputs"], list):
remaining["inputs"] = [
cls.from_dict(item) if isinstance(item, dict) else item for item in remaining["inputs"]
]
if "outputs" in remaining and isinstance(remaining["outputs"], list):
remaining["outputs"] = [
cls.from_dict(item) if isinstance(item, dict) else item for item in remaining["outputs"]
]
if (input_items := remaining.get("inputs")) and isinstance(input_items, list):
remaining["inputs"] = [cls.from_dict(item) if isinstance(item, dict) else item for item in input_items] # type: ignore[reportUnknownVariableType]
if (output_items := remaining.get("outputs")) and isinstance(output_items, list):
remaining["outputs"] = [cls.from_dict(item) if isinstance(item, dict) else item for item in output_items] # type: ignore[reportUnknownVariableType]
return cls(
type=content_type,
@@ -1306,55 +1296,16 @@ class Content:
def _add_text_content(self, other: Content) -> Content:
"""Add two TextContent instances."""
# Merge raw representations
if self.raw_representation is None:
raw_representation = other.raw_representation
elif other.raw_representation is None:
raw_representation = self.raw_representation
else:
raw_representation = (
self.raw_representation if isinstance(self.raw_representation, list) else [self.raw_representation]
) + (other.raw_representation if isinstance(other.raw_representation, list) else [other.raw_representation])
# Merge annotations
if self.annotations is None:
annotations = other.annotations
elif other.annotations is None:
annotations = self.annotations
else:
annotations = self.annotations + other.annotations # type: ignore[operator]
return Content(
"text",
text=self.text + other.text, # type: ignore[attr-defined, operator]
annotations=annotations,
additional_properties={
**(other.additional_properties or {}),
**(self.additional_properties or {}),
},
raw_representation=raw_representation,
annotations=_combine_annotations(self.annotations, other.annotations),
additional_properties=_combine_additional_props(self.additional_properties, other.additional_properties),
raw_representation=_combine_raw_representations(self.raw_representation, other.raw_representation),
)
def _add_text_reasoning_content(self, other: Content) -> Content:
"""Add two TextReasoningContent instances."""
# Merge raw representations
if self.raw_representation is None:
raw_representation = other.raw_representation
elif other.raw_representation is None:
raw_representation = self.raw_representation
else:
raw_representation = (
self.raw_representation if isinstance(self.raw_representation, list) else [self.raw_representation]
) + (other.raw_representation if isinstance(other.raw_representation, list) else [other.raw_representation])
# Merge annotations
if self.annotations is None:
annotations = other.annotations
elif other.annotations is None:
annotations = self.annotations
else:
annotations = self.annotations + other.annotations # type: ignore[operator]
# Concatenate text, handling None values
self_text = self.text or "" # type: ignore[attr-defined]
other_text = other.text or "" # type: ignore[attr-defined]
@@ -1367,12 +1318,9 @@ class Content:
"text_reasoning",
text=combined_text,
protected_data=protected_data,
annotations=annotations,
additional_properties={
**(other.additional_properties or {}),
**(self.additional_properties or {}),
},
raw_representation=raw_representation,
annotations=_combine_annotations(self.annotations, other.annotations),
additional_properties=_combine_additional_props(self.additional_properties, other.additional_properties),
raw_representation=_combine_raw_representations(self.raw_representation, other.raw_representation),
)
def _add_function_call_content(self, other: Content) -> Content:
@@ -1396,64 +1344,23 @@ class Content:
else:
raise TypeError("Incompatible argument types")
# Merge raw representations
if self.raw_representation is None:
raw_representation: Any = other.raw_representation
elif other.raw_representation is None:
raw_representation = self.raw_representation
else:
raw_representation = (
self.raw_representation if isinstance(self.raw_representation, list) else [self.raw_representation]
) + (other.raw_representation if isinstance(other.raw_representation, list) else [other.raw_representation])
return Content(
"function_call",
call_id=self_call_id,
name=getattr(self, "name", getattr(other, "name", None)),
arguments=arguments,
exception=getattr(self, "exception", None) or getattr(other, "exception", None),
additional_properties={
**(self.additional_properties or {}),
**(other.additional_properties or {}),
},
raw_representation=raw_representation,
additional_properties=_combine_additional_props(self.additional_properties, other.additional_properties),
raw_representation=_combine_raw_representations(self.raw_representation, other.raw_representation),
)
def _add_usage_content(self, other: Content) -> Content:
"""Add two UsageContent instances by combining their usage details."""
self_details = getattr(self, "usage_details", {})
other_details = getattr(other, "usage_details", {})
# Combine token counts
combined_details: dict[str, Any] = {}
for key in set(list(self_details.keys()) + list(other_details.keys())):
self_val = self_details.get(key)
other_val = other_details.get(key)
if isinstance(self_val, int) and isinstance(other_val, int):
combined_details[key] = self_val + other_val
elif self_val is not None:
combined_details[key] = self_val
elif other_val is not None:
combined_details[key] = other_val
# Merge raw representations
if self.raw_representation is None:
raw_representation = other.raw_representation
elif other.raw_representation is None:
raw_representation = self.raw_representation
else:
raw_representation = (
self.raw_representation if isinstance(self.raw_representation, list) else [self.raw_representation]
) + (other.raw_representation if isinstance(other.raw_representation, list) else [other.raw_representation])
return Content(
"usage",
usage_details=combined_details,
additional_properties={
**(self.additional_properties or {}),
**(other.additional_properties or {}),
},
raw_representation=raw_representation,
usage_details=add_usage_details(self.usage_details, other.usage_details),
additional_properties=_combine_additional_props(self.additional_properties, other.additional_properties),
raw_representation=_combine_raw_representations(self.raw_representation, other.raw_representation),
)
def has_top_level_media_type(self, top_level_media_type: Literal["application", "audio", "image", "text"]) -> bool:
@@ -1530,6 +1437,42 @@ class Content:
return self.arguments # type: ignore[return-value]
def _combine_additional_props(
self_additional_properties: dict[str, Any], other_additional_properties: dict[str, Any]
) -> dict[str, Any]:
"""Combine additional properties for addition operations."""
return {
**other_additional_properties,
**self_additional_properties,
}
def _combine_raw_representations(
self_repr: Any,
other_repr: Any,
) -> Any:
"""Combine raw representations for addition operations."""
if self_repr is None:
return other_repr
if other_repr is None:
return self_repr
self_list = self_repr if isinstance(self_repr, list) else [self_repr] # type: ignore[reportUnknownVariableType]
other_list = other_repr if isinstance(other_repr, list) else [other_repr] # type: ignore[reportUnknownVariableType]
return self_list + other_list # type: ignore[reportUnknownVariableType]
def _combine_annotations(
self_annotations: Sequence[Annotation] | None,
other_annotations: Sequence[Annotation] | None,
) -> Sequence[Annotation] | None:
"""Combine annotations for addition operations."""
if self_annotations is None:
return other_annotations
if other_annotations is None:
return self_annotations
return [*self_annotations, *other_annotations]
# endregion
@@ -1665,10 +1608,6 @@ class Message(SerializationMixin):
Additional properties are used within Agent Framework, they are not sent to services.
raw_representation: Optional raw representation of the chat message.
"""
# Handle role conversion from legacy dict format
if isinstance(role, dict) and "value" in role:
role = role["value"]
# Handle contents conversion
parsed_contents = [] if contents is None else _parse_content_list(contents)
@@ -1836,14 +1775,14 @@ def _process_update(response: ChatResponse | AgentResponse, update: ChatResponse
if update.created_at is not None:
response.created_at = update.created_at
if update.additional_properties is not None:
if response.additional_properties is None:
response.additional_properties = {}
response.additional_properties.update(update.additional_properties)
if response.raw_representation is None:
response.raw_representation = []
if not isinstance(response.raw_representation, list):
response.raw_representation = [response.raw_representation]
response.raw_representation.append(update.raw_representation)
raw_representation_value = cast(Any, getattr(response, "raw_representation", None))
raw_representation_list = cast(list[Any], raw_representation_value)
raw_representation_list.append(update.raw_representation)
if isinstance(response, ChatResponse) and isinstance(update, ChatResponseUpdate):
if update.conversation_id is not None:
response.conversation_id = update.conversation_id
@@ -2026,9 +1965,6 @@ class ChatResponse(SerializationMixin, Generic[ResponseModelT]):
self.conversation_id = conversation_id
self.model_id = model_id
self.created_at = created_at
# Handle legacy dict format for finish_reason
if isinstance(finish_reason, dict) and "value" in finish_reason:
finish_reason = finish_reason["value"]
self.finish_reason = finish_reason
self.usage_details = usage_details
self._value: ResponseModelT | None = value
@@ -2620,10 +2556,6 @@ class AgentResponseUpdate(SerializationMixin):
processed_contents.append(c)
self.contents = processed_contents
# Handle legacy dict format for role
if isinstance(role, dict) and "value" in role:
role = role["value"]
self.role: str | None = role
self.author_name = author_name
self.agent_id = agent_id
@@ -2717,7 +2649,7 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
self._inner_stream: ResponseStream[Any, Any] | None = None
self._inner_stream_source: ResponseStream[Any, Any] | Awaitable[ResponseStream[Any, Any]] | None = None
self._wrap_inner: bool = False
self._map_update: Callable[[Any], Any | Awaitable[Any]] | None = None
self._map_update: Callable[[Any], UpdateT | Awaitable[UpdateT]] | None = None
def map(
self,
@@ -2757,11 +2689,11 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
... AgentResponse.from_updates,
... )
"""
stream: ResponseStream[Any, Any] = ResponseStream(self, finalizer=finalizer)
stream: ResponseStream[OuterUpdateT, OuterFinalT] = ResponseStream(self, finalizer=finalizer)
stream._inner_stream_source = self
stream._wrap_inner = True
stream._map_update = transform
return stream # type: ignore[return-value]
return stream
def with_finalizer(
self,
@@ -2785,10 +2717,10 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
Example:
>>> stream.with_finalizer(AgentResponse.from_updates)
"""
stream: ResponseStream[Any, Any] = ResponseStream(self, finalizer=finalizer)
stream: ResponseStream[UpdateT, OuterFinalT] = ResponseStream(self, finalizer=finalizer)
stream._inner_stream_source = self
stream._wrap_inner = True
return stream # type: ignore[return-value]
return stream
@classmethod
def from_awaitable(
@@ -2813,10 +2745,10 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
>>> async def get_stream() -> ResponseStream[Update, Response]: ...
>>> stream = ResponseStream.from_awaitable(get_stream())
"""
stream: ResponseStream[Any, Any] = cls(awaitable) # type: ignore[arg-type]
stream._inner_stream_source = awaitable # type: ignore[assignment]
stream: ResponseStream[UpdateT, FinalT] = cls(cast(Awaitable[AsyncIterable[UpdateT]], awaitable))
stream._inner_stream_source = awaitable
stream._wrap_inner = True
return stream # type: ignore[return-value]
return stream
async def _get_stream(self) -> AsyncIterable[UpdateT]:
if self._stream is None:
@@ -2826,10 +2758,10 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
if not iscoroutine(self._stream_source):
self._stream = self._stream_source # type: ignore[assignment]
else:
self._stream = await self._stream_source # type: ignore[assignment]
self._stream = await self._stream_source
if isinstance(self._stream, ResponseStream) and self._wrap_inner:
self._inner_stream = self._stream
return self._stream
self._inner_stream = self._stream # type: ignore[assignment]
return self._inner_stream
return self._stream # type: ignore[return-value]
def __aiter__(self) -> ResponseStream[UpdateT, FinalT]:
@@ -2840,7 +2772,7 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
stream = await self._get_stream()
self._iterator = stream.__aiter__()
try:
update = await self._iterator.__anext__()
update: UpdateT = await self._iterator.__anext__()
except StopAsyncIteration:
self._consumed = True
await self._run_cleanup_hooks()
@@ -2849,18 +2781,16 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
await self._run_cleanup_hooks()
raise
if self._map_update is not None:
mapped = self._map_update(update)
if isinstance(mapped, Awaitable):
update = await mapped
else:
update = mapped # type: ignore[assignment]
update = self._map_update(update) # type: ignore[assignment]
if isawaitable(update):
update = await update
self._updates.append(update)
for hook in self._transform_hooks:
hooked = hook(update)
if isinstance(hooked, Awaitable):
update = await hooked
elif hooked is not None:
update = hooked # type: ignore[assignment]
if isawaitable(hooked):
hooked = await hooked
if hooked is not None:
update = hooked
return update
def __await__(self) -> Any:
@@ -2903,58 +2833,71 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
# First, finalize the inner stream and run its result hooks
# This ensures inner post-processing (e.g., context provider notifications) runs
if self._inner_stream._finalizer is not None:
inner_result: Any = self._inner_stream._finalizer(self._inner_stream._updates)
if isinstance(inner_result, Awaitable):
inner_stream = self._inner_stream
inner_result: Any
if inner_stream._finalizer is not None:
inner_finalizer = inner_stream._finalizer
inner_result = inner_finalizer(inner_stream._updates)
if isawaitable(inner_result):
inner_result = await inner_result
else:
inner_result = self._inner_stream._updates
inner_result = list(inner_stream._updates)
# Run inner stream's result hooks
for hook in self._inner_stream._result_hooks:
hooked = hook(inner_result)
if isinstance(hooked, Awaitable):
hooked = await hooked
if hooked is not None:
inner_result = hooked
self._inner_stream._final_result = inner_result
self._inner_stream._finalized = True
inner_hooks = cast(list[Callable[[Any], Any | Awaitable[Any] | None]], inner_stream._result_hooks)
for hook in inner_hooks:
hooked_result = hook(inner_result)
if isawaitable(hooked_result):
hooked_result = await hooked_result
if hooked_result is not None:
inner_result = hooked_result
inner_stream._final_result = inner_result
inner_stream._finalized = True
# Now finalize the outer stream with its own finalizer
# If outer has no finalizer, use inner's result (preserves from_awaitable behavior)
outer_result: Any
if self._finalizer is not None:
result: Any = self._finalizer(self._updates)
if isinstance(result, Awaitable):
result = await result
outer_result = self._finalizer(self._updates)
if isawaitable(outer_result):
outer_result = await outer_result
else:
# No outer finalizer - use inner's finalized result
result = inner_result
outer_result = inner_result
# Apply outer's result_hooks
for hook in self._result_hooks:
hooked = hook(result)
if isinstance(hooked, Awaitable):
hooked = await hooked
if hooked is not None:
result = hooked
self._final_result = result
outer_hooks = cast(list[Callable[[Any], Any | Awaitable[Any] | None]], self._result_hooks)
for hook in outer_hooks:
outer_hook_result = hook(outer_result)
if isawaitable(outer_hook_result):
outer_hook_result = await outer_hook_result
if outer_hook_result is not None:
outer_result = outer_hook_result
self._final_result = outer_result
self._finalized = True
return self._final_result # type: ignore[return-value]
if not self._finalized:
if not self._consumed:
async for _ in self:
pass
# Use finalizer if configured, otherwise return collected updates
result: Any
if self._finalizer is not None:
result = self._finalizer(self._updates)
if isinstance(result, Awaitable):
if isawaitable(result):
result = await result
else:
result = self._updates
for hook in self._result_hooks:
hooked = hook(result)
if isinstance(hooked, Awaitable):
hooked = await hooked
if hooked is not None:
result = hooked
result = list(self._updates)
final_hooks = cast(list[Callable[[Any], Any | Awaitable[Any] | None]], self._result_hooks)
for hook in final_hooks:
final_hook_result = hook(result)
if isawaitable(final_hook_result):
final_hook_result = await final_hook_result
if final_hook_result is not None:
result = final_hook_result
self._final_result = result
self._finalized = True
return self._final_result # type: ignore[return-value]
@@ -2991,7 +2934,7 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
self._cleanup_run = True
for hook in self._cleanup_hooks:
result = hook()
if isinstance(result, Awaitable):
if isawaitable(result):
await result
@property
@@ -3302,9 +3245,9 @@ def merge_chat_options(
# Copy base values (shallow copy for simple values, dict copy for dicts)
for key, value in base.items():
if isinstance(value, dict):
result[key] = dict(value)
result[key] = dict(value) # type: ignore[reportUnknownArgumentType]
elif isinstance(value, list):
result[key] = list(value)
result[key] = list(value) # type: ignore[reportUnknownArgumentType]
else:
result[key] = value
@@ -3326,19 +3269,19 @@ def merge_chat_options(
if base_tools and value:
# Add tools that aren't already present
merged_tools = list(base_tools)
for tool in value if isinstance(value, list) else [value]:
for tool in value if isinstance(value, Iterable) else [value]: # type: ignore[reportUnknownVariableType]
if tool not in merged_tools:
merged_tools.append(tool)
result["tools"] = merged_tools
elif value:
result["tools"] = list(value) if isinstance(value, list) else [value]
result["tools"] = value if isinstance(value, list) else [value]
elif key in ("logit_bias", "metadata", "additional_properties"):
# Merge dicts
base_dict = result.get(key)
if base_dict and isinstance(value, dict):
if base_dict and isinstance(base_dict, dict) and isinstance(value, dict):
result[key] = {**base_dict, **value}
elif value:
result[key] = dict(value) if isinstance(value, dict) else value
result[key] = dict(cast(Mapping[Any, Any], value)) if isinstance(value, dict) else value
elif key == "tool_choice":
# tool_choice from override takes precedence
result["tool_choice"] = value if value else result.get("tool_choice")
@@ -3424,8 +3367,8 @@ class Embedding(Generic[EmbeddingT]):
"""
if self._dimensions is not None:
return self._dimensions
if isinstance(self.vector, (list, tuple, bytes)):
return len(self.vector)
if isinstance(self.vector, Sized) and not isinstance(self.vector, str):
return len(cast(Sized, self.vector))
return None
@@ -450,9 +450,9 @@ class AgentExecutor(Executor):
options: dict[str, Any] = {}
if options_from_workflow is not None:
if isinstance(options_from_workflow, Mapping):
for key, value in options_from_workflow.items():
if isinstance(key, str):
options[key] = value
options_from_workflow_map = cast(Mapping[str, Any], options_from_workflow)
for key, value in options_from_workflow_map.items():
options[key] = value
else:
logger.warning(
"Ignoring non-mapping workflow 'options' kwarg of type %s for AgentExecutor %s.",
@@ -461,16 +461,17 @@ class AgentExecutor(Executor):
)
existing_additional_args = options.get("additional_function_arguments")
additional_args: dict[str, Any]
if isinstance(existing_additional_args, Mapping):
additional_args = {key: value for key, value in existing_additional_args.items() if isinstance(key, str)}
existing_additional_args_map = cast(Mapping[str, Any], existing_additional_args)
additional_args = {key: value for key, value in existing_additional_args_map.items()}
else:
additional_args = {}
if workflow_additional_args is not None:
if isinstance(workflow_additional_args, Mapping):
additional_args.update({
key: value for key, value in workflow_additional_args.items() if isinstance(key, str)
})
workflow_additional_args_map = cast(Mapping[str, Any], workflow_additional_args)
additional_args.update({key: value for key, value in workflow_additional_args_map.items()})
else:
logger.warning(
"Ignoring non-mapping workflow 'additional_function_arguments' kwarg of type %s for AgentExecutor %s.", # noqa: E501
@@ -119,7 +119,7 @@ class FunctionExecutor(Executor):
# Determine if function has WorkflowContext parameter
self._has_context = ctx_annotation is not None
# Determine if the function is an async function
self._is_async = asyncio.iscoroutinefunction(func)
self._is_async = inspect.iscoroutinefunction(func)
# Initialize parent WITHOUT calling _discover_handlers yet
# We'll manually set up the attributes first
@@ -99,11 +99,11 @@ class RunnerContext(Protocol):
If checkpoint storage is not configured, checkpoint methods may raise.
"""
async def send_message(self, WorkflowMessage: WorkflowMessage) -> None:
async def send_message(self, message: WorkflowMessage) -> None:
"""Send a WorkflowMessage from the executor to the context.
Args:
WorkflowMessage: The WorkflowMessage to be sent.
message: The WorkflowMessage to be sent.
"""
...
@@ -288,9 +288,9 @@ class InProcRunnerContext:
self._streaming: bool = False
# region Messaging and Events
async def send_message(self, WorkflowMessage: WorkflowMessage) -> None:
self._messages.setdefault(WorkflowMessage.source_id, [])
self._messages[WorkflowMessage.source_id].append(WorkflowMessage)
async def send_message(self, message: WorkflowMessage) -> None:
self._messages.setdefault(message.source_id, [])
self._messages[message.source_id].append(message)
async def drain_messages(self) -> dict[str, list[WorkflowMessage]]:
messages = copy(self._messages)
@@ -193,36 +193,40 @@ def try_coerce_to_type(data: Any, target_type: type | UnionType | Any) -> Any:
Returns:
The coerced value, or the original value if coercion fails.
"""
original_data = data
# If already the right type, return as-is
if is_instance_of(data, target_type):
return data
# Can't coerce to non-concrete targets (Union, generic, etc.)
if not isinstance(target_type, type):
return data
return original_data
target_cls: type[Any] = target_type
# int -> float (JSON integers for float fields)
if isinstance(data, int) and target_type is float:
if isinstance(data, int) and target_cls is float:
return float(data)
# dict -> dataclass
# dict -> dataclass or pydantic model
if isinstance(data, dict):
from dataclasses import is_dataclass
if is_dataclass(target_type):
if is_dataclass(target_cls):
try:
return target_type(**data)
return target_cls(**data)
except (TypeError, ValueError):
return data
return original_data
# dict -> Pydantic model
if hasattr(target_type, "model_validate"):
model_validate = getattr(target_cls, "model_validate", None)
if callable(model_validate):
try:
return target_type.model_validate(data)
return model_validate(data)
except Exception:
return data
return original_data
return data
return original_data
def serialize_type(t: type) -> str:
@@ -12,7 +12,7 @@ from .._settings import load_settings
from ..openai import OpenAIAssistantsClient
from ..openai._assistants_client import OpenAIAssistantsOptions
from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider, resolve_credential_to_token_provider
from ._shared import AzureOpenAISettings, _apply_azure_defaults
from ._shared import AzureOpenAISettings, _apply_azure_defaults # pyright: ignore[reportPrivateUsage]
if sys.version_info >= (3, 13):
from typing import TypeVar # type: ignore # pragma: no cover
@@ -145,43 +145,46 @@ class AzureOpenAIAssistantsClient(
)
_apply_azure_defaults(azure_openai_settings, default_api_version=self.DEFAULT_AZURE_API_VERSION)
if not azure_openai_settings["chat_deployment_name"]:
chat_deployment_name = azure_openai_settings.get("chat_deployment_name")
if not chat_deployment_name:
raise ValueError(
"Azure OpenAI deployment name is required. Set via 'deployment_name' parameter "
"or 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' environment variable."
)
api_key_secret = azure_openai_settings.get("api_key")
token_scope = azure_openai_settings.get("token_endpoint")
# Resolve credential to token provider
ad_token_provider = None
if not async_client and not azure_openai_settings["api_key"] and credential:
ad_token_provider = resolve_credential_to_token_provider(
credential, azure_openai_settings["token_endpoint"]
)
if not async_client and not api_key_secret and credential:
ad_token_provider = resolve_credential_to_token_provider(credential, token_scope)
if not async_client and not azure_openai_settings["api_key"] and not ad_token_provider:
if not async_client and not api_key_secret and not ad_token_provider:
raise ValueError("Please provide either api_key, credential, or a client.")
# Create Azure client if not provided
if not async_client:
client_params: dict[str, Any] = {
"api_version": azure_openai_settings["api_version"],
"default_headers": default_headers,
}
if resolved_api_version := azure_openai_settings.get("api_version"):
client_params["api_version"] = resolved_api_version
if azure_openai_settings["api_key"]:
client_params["api_key"] = azure_openai_settings["api_key"].get_secret_value()
if api_key_secret:
client_params["api_key"] = api_key_secret.get_secret_value()
elif ad_token_provider:
client_params["azure_ad_token_provider"] = ad_token_provider
if azure_openai_settings["base_url"]:
client_params["base_url"] = str(azure_openai_settings["base_url"])
elif azure_openai_settings["endpoint"]:
client_params["azure_endpoint"] = str(azure_openai_settings["endpoint"])
if resolved_base_url := azure_openai_settings.get("base_url"):
client_params["base_url"] = str(resolved_base_url)
elif resolved_endpoint := azure_openai_settings.get("endpoint"):
client_params["azure_endpoint"] = str(resolved_endpoint)
async_client = AsyncAzureOpenAI(**client_params)
super().__init__(
model_id=azure_openai_settings["chat_deployment_name"],
model_id=chat_deployment_name,
assistant_id=assistant_id,
assistant_name=assistant_name,
assistant_description=assistant_description,
@@ -6,7 +6,7 @@ import json
import logging
import sys
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Any, Generic
from typing import TYPE_CHECKING, Any, Generic, cast
from openai.lib.azure import AsyncAzureOpenAI
from openai.types.chat.chat_completion import Choice
@@ -31,7 +31,7 @@ from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider
from ._shared import (
AzureOpenAIConfigMixin,
AzureOpenAISettings,
_apply_azure_defaults,
_apply_azure_defaults, # pyright: ignore[reportPrivateUsage]
)
if sys.version_info >= (3, 13):
@@ -260,19 +260,26 @@ class AzureOpenAIChatClient( # type: ignore[misc]
)
_apply_azure_defaults(azure_openai_settings)
if not azure_openai_settings["chat_deployment_name"]:
chat_deployment_name = azure_openai_settings.get("chat_deployment_name")
if not chat_deployment_name:
raise ValueError(
"Azure OpenAI deployment name is required. Set via 'deployment_name' parameter "
"or 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' environment variable."
)
endpoint_value = azure_openai_settings.get("endpoint")
base_url_value = azure_openai_settings.get("base_url")
api_version_value = cast(str, azure_openai_settings.get("api_version"))
api_key_value = azure_openai_settings.get("api_key")
token_endpoint_value = azure_openai_settings.get("token_endpoint")
super().__init__(
deployment_name=azure_openai_settings["chat_deployment_name"],
endpoint=azure_openai_settings["endpoint"],
base_url=azure_openai_settings["base_url"],
api_version=azure_openai_settings["api_version"], # type: ignore
api_key=azure_openai_settings["api_key"].get_secret_value() if azure_openai_settings["api_key"] else None,
token_endpoint=azure_openai_settings["token_endpoint"],
deployment_name=chat_deployment_name,
endpoint=endpoint_value,
base_url=base_url_value,
api_version=api_version_value,
api_key=api_key_value.get_secret_value() if api_key_value else None,
token_endpoint=token_endpoint_value,
credential=credential,
default_headers=default_headers,
client=async_client,
@@ -302,24 +309,29 @@ class AzureOpenAIChatClient( # type: ignore[misc]
if not message.model_extra or "context" not in message.model_extra:
return text_content
context: dict[str, Any] | str = message.context # type: ignore[assignment, union-attr]
if isinstance(context, str):
context_raw: object = cast(object, message.context) # type: ignore[union-attr]
if isinstance(context_raw, str):
try:
context = json.loads(context)
context_raw = json.loads(context_raw)
except json.JSONDecodeError:
logger.warning("Context is not a valid JSON string, ignoring context.")
return text_content
if not isinstance(context, dict):
if not isinstance(context_raw, dict):
logger.warning("Context is not a valid dictionary, ignoring context.")
return text_content
context = cast(dict[str, Any], context_raw)
# `all_retrieved_documents` is currently not used, but can be retrieved
# through the raw_representation in the text content.
if intent := context.get("intent"):
text_content.additional_properties = {"intent": intent}
if citations := context.get("citations"):
text_content.annotations = []
for citation in citations:
text_content.annotations.append(
citations = context.get("citations")
if isinstance(citations, list) and citations:
annotations: list[Annotation] = []
for citation_raw in cast(list[object], citations):
if not isinstance(citation_raw, dict):
continue
citation = cast(dict[str, Any], citation_raw)
annotations.append(
Annotation(
type="citation",
title=citation.get("title", ""),
@@ -331,4 +343,5 @@ class AzureOpenAIChatClient( # type: ignore[misc]
raw_representation=citation,
)
)
text_content.annotations = annotations
return text_content
@@ -17,7 +17,7 @@ from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider
from ._shared import (
AzureOpenAIConfigMixin,
AzureOpenAISettings,
_apply_azure_defaults,
_apply_azure_defaults, # pyright: ignore[reportPrivateUsage]
)
if sys.version_info >= (3, 13):
@@ -118,19 +118,22 @@ class AzureOpenAIEmbeddingClient(
)
_apply_azure_defaults(azure_openai_settings)
if not azure_openai_settings.get("embedding_deployment_name"):
embedding_deployment_name = azure_openai_settings.get("embedding_deployment_name")
if not embedding_deployment_name:
raise ValueError(
"Azure OpenAI embedding deployment name is required. Set via 'deployment_name' parameter "
"or 'AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME' environment variable."
)
api_key_secret = azure_openai_settings.get("api_key")
super().__init__(
deployment_name=azure_openai_settings["embedding_deployment_name"], # type: ignore[arg-type]
endpoint=azure_openai_settings["endpoint"],
base_url=azure_openai_settings["base_url"],
api_version=azure_openai_settings["api_version"], # type: ignore
api_key=azure_openai_settings["api_key"].get_secret_value() if azure_openai_settings["api_key"] else None,
token_endpoint=azure_openai_settings["token_endpoint"],
deployment_name=embedding_deployment_name,
endpoint=azure_openai_settings.get("endpoint"),
base_url=azure_openai_settings.get("base_url"),
api_version=azure_openai_settings.get("api_version") or "",
api_key=api_key_secret.get_secret_value() if api_key_secret else None,
token_endpoint=azure_openai_settings.get("token_endpoint"),
credential=credential,
default_headers=default_headers,
client=async_client,
@@ -20,7 +20,7 @@ from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider
from ._shared import (
AzureOpenAIConfigMixin,
AzureOpenAISettings,
_apply_azure_defaults,
_apply_azure_defaults, # pyright: ignore[reportPrivateUsage]
)
if sys.version_info >= (3, 13):
@@ -207,27 +207,31 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
# TODO(peterychang): This is a temporary hack to ensure that the base_url is set correctly
# while this feature is in preview.
# But we should only do this if we're on azure. Private deployments may not need this.
endpoint_value = azure_openai_settings.get("endpoint")
if (
not azure_openai_settings.get("base_url")
and azure_openai_settings.get("endpoint")
and (hostname := urlparse(str(azure_openai_settings["endpoint"])).hostname)
and endpoint_value
and (hostname := urlparse(str(endpoint_value)).hostname)
and hostname.endswith(".openai.azure.com")
):
azure_openai_settings["base_url"] = urljoin(str(azure_openai_settings["endpoint"]), "/openai/v1/")
azure_openai_settings["base_url"] = urljoin(str(endpoint_value), "/openai/v1/")
if not azure_openai_settings["responses_deployment_name"]:
responses_deployment_name = azure_openai_settings.get("responses_deployment_name")
if not responses_deployment_name:
raise ValueError(
"Azure OpenAI deployment name is required. Set via 'deployment_name' parameter "
"or 'AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME' environment variable."
)
api_key_secret = azure_openai_settings.get("api_key")
super().__init__(
deployment_name=azure_openai_settings["responses_deployment_name"],
endpoint=azure_openai_settings["endpoint"],
base_url=azure_openai_settings["base_url"],
api_version=azure_openai_settings["api_version"], # type: ignore
api_key=azure_openai_settings["api_key"].get_secret_value() if azure_openai_settings["api_key"] else None,
token_endpoint=azure_openai_settings["token_endpoint"],
deployment_name=responses_deployment_name,
endpoint=azure_openai_settings.get("endpoint"),
base_url=azure_openai_settings.get("base_url"),
api_version=azure_openai_settings.get("api_version") or "",
api_key=api_key_secret.get_secret_value() if api_key_secret else None,
token_endpoint=azure_openai_settings.get("token_endpoint"),
credential=credential,
default_headers=default_headers,
client=async_client,
@@ -123,6 +123,9 @@ def _apply_azure_defaults(
settings["token_endpoint"] = default_token_endpoint
_AZURE_DEFAULTS_APPLIER = _apply_azure_defaults
class AzureOpenAIConfigMixin(OpenAIBase):
"""Internal class for configuring a connection to an Azure OpenAI service."""
@@ -4,7 +4,6 @@ from agent_framework_declarative import (
AgentExternalInputRequest,
AgentExternalInputResponse,
AgentFactory,
AgentInvocationError,
DeclarativeLoaderError,
DeclarativeWorkflowError,
ExternalInputRequest,
@@ -19,7 +18,6 @@ __all__ = [
"AgentExternalInputRequest",
"AgentExternalInputResponse",
"AgentFactory",
"AgentInvocationError",
"DeclarativeLoaderError",
"DeclarativeWorkflowError",
"ExternalInputRequest",
@@ -22,7 +22,7 @@ import weakref
from collections.abc import Awaitable, Callable, Generator, Mapping, Sequence
from enum import Enum
from time import perf_counter, time_ns
from typing import TYPE_CHECKING, Any, ClassVar, Final, Generic, Literal, TypedDict, overload
from typing import TYPE_CHECKING, Any, ClassVar, Final, Generic, Literal, TypedDict, cast, overload
from dotenv import load_dotenv
from opentelemetry import metrics, trace
@@ -199,6 +199,7 @@ class OtelAttr(str, Enum):
T_TYPE_INPUT = "input"
T_TYPE_OUTPUT = "output"
DURATION_UNIT = "s"
# Agent attributes
AGENT_NAME = "gen_ai.agent.name"
AGENT_DESCRIPTION = "gen_ai.agent.description"
@@ -894,7 +895,6 @@ def get_meter(
return metrics.get_meter(name=name, version=version, schema_url=schema_url)
global OBSERVABILITY_SETTINGS
OBSERVABILITY_SETTINGS: ObservabilitySettings = ObservabilitySettings()
@@ -1053,7 +1053,15 @@ def configure_otel_providers(
if vs_code_extension_port is not None:
settings_kwargs["vs_code_extension_port"] = vs_code_extension_port
OBSERVABILITY_SETTINGS = ObservabilitySettings(**settings_kwargs)
updated_settings = ObservabilitySettings(**settings_kwargs)
OBSERVABILITY_SETTINGS.enable_instrumentation = updated_settings.enable_instrumentation
OBSERVABILITY_SETTINGS.enable_sensitive_data = updated_settings.enable_sensitive_data
OBSERVABILITY_SETTINGS.enable_console_exporters = updated_settings.enable_console_exporters
OBSERVABILITY_SETTINGS.vs_code_extension_port = updated_settings.vs_code_extension_port
OBSERVABILITY_SETTINGS.env_file_path = updated_settings.env_file_path
OBSERVABILITY_SETTINGS.env_file_encoding = updated_settings.env_file_encoding
OBSERVABILITY_SETTINGS._resource = updated_settings._resource # type: ignore[reportPrivateUsage]
OBSERVABILITY_SETTINGS._executed_setup = False # type: ignore[reportPrivateUsage]
else:
# Update the observability settings with the provided values
OBSERVABILITY_SETTINGS.enable_instrumentation = True
@@ -1146,6 +1154,8 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
**kwargs: Any,
) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]:
"""Trace chat responses with OpenTelemetry spans and metrics."""
from ._types import ChatResponse, ChatResponseUpdate, ResponseStream # type: ignore[reportUnusedImport]
global OBSERVABILITY_SETTINGS
super_get_response = super().get_response # type: ignore[misc]
@@ -1153,7 +1163,7 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
return super_get_response(messages=messages, stream=stream, options=options, **kwargs) # type: ignore[no-any-return]
opts: dict[str, Any] = options or {} # type: ignore[assignment]
provider_name = str(self.otel_provider_name)
provider_name = str(getattr(self, "otel_provider_name", "unknown"))
model_id = kwargs.get("model_id") or opts.get("model_id") or getattr(self, "model_id", None) or "unknown"
service_url_func = getattr(self, "service_url", None)
service_url = str(service_url_func() if callable(service_url_func) else "unknown")
@@ -1166,15 +1176,10 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
)
if stream:
from ._types import ResponseStream
stream_result = super_get_response(messages=messages, stream=True, options=opts, **kwargs)
if isinstance(stream_result, ResponseStream):
result_stream = stream_result
elif isinstance(stream_result, Awaitable):
result_stream = ResponseStream.from_awaitable(stream_result)
else:
raise RuntimeError("Streaming telemetry requires a ResponseStream result.")
result_stream = cast(
ResponseStream[ChatResponseUpdate, ChatResponse[Any]],
super_get_response(messages=messages, stream=True, options=opts, **kwargs),
)
# Create span directly without trace.use_span() context attachment.
# Streaming spans are closed asynchronously in cleanup hooks, which run
@@ -1209,14 +1214,14 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
from ._types import ChatResponse
try:
response = await result_stream.get_final_response()
response: ChatResponse[Any] = await result_stream.get_final_response()
duration = duration_state.get("duration")
response_attributes = _get_response_attributes(attributes, response)
_capture_response(
span=span,
attributes=response_attributes,
token_usage_histogram=self.token_usage_histogram,
operation_duration_histogram=self.duration_histogram,
token_usage_histogram=getattr(self, "token_usage_histogram", None),
operation_duration_histogram=getattr(self, "duration_histogram", None),
duration=duration,
)
if (
@@ -1238,7 +1243,9 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
# Register a weak reference callback to close the span if stream is garbage collected
# without being consumed. This ensures spans don't leak if users don't consume streams.
wrapped_stream = result_stream.with_cleanup_hook(_record_duration).with_cleanup_hook(_finalize_stream)
wrapped_stream: ResponseStream[ChatResponseUpdate, ChatResponse[Any]] = result_stream.with_cleanup_hook(
_record_duration
).with_cleanup_hook(_finalize_stream)
weakref.finalize(wrapped_stream, _close_span)
return wrapped_stream
@@ -1253,7 +1260,15 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
)
start_time_stamp = perf_counter()
try:
response = await super_get_response(messages=messages, stream=False, options=opts, **kwargs)
response = cast(
ChatResponse[Any],
await super_get_response(
messages=messages,
stream=False,
options=opts,
**kwargs,
),
)
except Exception as exception:
capture_exception(span=span, exception=exception, timestamp=time_ns())
raise
@@ -1262,16 +1277,20 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
_capture_response(
span=span,
attributes=response_attributes,
token_usage_histogram=self.token_usage_histogram,
operation_duration_histogram=self.duration_histogram,
token_usage_histogram=getattr(self, "token_usage_histogram", None),
operation_duration_histogram=getattr(self, "duration_histogram", None),
duration=duration,
)
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages:
finish_reason = cast(
"FinishReason | None",
response.finish_reason if response.finish_reason in FINISH_REASON_MAP else None,
)
_capture_messages(
span=span,
provider_name=provider_name,
messages=response.messages,
finish_reason=response.finish_reason,
finish_reason=finish_reason,
output=True,
)
return response # type: ignore[return-value,no-any-return]
@@ -1302,8 +1321,10 @@ class EmbeddingTelemetryLayer(Generic[EmbeddingInputT, EmbeddingT, EmbeddingOpti
values: Sequence[EmbeddingInputT],
*,
options: EmbeddingOptionsT | None = None,
) -> GeneratedEmbeddings[EmbeddingT]:
) -> GeneratedEmbeddings[EmbeddingT, EmbeddingOptionsT]:
"""Trace embedding generation with OpenTelemetry spans and metrics."""
from ._types import GeneratedEmbeddings # type: ignore[reportUnusedImport]
global OBSERVABILITY_SETTINGS
super_get_embeddings = super().get_embeddings # type: ignore[misc]
@@ -1311,7 +1332,7 @@ class EmbeddingTelemetryLayer(Generic[EmbeddingInputT, EmbeddingT, EmbeddingOpti
return await super_get_embeddings(values, options=options) # type: ignore[no-any-return]
opts: dict[str, Any] = options or {} # type: ignore[assignment]
provider_name = str(self.otel_provider_name)
provider_name = str(getattr(self, "otel_provider_name", "unknown"))
model_id = opts.get("model_id") or getattr(self, "model_id", None) or "unknown"
service_url_func = getattr(self, "service_url", None)
service_url = str(service_url_func() if callable(service_url_func) else "unknown")
@@ -1325,14 +1346,18 @@ class EmbeddingTelemetryLayer(Generic[EmbeddingInputT, EmbeddingT, EmbeddingOpti
with _get_span(attributes=attributes, span_name_attribute=OtelAttr.REQUEST_MODEL) as span:
start_time_stamp = perf_counter()
try:
result = await super_get_embeddings(values, options=options)
result = cast(
GeneratedEmbeddings[EmbeddingT, EmbeddingOptionsT],
await super_get_embeddings(values, options=options),
)
except Exception as exception:
capture_exception(span=span, exception=exception, timestamp=time_ns())
raise
duration = perf_counter() - start_time_stamp
response_attributes: dict[str, Any] = {**attributes}
if result.usage and "prompt_tokens" in result.usage:
response_attributes[OtelAttr.INPUT_TOKENS] = result.usage["prompt_tokens"]
usage = result.usage or {}
if (input_tokens := usage.get("input_token_count")) is not None:
response_attributes[OtelAttr.INPUT_TOKENS] = input_tokens
_capture_response(
span=span,
attributes=response_attributes,
@@ -1391,7 +1416,12 @@ class AgentTelemetryLayer:
) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]:
"""Trace agent runs with OpenTelemetry spans and metrics."""
global OBSERVABILITY_SETTINGS
super_run = super().run # type: ignore[misc]
from ._types import ResponseStream, merge_chat_options
super_run = cast(
"Callable[..., Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]]",
super().run, # type: ignore[misc]
)
provider_name = str(self.otel_provider_name)
capture_usage = bool(getattr(self, "_otel_capture_usage", True))
@@ -1403,8 +1433,6 @@ class AgentTelemetryLayer:
**kwargs,
)
from ._types import ResponseStream, merge_chat_options
default_options = getattr(self, "default_options", {})
options = kwargs.get("options")
merged_options: dict[str, Any] = merge_chat_options(default_options, options or {})
@@ -1420,16 +1448,16 @@ class AgentTelemetryLayer:
)
if stream:
run_result = super_run(
run_result: object = super_run(
messages=messages,
stream=True,
session=session,
**kwargs,
)
if isinstance(run_result, ResponseStream):
result_stream = run_result
result_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]] = run_result # pyright: ignore[reportUnknownVariableType]
elif isinstance(run_result, Awaitable):
result_stream = ResponseStream.from_awaitable(run_result)
result_stream = ResponseStream.from_awaitable(run_result) # type: ignore[arg-type] # pyright: ignore[reportArgumentType]
else:
raise RuntimeError("Streaming telemetry requires a ResponseStream result.")
@@ -1466,7 +1494,7 @@ class AgentTelemetryLayer:
from ._types import AgentResponse
try:
response = await result_stream.get_final_response()
response: AgentResponse[Any] = await result_stream.get_final_response()
duration = duration_state.get("duration")
response_attributes = _get_response_attributes(
attributes,
@@ -1492,7 +1520,9 @@ class AgentTelemetryLayer:
# Register a weak reference callback to close the span if stream is garbage collected
# without being consumed. This ensures spans don't leak if users don't consume streams.
wrapped_stream = result_stream.with_cleanup_hook(_record_duration).with_cleanup_hook(_finalize_stream)
wrapped_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]] = result_stream.with_cleanup_hook(
_record_duration
).with_cleanup_hook(_finalize_stream)
weakref.finalize(wrapped_stream, _close_span)
return wrapped_stream
@@ -1507,7 +1537,7 @@ class AgentTelemetryLayer:
)
start_time_stamp = perf_counter()
try:
response = await super_run(
response: AgentResponse[Any] = await super_run(
messages=messages,
stream=False,
session=session,
@@ -1598,12 +1628,17 @@ def _get_span(
yield current_span
def _get_instructions_from_options(options: Any) -> str | None:
def _get_instructions_from_options(options: Any) -> str | list[str] | None:
"""Extract instructions from options dict."""
if options is None:
return None
if isinstance(options, dict):
return options.get("instructions")
if isinstance(options, Mapping):
instructions = cast(Mapping[str, Any], options).get("instructions")
if isinstance(instructions, str):
return instructions
if isinstance(instructions, list) and all(isinstance(item, str) for item in instructions): # type: ignore[reportUnknownVariableType]
return instructions # type: ignore[reportUnknownVariableType]
return None
return None
@@ -1662,8 +1697,7 @@ def _get_span_attributes(**kwargs: Any) -> dict[str, Any]:
"""Get the span attributes from a kwargs dictionary."""
attributes: dict[str, Any] = {}
options = kwargs.get("all_options", kwargs.get("options"))
if options is not None and not isinstance(options, dict):
options = None
options_mapping = cast(Mapping[str, Any], options) if isinstance(options, Mapping) else None
for source_keys, (otel_key, transform_func, check_options, default_value) in OTEL_ATTR_MAP.items():
# Normalize to tuple of keys
@@ -1671,8 +1705,8 @@ def _get_span_attributes(**kwargs: Any) -> dict[str, Any]:
value = None
for key in keys:
if check_options and options is not None:
value = options.get(key)
if check_options and options_mapping is not None:
value = options_mapping.get(key)
if value is None:
value = kwargs.get(key)
if value is not None:
@@ -1743,7 +1777,7 @@ def _to_otel_message(message: Message) -> dict[str, Any]:
def _to_otel_part(content: Content) -> dict[str, Any] | None:
"""Create a otel representation of a Content."""
from ._types import _get_data_bytes_as_str
from ._types import _get_data_bytes_as_str # pyright: ignore[reportPrivateUsage]
match content.type:
case "text":
@@ -1798,10 +1832,12 @@ def _get_response_attributes(
if model_id := getattr(response, "model_id", None):
attributes[OtelAttr.RESPONSE_MODEL] = model_id
if capture_usage and (usage := response.usage_details):
if usage.get("input_token_count"):
attributes[OtelAttr.INPUT_TOKENS] = usage["input_token_count"]
if usage.get("output_token_count"):
attributes[OtelAttr.OUTPUT_TOKENS] = usage["output_token_count"]
input_tokens = usage.get("input_token_count")
if input_tokens:
attributes[OtelAttr.INPUT_TOKENS] = input_tokens
output_tokens = usage.get("output_token_count")
if output_tokens:
attributes[OtelAttr.OUTPUT_TOKENS] = output_tokens
return attributes
@@ -3,7 +3,7 @@
from __future__ import annotations
import sys
from collections.abc import Awaitable, Callable, MutableMapping, Sequence
from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequence
from typing import TYPE_CHECKING, Any, Generic, cast
from openai import AsyncOpenAI
@@ -149,24 +149,25 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]):
env_file_encoding=env_file_encoding,
)
if not settings["api_key"]:
api_key_setting = settings.get("api_key")
if not api_key_setting:
raise ValueError(
"OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable."
)
# Get API key value
api_key_value: str | Callable[[], str | Awaitable[str]] | None
if isinstance(settings["api_key"], SecretString):
api_key_value = settings["api_key"].get_secret_value()
api_key_value: str | Callable[[], str | Awaitable[str]]
if isinstance(api_key_setting, SecretString):
api_key_value = api_key_setting.get_secret_value()
else:
api_key_value = settings["api_key"]
api_key_value = api_key_setting
# Create client
client_args: dict[str, Any] = {"api_key": api_key_value}
if settings["org_id"]:
client_args["organization"] = settings["org_id"]
if settings["base_url"]:
client_args["base_url"] = settings["base_url"]
if org_id_value := settings.get("org_id"):
client_args["organization"] = org_id_value
if base_url_value := settings.get("base_url"):
client_args["base_url"] = base_url_value
self._client = AsyncOpenAI(**client_args)
@@ -250,7 +251,9 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]):
"""
# Normalize tools
normalized_tools = normalize_tools(tools)
assistant_tools = [tool for tool in normalized_tools if isinstance(tool, (FunctionTool, MutableMapping))]
assistant_tools: list[FunctionTool | MutableMapping[str, Any]] = [
tool for tool in normalized_tools if isinstance(tool, (FunctionTool, MutableMapping))
]
api_tools = to_assistant_tools(assistant_tools) if assistant_tools else []
# Extract response_format from default_options if present
@@ -287,7 +290,7 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]):
if not self._client:
raise RuntimeError("OpenAI client is not initialized.")
assistant = await self._client.beta.assistants.create(**create_params)
assistant = await self._client.beta.assistants.create(**create_params) # type: ignore[reportDeprecated]
# Create Agent - pass default_options which contains response_format
return self._create_chat_agent_from_assistant(
@@ -353,7 +356,7 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]):
if not self._client:
raise RuntimeError("OpenAI client is not initialized.")
assistant = await self._client.beta.assistants.retrieve(assistant_id)
assistant = await self._client.beta.assistants.retrieve(assistant_id) # type: ignore[reportDeprecated]
# Use as_agent to wrap it
return self.as_agent(
@@ -466,12 +469,14 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]):
for tool in normalized:
if isinstance(tool, FunctionTool):
provided_functions.add(tool.name)
elif isinstance(tool, MutableMapping) and "function" in tool:
func_spec = tool.get("function", {})
if isinstance(func_spec, dict):
func_dict = cast(dict[str, Any], func_spec)
if "name" in func_dict:
provided_functions.add(str(func_dict["name"]))
elif isinstance(tool, Mapping):
typed_tool = cast(Mapping[str, Any], tool)
raw_func_spec = typed_tool.get("function")
if isinstance(raw_func_spec, Mapping):
typed_func_spec = cast(Mapping[str, Any], raw_func_spec)
raw_name = typed_func_spec.get("name")
if isinstance(raw_name, str) and raw_name:
provided_functions.add(raw_name)
# Check for missing functions
missing = required_functions - provided_functions
@@ -360,23 +360,26 @@ class OpenAIAssistantsClient( # type: ignore[misc]
env_file_encoding=env_file_encoding,
)
if not async_client and not openai_settings["api_key"]:
api_key_value = openai_settings.get("api_key")
if not async_client and not api_key_value:
raise ValueError(
"OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable."
)
if not openai_settings["chat_model_id"]:
chat_model_id = openai_settings.get("chat_model_id")
if not chat_model_id:
raise ValueError(
"OpenAI model ID is required. "
"Set via 'model_id' parameter or 'OPENAI_CHAT_MODEL_ID' environment variable."
)
super().__init__(
model_id=openai_settings["chat_model_id"],
api_key=self._get_api_key(openai_settings["api_key"]),
org_id=openai_settings["org_id"],
model_id=chat_model_id,
api_key=self._get_api_key(api_key_value),
org_id=openai_settings.get("org_id"),
default_headers=default_headers,
client=async_client,
base_url=openai_settings["base_url"],
base_url=openai_settings.get("base_url"),
middleware=middleware,
function_invocation_configuration=function_invocation_configuration,
)
@@ -403,7 +406,7 @@ class OpenAIAssistantsClient( # type: ignore[misc]
"""Clean up any assistants we created."""
if self._should_delete_assistant and self.assistant_id is not None:
client = await self._ensure_client()
await client.beta.assistants.delete(self.assistant_id)
await client.beta.assistants.delete(self.assistant_id) # type: ignore[reportDeprecated]
object.__setattr__(self, "assistant_id", None)
object.__setattr__(self, "_should_delete_assistant", False)
@@ -466,7 +469,7 @@ class OpenAIAssistantsClient( # type: ignore[misc]
raise ValueError("Parameter 'model_id' is required for assistant creation.")
client = await self._ensure_client()
created_assistant = await client.beta.assistants.create(
created_assistant = await client.beta.assistants.create( # type: ignore[reportDeprecated]
model=self.model_id,
description=self.assistant_description,
name=self.assistant_name,
@@ -568,7 +571,8 @@ class OpenAIAssistantsClient( # type: ignore[misc]
if isinstance(delta_block, TextDeltaBlock) and delta_block.text and delta_block.text.value:
text_content = Content.from_text(delta_block.text.value)
if delta_block.text.annotations:
text_content.annotations = []
annotations: list[Annotation] = []
text_content.annotations = annotations
for annotation in delta_block.text.annotations:
if isinstance(annotation, FileCitationDeltaAnnotation):
ann: Annotation = Annotation(
@@ -589,7 +593,7 @@ class OpenAIAssistantsClient( # type: ignore[misc]
end_index=annotation.end_index,
)
]
text_content.annotations.append(ann)
annotations.append(ann)
elif isinstance(annotation, FilePathDeltaAnnotation):
ann = Annotation(
type="citation",
@@ -609,7 +613,7 @@ class OpenAIAssistantsClient( # type: ignore[misc]
end_index=annotation.end_index,
)
]
text_content.annotations.append(ann)
annotations.append(ann)
yield ChatResponseUpdate(
role=role, # type: ignore[arg-type]
contents=[text_content],
@@ -628,7 +632,8 @@ class OpenAIAssistantsClient( # type: ignore[misc]
continue
text_content = Content.from_text(block.text.value)
if block.text.annotations:
text_content.annotations = []
completed_annotations: list[Annotation] = []
text_content.annotations = completed_annotations
for completed_annotation in block.text.annotations:
if isinstance(completed_annotation, FileCitationAnnotation):
props: dict[str, Any] = {
@@ -644,17 +649,13 @@ class OpenAIAssistantsClient( # type: ignore[misc]
and completed_annotation.file_citation.file_id
):
ann["file_id"] = completed_annotation.file_citation.file_id
if (
completed_annotation.start_index is not None
and completed_annotation.end_index is not None
):
ann["annotated_regions"] = [
TextSpanRegion(
type="text_span",
start_index=completed_annotation.start_index,
end_index=completed_annotation.end_index,
)
]
ann["annotated_regions"] = [
TextSpanRegion(
type="text_span",
start_index=completed_annotation.start_index,
end_index=completed_annotation.end_index,
)
]
text_content.annotations.append(ann)
elif isinstance(completed_annotation, FilePathAnnotation):
ann = Annotation(
@@ -666,17 +667,13 @@ class OpenAIAssistantsClient( # type: ignore[misc]
)
if completed_annotation.file_path and completed_annotation.file_path.file_id:
ann["file_id"] = completed_annotation.file_path.file_id
if (
completed_annotation.start_index is not None
and completed_annotation.end_index is not None
):
ann["annotated_regions"] = [
TextSpanRegion(
type="text_span",
start_index=completed_annotation.start_index,
end_index=completed_annotation.end_index,
)
]
ann["annotated_regions"] = [
TextSpanRegion(
type="text_span",
start_index=completed_annotation.start_index,
end_index=completed_annotation.end_index,
)
]
text_content.annotations.append(ann)
else:
logger.debug("Unparsed annotation type: %s", completed_annotation.type)
@@ -823,15 +820,16 @@ class OpenAIAssistantsClient( # type: ignore[misc]
tool_definitions.append(tool.to_json_schema_spec()) # type: ignore[reportUnknownArgumentType]
elif isinstance(tool, MutableMapping):
# Pass through dict-based tools directly (from static factory methods)
tool_definitions.append(tool)
tool_definitions.append(cast(MutableMapping[str, Any], tool))
if len(tool_definitions) > 0:
run_options["tools"] = tool_definitions
if tool_mode is not None:
if (mode := tool_mode["mode"]) == "required" and (
func_name := tool_mode.get("required_function_name")
) is not None:
mode = tool_mode.get("mode")
if mode is None:
raise ValueError("tool_choice mode is required")
if mode == "required" and (func_name := tool_mode.get("required_function_name")) is not None:
run_options["tool_choice"] = {
"type": "function",
"function": {"name": func_name},
@@ -15,7 +15,7 @@ from collections.abc import (
)
from datetime import datetime, timezone
from itertools import chain
from typing import Any, Generic, Literal
from typing import Any, Generic, Literal, cast
from openai import AsyncOpenAI, BadRequestError
from openai.lib._parsing._completions import type_to_response_format_param
@@ -301,11 +301,16 @@ class RawOpenAIChatClient( # type: ignore[misc]
for tool in normalize_tools(tools):
if isinstance(tool, FunctionTool):
chat_tools.append(tool.to_json_schema_spec())
elif isinstance(tool, MutableMapping) and tool.get("type") == "web_search":
# Web search is handled via web_search_options, not tools array
web_search_options = {k: v for k, v in tool.items() if k != "type"}
elif isinstance(tool, MutableMapping):
typed_tool = cast(MutableMapping[str, Any], tool)
if typed_tool.get("type") == "web_search":
# Web search is handled via web_search_options, not tools array
web_search_options = {k: v for k, v in typed_tool.items() if k != "type"}
else:
# Pass through all other dict-based tools unchanged
chat_tools.append(typed_tool)
else:
# Pass through all other tools (dicts, SDK types) unchanged
# Pass through all other tools (SDK types) unchanged
chat_tools.append(tool)
result: dict[str, Any] = {}
if chat_tools:
@@ -608,10 +613,21 @@ class RawOpenAIChatClient( # type: ignore[misc]
# See https://github.com/microsoft/agent-framework/issues/4084
for msg in all_messages:
msg_content: Any = msg.get("content")
if isinstance(msg_content, list) and all(
isinstance(c, dict) and c.get("type") == "text" for c in msg_content
):
msg["content"] = "\n".join(c.get("text", "") for c in msg_content)
if isinstance(msg_content, list):
typed_msg_content = cast(list[object], msg_content)
text_items: list[Mapping[str, Any]] = []
for item in typed_msg_content:
if not isinstance(item, Mapping):
break
text_item = cast(Mapping[str, Any], item)
if text_item.get("type") != "text":
break
text_items.append(text_item)
else:
msg["content"] = "\n".join(
text_item.get("text", "") if isinstance(text_item.get("text", ""), str) else ""
for text_item in text_items
)
return all_messages
@@ -775,21 +791,26 @@ class OpenAIChatClient( # type: ignore[misc]
env_file_encoding=env_file_encoding,
)
if not async_client and not openai_settings["api_key"]:
api_key_value = openai_settings.get("api_key")
if not async_client and not api_key_value:
raise ValueError(
"OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable."
)
if not openai_settings["chat_model_id"]:
chat_model_id = openai_settings.get("chat_model_id")
if not chat_model_id:
raise ValueError(
"OpenAI model ID is required. "
"Set via 'model_id' parameter or 'OPENAI_CHAT_MODEL_ID' environment variable."
)
base_url_value = openai_settings.get("base_url")
super().__init__(
model_id=openai_settings["chat_model_id"],
api_key=self._get_api_key(openai_settings["api_key"]),
base_url=openai_settings["base_url"] if openai_settings["base_url"] else None,
org_id=openai_settings["org_id"],
model_id=chat_model_id,
api_key=self._get_api_key(api_key_value),
base_url=base_url_value if base_url_value else None,
org_id=openai_settings.get("org_id"),
default_headers=default_headers,
client=async_client,
instruction_role=instruction_role,
@@ -67,7 +67,7 @@ class RawOpenAIEmbeddingClient(
values: Sequence[str],
*,
options: OpenAIEmbeddingOptionsT | None = None,
) -> GeneratedEmbeddings[list[float]]:
) -> GeneratedEmbeddings[list[float], OpenAIEmbeddingOptionsT]:
"""Call the OpenAI embeddings API.
Args:
@@ -81,9 +81,9 @@ class RawOpenAIEmbeddingClient(
ValueError: If model_id is not provided or values is empty.
"""
if not values:
return GeneratedEmbeddings([], options=options)
return GeneratedEmbeddings([], options=options) # type: ignore
opts: dict[str, Any] = dict(options) if options else {}
opts: dict[str, Any] = options or {} # type: ignore
model = opts.get("model_id") or self.model_id
if not model:
raise ValueError("model_id is required")
@@ -193,21 +193,26 @@ class OpenAIEmbeddingClient(
env_file_encoding=env_file_encoding,
)
if not async_client and not openai_settings["api_key"]:
api_key_value = openai_settings.get("api_key")
if not async_client and not api_key_value:
raise ValueError(
"OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable."
)
if not openai_settings["embedding_model_id"]:
embedding_model_id = openai_settings.get("embedding_model_id")
if not embedding_model_id:
raise ValueError(
"OpenAI embedding model ID is required. "
"Set via 'model_id' parameter or 'OPENAI_EMBEDDING_MODEL_ID' environment variable."
)
base_url_value = openai_settings.get("base_url")
super().__init__(
model_id=openai_settings["embedding_model_id"],
api_key=self._get_api_key(openai_settings["api_key"]),
base_url=openai_settings["base_url"] if openai_settings["base_url"] else None,
org_id=openai_settings["org_id"],
model_id=embedding_model_id,
api_key=self._get_api_key(api_key_value),
base_url=base_url_value if base_url_value else None,
org_id=openai_settings.get("org_id"),
default_headers=default_headers,
client=async_client,
otel_provider_name=otel_provider_name,
@@ -460,14 +460,13 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
for tool_item in tools_list:
if isinstance(tool_item, FunctionTool) and tool_item.kind == SHELL_TOOL_KIND_VALUE:
shell_env = (tool_item.additional_properties or {}).get(OPENAI_SHELL_ENVIRONMENT_KEY)
if isinstance(shell_env, Mapping):
response_tools.append(
FunctionShellTool(
type="shell",
environment=dict(shell_env),
)
response_tools.append(
FunctionShellTool(
type="shell",
environment=shell_env, # type: ignore[typeddict-item]
)
continue
)
continue
if isinstance(tool_item, FunctionTool):
params = tool_item.parameters()
params["additionalProperties"] = False
@@ -496,7 +495,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
if tool_item.kind != SHELL_TOOL_KIND_VALUE:
continue
shell_env = (tool_item.additional_properties or {}).get(OPENAI_SHELL_ENVIRONMENT_KEY)
if isinstance(shell_env, Mapping) and shell_env.get("type") == "local":
if isinstance(shell_env, Mapping) and shell_env.get("type") == "local": # type: ignore[typeddict-item]
return tool_item.name
return None
@@ -714,7 +713,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
)
if env_config.get("type") == "local":
raise ValueError("Local shell requires func. Provide func for local execution.")
return FunctionShellTool(type="shell", environment=env_config)
return FunctionShellTool(type="shell", environment=env_config) # type: ignore[typeddict-item]
if isinstance(environment, dict):
raise ValueError("When func is provided, environment config is not supported.")
@@ -1226,7 +1225,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
"""Convert function tool output to the local shell JSON payload format."""
payload: dict[str, Any]
if isinstance(content.result, Mapping):
payload = dict(content.result)
payload = dict(content.result) # type: ignore[assignment]
else:
payload = {
"stdout": "" if content.result is None else str(content.result),
@@ -1242,7 +1241,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
"""Convert function tool output to shell_call_output payload format."""
payload: dict[str, Any]
if isinstance(content.result, Mapping):
payload = dict(content.result)
payload = dict(content.result) # type: ignore[assignment]
else:
payload = {
"stdout": "" if content.result is None else str(content.result),
@@ -1252,8 +1251,8 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
# Pass through native payload shape when tool already returns shell output entries.
direct_output = payload.get("output")
if isinstance(direct_output, list) and all(isinstance(item, Mapping) for item in direct_output):
return [dict(item) for item in direct_output]
if isinstance(direct_output, list) and all(isinstance(item, Mapping) for item in direct_output): # type: ignore[reportUnknownMemberType]
return [dict(item) for item in direct_output] # type: ignore[reportUnknownMemberType]
stdout = str(payload.get("stdout", ""))
stderr = str(payload.get("stderr", ""))
@@ -2293,24 +2292,26 @@ class OpenAIResponsesClient( # type: ignore[misc]
env_file_encoding=env_file_encoding,
)
if not async_client and not openai_settings["api_key"]:
api_key_setting = openai_settings.get("api_key")
if not async_client and not api_key_setting:
raise ValueError(
"OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable."
)
if not openai_settings["responses_model_id"]:
responses_model_id = openai_settings.get("responses_model_id")
if not responses_model_id:
raise ValueError(
"OpenAI model ID is required. "
"Set via 'model_id' parameter or 'OPENAI_RESPONSES_MODEL_ID' environment variable."
)
super().__init__(
model_id=openai_settings["responses_model_id"],
api_key=self._get_api_key(openai_settings["api_key"]),
org_id=openai_settings["org_id"],
model_id=responses_model_id,
api_key=self._get_api_key(api_key_setting),
org_id=openai_settings.get("org_id"),
default_headers=default_headers,
client=async_client,
instruction_role=instruction_role,
base_url=openai_settings["base_url"],
base_url=openai_settings.get("base_url"),
middleware=middleware,
function_invocation_configuration=function_invocation_configuration,
**kwargs,
@@ -6,7 +6,7 @@ import logging
import sys
from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequence
from copy import copy
from typing import Any, ClassVar, Union
from typing import Any, ClassVar, Union, cast
import openai
from openai import (
@@ -332,8 +332,10 @@ def from_assistant_tools(
for tool in assistant_tools:
if hasattr(tool, "type"):
tool_type = tool.type
elif isinstance(tool, dict):
tool_type = tool.get("type")
elif isinstance(tool, Mapping):
typed_tool = cast(Mapping[str, Any], tool)
tool_type_value: Any = typed_tool.get("type")
tool_type = tool_type_value if isinstance(tool_type_value, str) else None
else:
tool_type = None
+3 -2
View File
@@ -104,11 +104,12 @@ extend = "../../pyproject.toml"
[tool.pyright]
extends = "../../pyproject.toml"
include = ["tests/workflow"]
include = ["agent_framework", "tests/workflow"]
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
incremental = false
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
@@ -130,7 +131,7 @@ include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework"
test = "pytest --cov=agent_framework --cov-report=term-missing:skip-covered -n auto --dist worksteal tests"
test = "pytest -m \"not integration\" --cov=agent_framework --cov-report=term-missing:skip-covered -n auto --dist worksteal tests"
[tool.flit.module]
name = "agent_framework"
@@ -10,7 +10,7 @@ from unittest.mock import AsyncMock
import pytest
from agent_framework import Skill, SkillResource, SkillsProvider, SessionContext
from agent_framework import SessionContext, Skill, SkillResource, SkillsProvider
from agent_framework._skills import (
DEFAULT_RESOURCE_EXTENSIONS,
_create_instructions,
@@ -1348,9 +1348,7 @@ class TestReadAndParseSkillFile:
def test_valid_file(self, tmp_path: Path) -> None:
skill_dir = tmp_path / "my-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
"---\nname: my-skill\ndescription: A skill.\n---\nBody.", encoding="utf-8"
)
(skill_dir / "SKILL.md").write_text("---\nname: my-skill\ndescription: A skill.\n---\nBody.", encoding="utf-8")
result = _read_and_parse_skill_file(str(skill_dir))
assert result is not None
name, desc, content = result
@@ -1393,7 +1391,7 @@ class TestCreateResourceElement:
def test_xml_escapes_name(self) -> None:
r = SkillResource(name='ref"special', content="data")
elem = _create_resource_element(r)
assert '&quot;' in elem
assert "&quot;" in elem
def test_xml_escapes_description(self) -> None:
r = SkillResource(name="ref", description='Uses <tags> & "quotes"', content="data")
+1 -465
View File
@@ -5,7 +5,7 @@ from unittest.mock import Mock
import pytest
from opentelemetry import trace
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from pydantic import BaseModel, ValidationError
from pydantic import BaseModel
from agent_framework import (
Content,
@@ -13,7 +13,6 @@ from agent_framework import (
tool,
)
from agent_framework._tools import (
_build_pydantic_model_from_json_schema,
_parse_annotation,
_parse_inputs,
)
@@ -1001,467 +1000,4 @@ def test_parse_annotation_with_annotated_and_literal():
assert get_args(literal_type) == ("A", "B", "C")
def test_build_pydantic_model_from_json_schema_array_of_objects_issue():
"""Test for Tools with complex input schema (array of objects).
This test verifies that JSON schemas with array properties containing nested objects
are properly parsed, ensuring that the nested object schema is preserved
and not reduced to a bare dict.
Example from issue:
```
const SalesOrderItemSchema = z.object({
customerMaterialNumber: z.string().optional(),
quantity: z.number(),
unitOfMeasure: z.string()
});
const CreateSalesOrderInputSchema = z.object({
contract: z.string(),
items: z.array(SalesOrderItemSchema)
});
```
The issue was that agents only saw:
```
{"contract": "str", "items": "list[dict]"}
```
Instead of the proper nested schema with all fields.
"""
# Schema matching the issue description
schema = {
"type": "object",
"properties": {
"contract": {"type": "string", "description": "Reference contract number"},
"items": {
"type": "array",
"description": "Sales order line items",
"items": {
"type": "object",
"properties": {
"customerMaterialNumber": {
"type": "string",
"description": "Customer's material number",
},
"quantity": {"type": "number", "description": "Order quantity"},
"unitOfMeasure": {
"type": "string",
"description": "Unit of measure (e.g., 'ST', 'KG', 'TO')",
},
},
"required": ["quantity", "unitOfMeasure"],
},
},
},
"required": ["contract", "items"],
}
model = _build_pydantic_model_from_json_schema("create_sales_order", schema)
# Test valid data
valid_data = {
"contract": "CONTRACT-123",
"items": [
{
"customerMaterialNumber": "MAT-001",
"quantity": 10,
"unitOfMeasure": "ST",
},
{"quantity": 5.5, "unitOfMeasure": "KG"},
],
}
instance = model(**valid_data)
# Verify the data was parsed correctly
assert instance.contract == "CONTRACT-123"
assert len(instance.items) == 2
# Verify first item
assert instance.items[0].customerMaterialNumber == "MAT-001"
assert instance.items[0].quantity == 10
assert instance.items[0].unitOfMeasure == "ST"
# Verify second item (optional field not provided)
assert instance.items[1].quantity == 5.5
assert instance.items[1].unitOfMeasure == "KG"
# Verify that items are proper BaseModel instances, not bare dicts
assert isinstance(instance.items[0], BaseModel)
assert isinstance(instance.items[1], BaseModel)
# Verify that the nested object has the expected fields
assert hasattr(instance.items[0], "customerMaterialNumber")
assert hasattr(instance.items[0], "quantity")
assert hasattr(instance.items[0], "unitOfMeasure")
# CRITICAL: Validate using the same methods that actual chat clients use
# This is what would actually be sent to the LLM
# Create a FunctionTool wrapper to access the client-facing APIs
def dummy_func(**kwargs):
return kwargs
test_func = FunctionTool(
func=dummy_func,
name="create_sales_order",
description="Create a sales order",
input_model=model,
)
# Test 1: Anthropic client uses tool.parameters() directly
anthropic_schema = test_func.parameters()
# Verify contract property
assert "contract" in anthropic_schema["properties"]
assert anthropic_schema["properties"]["contract"]["type"] == "string"
# Verify items array property exists
assert "items" in anthropic_schema["properties"]
items_prop = anthropic_schema["properties"]["items"]
assert items_prop["type"] == "array"
# THE KEY TEST for Anthropic: array items must have proper object schema
assert "items" in items_prop, "Array should have 'items' schema definition"
array_items_schema = items_prop["items"]
# Resolve schema if using $ref
if "$ref" in array_items_schema:
ref_path = array_items_schema["$ref"]
assert ref_path.startswith("#/$defs/") or ref_path.startswith("#/definitions/")
ref_name = ref_path.split("/")[-1]
defs = anthropic_schema.get("$defs", anthropic_schema.get("definitions", {}))
assert ref_name in defs, f"Referenced schema '{ref_name}' should exist"
item_schema = defs[ref_name]
else:
item_schema = array_items_schema
# Verify the nested object has all properties defined
assert "properties" in item_schema, "Array items should have properties (not bare dict)"
item_properties = item_schema["properties"]
# All three fields must be present in schema sent to LLM
assert "customerMaterialNumber" in item_properties, "customerMaterialNumber missing from LLM schema"
assert "quantity" in item_properties, "quantity missing from LLM schema"
assert "unitOfMeasure" in item_properties, "unitOfMeasure missing from LLM schema"
# Verify types are correct
assert item_properties["customerMaterialNumber"]["type"] == "string"
assert item_properties["quantity"]["type"] in ["number", "integer"]
assert item_properties["unitOfMeasure"]["type"] == "string"
# Test 2: OpenAI client uses tool.to_json_schema_spec()
openai_spec = test_func.to_json_schema_spec()
assert openai_spec["type"] == "function"
assert "function" in openai_spec
openai_schema = openai_spec["function"]["parameters"]
# Verify the same structure is present in OpenAI format
assert "items" in openai_schema["properties"]
openai_items_prop = openai_schema["properties"]["items"]
assert openai_items_prop["type"] == "array"
assert "items" in openai_items_prop
openai_array_items = openai_items_prop["items"]
if "$ref" in openai_array_items:
ref_path = openai_array_items["$ref"]
ref_name = ref_path.split("/")[-1]
defs = openai_schema.get("$defs", openai_schema.get("definitions", {}))
openai_item_schema = defs[ref_name]
else:
openai_item_schema = openai_array_items
assert "properties" in openai_item_schema
openai_props = openai_item_schema["properties"]
assert "customerMaterialNumber" in openai_props
assert "quantity" in openai_props
assert "unitOfMeasure" in openai_props
# Test validation - missing required quantity
with pytest.raises(ValidationError):
model(
contract="CONTRACT-456",
items=[
{
"customerMaterialNumber": "MAT-002",
"unitOfMeasure": "TO",
# Missing required 'quantity'
}
],
)
# Test validation - missing required unitOfMeasure
with pytest.raises(ValidationError):
model(
contract="CONTRACT-789",
items=[
{
"quantity": 20
# Missing required 'unitOfMeasure'
}
],
)
def test_one_of_discriminator_polymorphism():
"""Test that oneOf with discriminator creates proper polymorphic union types.
Tests that oneOf + discriminator patterns are properly converted to Pydantic discriminated unions.
"""
schema = {
"$defs": {
"CreateProject": {
"description": "Action: Create an Azure DevOps project.",
"properties": {
"name": {
"const": "create_project",
"default": "create_project",
"type": "string",
},
"params": {"$ref": "#/$defs/CreateProjectParams"},
},
"required": ["params"],
"type": "object",
},
"CreateProjectParams": {
"description": "Parameters for the create_project action.",
"properties": {
"orgUrl": {"minLength": 1, "type": "string"},
"projectName": {"minLength": 1, "type": "string"},
"description": {"default": "", "type": "string"},
"template": {"default": "Agile", "type": "string"},
"sourceControl": {
"default": "Git",
"enum": ["Git", "Tfvc"],
"type": "string",
},
"visibility": {"default": "private", "type": "string"},
},
"required": ["orgUrl", "projectName"],
"type": "object",
},
"DeployRequest": {
"description": "Request to deploy Azure DevOps resources.",
"properties": {
"projectName": {"minLength": 1, "type": "string"},
"organization": {"minLength": 1, "type": "string"},
"actions": {
"items": {
"discriminator": {
"mapping": {
"create_project": "#/$defs/CreateProject",
"hello_world": "#/$defs/HelloWorld",
},
"propertyName": "name",
},
"oneOf": [
{"$ref": "#/$defs/HelloWorld"},
{"$ref": "#/$defs/CreateProject"},
],
},
"type": "array",
},
},
"required": ["projectName", "organization"],
"type": "object",
},
"HelloWorld": {
"description": "Action: Prints a greeting message.",
"properties": {
"name": {
"const": "hello_world",
"default": "hello_world",
"type": "string",
},
"params": {"$ref": "#/$defs/HelloWorldParams"},
},
"required": ["params"],
"type": "object",
},
"HelloWorldParams": {
"description": "Parameters for the hello_world action.",
"properties": {
"name": {
"description": "Name to greet",
"minLength": 1,
"type": "string",
}
},
"required": ["name"],
"type": "object",
},
},
"properties": {"params": {"$ref": "#/$defs/DeployRequest"}},
"required": ["params"],
"type": "object",
}
# Build the model
model = _build_pydantic_model_from_json_schema("deploy_tool", schema)
# Verify the model structure
assert model is not None
assert issubclass(model, BaseModel)
# Test with HelloWorld action
hello_world_data = {
"params": {
"projectName": "MyProject",
"organization": "MyOrg",
"actions": [
{
"name": "hello_world",
"params": {"name": "Alice"},
}
],
}
}
instance = model(**hello_world_data)
assert instance.params.projectName == "MyProject"
assert instance.params.organization == "MyOrg"
assert len(instance.params.actions) == 1
assert instance.params.actions[0].name == "hello_world"
assert instance.params.actions[0].params.name == "Alice"
# Test with CreateProject action
create_project_data = {
"params": {
"projectName": "MyProject",
"organization": "MyOrg",
"actions": [
{
"name": "create_project",
"params": {
"orgUrl": "https://dev.azure.com/myorg",
"projectName": "NewProject",
"sourceControl": "Git",
},
}
],
}
}
instance2 = model(**create_project_data)
assert instance2.params.actions[0].name == "create_project"
assert instance2.params.actions[0].params.projectName == "NewProject"
assert instance2.params.actions[0].params.sourceControl == "Git"
# Test with mixed actions
mixed_data = {
"params": {
"projectName": "MyProject",
"organization": "MyOrg",
"actions": [
{"name": "hello_world", "params": {"name": "Bob"}},
{
"name": "create_project",
"params": {
"orgUrl": "https://dev.azure.com/myorg",
"projectName": "AnotherProject",
},
},
],
}
}
instance3 = model(**mixed_data)
assert len(instance3.params.actions) == 2
assert instance3.params.actions[0].name == "hello_world"
assert instance3.params.actions[1].name == "create_project"
def test_const_creates_literal():
"""Test that const in JSON Schema creates Literal type."""
schema = {
"properties": {
"action": {
"const": "create",
"type": "string",
"description": "Action type",
},
"value": {"type": "integer"},
},
"required": ["action", "value"],
}
model = _build_pydantic_model_from_json_schema("test_const", schema)
# Verify valid const value works
instance = model(action="create", value=42)
assert instance.action == "create"
assert instance.value == 42
# Verify incorrect const value fails
with pytest.raises(ValidationError):
model(action="delete", value=42)
def test_enum_creates_literal():
"""Test that enum in JSON Schema creates Literal type."""
schema = {
"properties": {
"status": {
"enum": ["pending", "approved", "rejected"],
"type": "string",
"description": "Status",
},
"priority": {"enum": [1, 2, 3], "type": "integer"},
},
"required": ["status"],
}
model = _build_pydantic_model_from_json_schema("test_enum", schema)
# Verify valid enum values work
instance = model(status="approved", priority=2)
assert instance.status == "approved"
assert instance.priority == 2
# Verify invalid enum value fails
with pytest.raises(ValidationError):
model(status="unknown")
with pytest.raises(ValidationError):
model(status="pending", priority=5)
def test_nested_object_with_const_and_enum():
"""Test that const and enum work in nested objects."""
schema = {
"properties": {
"config": {
"type": "object",
"properties": {
"type": {
"const": "production",
"default": "production",
"type": "string",
},
"level": {"enum": ["low", "medium", "high"], "type": "string"},
},
"required": ["level"],
}
},
"required": ["config"],
}
model = _build_pydantic_model_from_json_schema("test_nested", schema)
# Valid data
instance = model(config={"type": "production", "level": "high"})
assert instance.config.type == "production"
assert instance.config.level == "high"
# Invalid const in nested object
with pytest.raises(ValidationError):
model(config={"type": "development", "level": "low"})
# Invalid enum in nested object
with pytest.raises(ValidationError):
model(config={"type": "production", "level": "critical"})
# endregion
+14 -6
View File
@@ -550,7 +550,6 @@ def test_usage_details():
assert usage["input_token_count"] == 5
assert usage["output_token_count"] == 10
assert usage["total_token_count"] == 15
assert usage.get("additional_counts", {}) == {}
def test_usage_details_addition():
@@ -581,8 +580,8 @@ def test_usage_details_addition():
def test_usage_details_fail():
# TypedDict doesn't validate types at runtime, so this test no longer applies
# Creating UsageDetails with wrong types won't raise ValueError
usage = UsageDetails(input_token_count=5, output_token_count=10, total_token_count=15, wrong_type="42.923") # type: ignore[typeddict-item]
assert usage["wrong_type"] == "42.923" # type: ignore[typeddict-item]
usage = UsageDetails(input_token_count=5, output_token_count=10, total_token_count=15, wrong_type="42.923")
assert usage["wrong_type"] == "42.923"
def test_usage_details_additional_counts():
@@ -601,6 +600,15 @@ def test_usage_details_add_with_none_and_type_errors():
# TypedDict doesn't support + operator, use add_usage_details
def test_usage_details_add_skips_non_int():
u1 = UsageDetails(input_token_count=10, other="test")
u2 = UsageDetails(input_token_count=10, another="test")
u3 = add_usage_details(u1, u2)
assert len(u3.keys()) == 1
assert "input_token_count" in u3
assert u3["input_token_count"] == 20
# region UserInputRequest and Response
@@ -1705,7 +1713,7 @@ def test_chat_response_complex_serialization():
{"role": "user", "contents": [{"type": "text", "text": "Hello"}]},
{"role": "assistant", "contents": [{"type": "text", "text": "Hi there"}]},
],
"finish_reason": {"value": "stop"},
"finish_reason": "stop",
"usage_details": {
"type": "usage_details",
"input_token_count": 5,
@@ -1831,7 +1839,7 @@ def test_agent_run_response_update_all_content_types():
},
{"type": "text_reasoning", "text": "reasoning"},
],
"role": {"value": "assistant"}, # Test role as dict
"role": "assistant", # Test role as dict
}
update = AgentResponseUpdate.from_dict(update_data)
@@ -2394,7 +2402,7 @@ def test_content_add_usage_content_non_integer_values():
result = usage1 + usage2
# Non-integer "model" should take first non-None value
assert result.usage_details["model"] == "gpt-4"
assert "model" not in result.usage_details
# Integer "count" should be summed
assert result.usage_details["count"] == 30
@@ -212,7 +212,8 @@ def test_azure_construction_with_existing_client() -> None:
assert client.client is mock_client
def test_azure_construction_missing_deployment_name_raises() -> None:
def test_azure_construction_missing_deployment_name_raises(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", raising=False)
with pytest.raises(ValueError, match="deployment name is required"):
AzureOpenAIEmbeddingClient(
api_key="test-key",
@@ -272,6 +273,7 @@ skip_if_azure_openai_integration_tests_disabled = pytest.mark.skipif(
@skip_if_openai_integration_tests_disabled
@pytest.mark.flaky
@pytest.mark.integration
async def test_integration_openai_get_embeddings() -> None:
"""End-to-end test of OpenAI embedding generation."""
client = OpenAIEmbeddingClient(model_id="text-embedding-3-small")
@@ -289,6 +291,7 @@ async def test_integration_openai_get_embeddings() -> None:
@skip_if_openai_integration_tests_disabled
@pytest.mark.flaky
@pytest.mark.integration
async def test_integration_openai_get_embeddings_multiple() -> None:
"""Test embedding generation for multiple inputs."""
client = OpenAIEmbeddingClient(model_id="text-embedding-3-small")
@@ -302,6 +305,7 @@ async def test_integration_openai_get_embeddings_multiple() -> None:
@skip_if_openai_integration_tests_disabled
@pytest.mark.flaky
@pytest.mark.integration
async def test_integration_openai_get_embeddings_with_dimensions() -> None:
"""Test embedding generation with custom dimensions."""
client = OpenAIEmbeddingClient(model_id="text-embedding-3-small")
@@ -315,6 +319,7 @@ async def test_integration_openai_get_embeddings_with_dimensions() -> None:
@skip_if_azure_openai_integration_tests_disabled
@pytest.mark.flaky
@pytest.mark.integration
async def test_integration_azure_openai_get_embeddings() -> None:
"""End-to-end test of Azure OpenAI embedding generation."""
client = AzureOpenAIEmbeddingClient()
@@ -332,6 +337,7 @@ async def test_integration_azure_openai_get_embeddings() -> None:
@skip_if_azure_openai_integration_tests_disabled
@pytest.mark.flaky
@pytest.mark.integration
async def test_integration_azure_openai_get_embeddings_multiple() -> None:
"""Test Azure OpenAI embedding generation for multiple inputs."""
client = AzureOpenAIEmbeddingClient()
@@ -345,6 +351,7 @@ async def test_integration_azure_openai_get_embeddings_multiple() -> None:
@skip_if_azure_openai_integration_tests_disabled
@pytest.mark.flaky
@pytest.mark.integration
async def test_integration_azure_openai_get_embeddings_with_dimensions() -> None:
"""Test Azure OpenAI embedding generation with custom dimensions."""
client = AzureOpenAIEmbeddingClient()
@@ -5,6 +5,7 @@ from collections.abc import AsyncIterable, Awaitable
from typing import TYPE_CHECKING, Any, Literal, overload
import pytest
from agent_framework import (
AgentExecutor,
AgentResponse,
@@ -59,30 +60,19 @@ class _CountingAgent(BaseAgent):
stream: bool = False,
session: AgentSession | None = None,
**kwargs: Any,
) -> (
Awaitable[AgentResponse[Any]]
| ResponseStream[AgentResponseUpdate, AgentResponse[Any]]
):
) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]:
self.call_count += 1
if stream:
async def _stream() -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(
contents=[
Content.from_text(
text=f"Response #{self.call_count}: {self.name}"
)
]
contents=[Content.from_text(text=f"Response #{self.call_count}: {self.name}")]
)
return ResponseStream(_stream(), finalizer=AgentResponse.from_updates)
async def _run() -> AgentResponse:
return AgentResponse(
messages=[
Message("assistant", [f"Response #{self.call_count}: {self.name}"])
]
)
return AgentResponse(messages=[Message("assistant", [f"Response #{self.call_count}: {self.name}"])])
return _run()
@@ -120,10 +110,7 @@ class _StreamingHookAgent(BaseAgent):
stream: bool = False,
session: AgentSession | None = None,
**kwargs: Any,
) -> (
Awaitable[AgentResponse[Any]]
| ResponseStream[AgentResponseUpdate, AgentResponse[Any]]
):
) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]:
if stream:
async def _stream() -> AsyncIterable[AgentResponseUpdate]:
@@ -138,9 +125,9 @@ class _StreamingHookAgent(BaseAgent):
self.result_hook_called = True
return response
return ResponseStream(
_stream(), finalizer=AgentResponse.from_updates
).with_result_hook(_mark_result_hook_called)
return ResponseStream(_stream(), finalizer=AgentResponse.from_updates).with_result_hook(
_mark_result_hook_called
)
async def _run() -> AgentResponse:
return AgentResponse(messages=[Message("assistant", ["hook test"])])
@@ -148,9 +135,7 @@ class _StreamingHookAgent(BaseAgent):
return _run()
async def test_agent_executor_streaming_finalizes_stream_and_runs_result_hooks() -> (
None
):
async def test_agent_executor_streaming_finalizes_stream_and_runs_result_hooks() -> None:
"""AgentExecutor should call get_final_response() so stream result hooks execute."""
agent = _StreamingHookAgent(id="hook_agent", name="HookAgent")
executor = AgentExecutor(agent, id="hook_exec")
@@ -217,9 +202,7 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None:
executor_state = executor_states[executor.id] # type: ignore[index]
assert "cache" in executor_state, "Checkpoint should store executor cache state"
assert "agent_session" in executor_state, (
"Checkpoint should store executor session state"
)
assert "agent_session" in executor_state, "Checkpoint should store executor session state"
# Verify session state structure
session_state = executor_state["agent_session"] # type: ignore[index]
@@ -240,15 +223,11 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None:
assert restored_agent.call_count == 0
# Build new workflow with the restored executor
wf_resume = SequentialBuilder(
participants=[restored_executor], checkpoint_storage=storage
).build()
wf_resume = SequentialBuilder(participants=[restored_executor], checkpoint_storage=storage).build()
# Resume from checkpoint
resumed_output: AgentExecutorResponse | None = None
async for ev in wf_resume.run(
checkpoint_id=restore_checkpoint.checkpoint_id, stream=True
):
async for ev in wf_resume.run(checkpoint_id=restore_checkpoint.checkpoint_id, stream=True):
if ev.type == "output":
resumed_output = ev.data # type: ignore[assignment]
if ev.type == "status" and ev.state in (
@@ -391,11 +370,7 @@ async def test_prepare_agent_run_args_strips_all_reserved_kwargs_at_once(
assert options is not None
assert options["additional_function_arguments"]["custom"] == 1
warned_keys = {
r.message.split("'")[1]
for r in caplog.records
if "reserved" in r.message.lower()
}
warned_keys = {r.message.split("'")[1] for r in caplog.records if "reserved" in r.message.lower()}
assert warned_keys == {"session", "stream", "messages"}
@@ -16,10 +16,31 @@ class MockAgent:
self.description: str | None = None
@overload
def run(self, messages: AgentRunInputs | None = ..., *, stream: Literal[False] = ..., session: AgentSession | None = ..., **kwargs: Any) -> Awaitable[AgentResponse[Any]]: ...
def run(
self,
messages: AgentRunInputs | None = ...,
*,
stream: Literal[False] = ...,
session: AgentSession | None = ...,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]]: ...
@overload
def run(self, messages: AgentRunInputs | None = ..., *, stream: Literal[True], session: AgentSession | None = ..., **kwargs: Any) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
def run(self, messages: AgentRunInputs | None = None, *, stream: bool = False, session: AgentSession | None = None, **kwargs: Any) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
def run(
self,
messages: AgentRunInputs | None = ...,
*,
stream: Literal[True],
session: AgentSession | None = ...,
**kwargs: Any,
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
def run(
self,
messages: AgentRunInputs | None = None,
*,
stream: bool = False,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
def create_session(self, **kwargs: Any) -> AgentSession:
"""Creates a new conversation session for the agent."""
@@ -4,9 +4,8 @@ from dataclasses import dataclass
from typing import Any
from unittest.mock import patch
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
import pytest
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from agent_framework import (
Executor,
@@ -3,6 +3,8 @@
from dataclasses import dataclass
import pytest
from typing_extensions import Never
from agent_framework import (
Executor,
Message,
@@ -14,7 +16,6 @@ from agent_framework import (
handler,
response_handler,
)
from typing_extensions import Never
# Module-level types for string forward reference tests
@@ -155,11 +156,7 @@ async def test_executor_invoked_event_contains_input_data():
workflow = WorkflowBuilder(start_executor=upper).add_edge(upper, collector).build()
events = await workflow.run("hello world")
invoked_events = [
e
for e in events
if isinstance(e, WorkflowEvent) and e.type == "executor_invoked"
]
invoked_events = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "executor_invoked"]
assert len(invoked_events) == 2
@@ -193,16 +190,10 @@ async def test_executor_completed_event_contains_sent_messages():
sender = MultiSenderExecutor(id="sender")
collector = CollectorExecutor(id="collector")
workflow = (
WorkflowBuilder(start_executor=sender).add_edge(sender, collector).build()
)
workflow = WorkflowBuilder(start_executor=sender).add_edge(sender, collector).build()
events = await workflow.run("hello")
completed_events = [
e
for e in events
if isinstance(e, WorkflowEvent) and e.type == "executor_completed"
]
completed_events = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "executor_completed"]
# Sender should have completed with the sent messages
sender_completed = next(e for e in completed_events if e.executor_id == "sender")
@@ -210,9 +201,7 @@ async def test_executor_completed_event_contains_sent_messages():
assert sender_completed.data == ["hello-first", "hello-second"]
# Collector should have completed with no sent messages (None)
collector_completed_events = [
e for e in completed_events if e.executor_id == "collector"
]
collector_completed_events = [e for e in completed_events if e.executor_id == "collector"]
# Collector is called twice (once per message from sender)
assert len(collector_completed_events) == 2
for collector_completed in collector_completed_events:
@@ -231,11 +220,7 @@ async def test_executor_completed_event_includes_yielded_outputs():
workflow = WorkflowBuilder(start_executor=executor).build()
events = await workflow.run("test")
completed_events = [
e
for e in events
if isinstance(e, WorkflowEvent) and e.type == "executor_completed"
]
completed_events = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "executor_completed"]
assert len(completed_events) == 1
assert completed_events[0].executor_id == "yielder"
@@ -263,9 +248,7 @@ async def test_executor_events_with_complex_message_types():
class ProcessorExecutor(Executor):
@handler
async def handle(
self, request: Request, ctx: WorkflowContext[Response]
) -> None:
async def handle(self, request: Request, ctx: WorkflowContext[Response]) -> None:
response = Response(results=[request.query.upper()] * request.limit)
await ctx.send_message(response)
@@ -277,23 +260,13 @@ async def test_executor_events_with_complex_message_types():
processor = ProcessorExecutor(id="processor")
collector = CollectorExecutor(id="collector")
workflow = (
WorkflowBuilder(start_executor=processor).add_edge(processor, collector).build()
)
workflow = WorkflowBuilder(start_executor=processor).add_edge(processor, collector).build()
input_request = Request(query="hello", limit=3)
events = await workflow.run(input_request)
invoked_events = [
e
for e in events
if isinstance(e, WorkflowEvent) and e.type == "executor_invoked"
]
completed_events = [
e
for e in events
if isinstance(e, WorkflowEvent) and e.type == "executor_completed"
]
invoked_events = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "executor_invoked"]
completed_events = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "executor_completed"]
# Check processor invoked event has the Request object
processor_invoked = next(e for e in invoked_events if e.executor_id == "processor")
@@ -302,9 +275,7 @@ async def test_executor_events_with_complex_message_types():
assert processor_invoked.data.limit == 3
# Check processor completed event has the Response object
processor_completed = next(
e for e in completed_events if e.executor_id == "processor"
)
processor_completed = next(e for e in completed_events if e.executor_id == "processor")
assert processor_completed.data is not None
assert len(processor_completed.data) == 1
assert isinstance(processor_completed.data[0], Response)
@@ -390,9 +361,7 @@ def test_executor_workflow_output_types_property():
# Test executor with union workflow output types
class UnionWorkflowOutputExecutor(Executor):
@handler
async def handle(
self, text: str, ctx: WorkflowContext[int, str | bool]
) -> None:
async def handle(self, text: str, ctx: WorkflowContext[int, str | bool]) -> None:
pass
executor = UnionWorkflowOutputExecutor(id="union_workflow_output")
@@ -403,15 +372,11 @@ def test_executor_workflow_output_types_property():
# Test executor with multiple handlers having different workflow output types
class MultiHandlerWorkflowExecutor(Executor):
@handler
async def handle_string(
self, text: str, ctx: WorkflowContext[int, str]
) -> None:
async def handle_string(self, text: str, ctx: WorkflowContext[int, str]) -> None:
pass
@handler
async def handle_number(
self, num: int, ctx: WorkflowContext[bool, float]
) -> None:
async def handle_number(self, num: int, ctx: WorkflowContext[bool, float]) -> None:
pass
executor = MultiHandlerWorkflowExecutor(id="multi_workflow")
@@ -465,9 +430,7 @@ def test_executor_output_types_includes_response_handlers():
pass
@response_handler
async def handle_response(
self, original_request: str, response: bool, ctx: WorkflowContext[float]
) -> None:
async def handle_response(self, original_request: str, response: bool, ctx: WorkflowContext[float]) -> None:
pass
executor = RequestResponseExecutor(id="request_response")
@@ -574,9 +537,7 @@ async def test_executor_invoked_event_data_not_mutated_by_handler():
"""Test that executor_invoked event (type='executor_invoked').data captures original input, not mutated input."""
@executor(id="Mutator")
async def mutator(
messages: list[Message], ctx: WorkflowContext[list[Message]]
) -> None:
async def mutator(messages: list[Message], ctx: WorkflowContext[list[Message]]) -> None:
# The handler mutates the input list by appending new messages
original_len = len(messages)
messages.append(Message(role="assistant", text="Added by executor"))
@@ -591,11 +552,7 @@ async def test_executor_invoked_event_data_not_mutated_by_handler():
events = await workflow.run(input_messages)
# Find the invoked event for the Mutator executor
invoked_events = [
e
for e in events
if isinstance(e, WorkflowEvent) and e.type == "executor_invoked"
]
invoked_events = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "executor_invoked"]
assert len(invoked_events) == 1
mutator_invoked = invoked_events[0]
@@ -672,12 +629,8 @@ class TestHandlerExplicitTypes:
assert handler_func._handler_spec["output_types"] == [list] # pyright: ignore[reportFunctionMemberAccess]
# Verify can_handle
assert exec_instance.can_handle(
WorkflowMessage(data={"key": "value"}, source_id="mock")
)
assert not exec_instance.can_handle(
WorkflowMessage(data="string", source_id="mock")
)
assert exec_instance.can_handle(WorkflowMessage(data={"key": "value"}, source_id="mock"))
assert not exec_instance.can_handle(WorkflowMessage(data="string", source_id="mock"))
def test_handler_with_explicit_union_input_type(self):
"""Test that explicit union input_type is handled correctly."""
@@ -698,9 +651,7 @@ class TestHandlerExplicitTypes:
assert exec_instance.can_handle(WorkflowMessage(data="hello", source_id="mock"))
assert exec_instance.can_handle(WorkflowMessage(data=42, source_id="mock"))
# Cannot handle float
assert not exec_instance.can_handle(
WorkflowMessage(data=3.14, source_id="mock")
)
assert not exec_instance.can_handle(WorkflowMessage(data=3.14, source_id="mock"))
def test_handler_with_explicit_union_output_type(self):
"""Test that explicit union output is normalized to a list."""
@@ -776,9 +727,7 @@ class TestHandlerExplicitTypes:
class OnlyWorkflowOutputExecutor(Executor): # pyright: ignore[reportUnusedClass]
@handler(workflow_output=bool)
async def handle(
self, message: str, ctx: WorkflowContext[int, str]
) -> None:
async def handle(self, message: str, ctx: WorkflowContext[int, str]) -> None:
pass
def test_handler_explicit_input_type_allows_no_message_annotation(self):
@@ -803,9 +752,7 @@ class TestHandlerExplicitTypes:
pass
@handler
async def handle_introspected(
self, message: float, ctx: WorkflowContext[bool]
) -> None:
async def handle_introspected(self, message: float, ctx: WorkflowContext[bool]) -> None:
pass
exec_instance = MixedExecutor(id="mixed")
@@ -831,9 +778,7 @@ class TestHandlerExplicitTypes:
# Should resolve the string to the actual type
assert ForwardRefMessage in exec_instance._handlers # pyright: ignore[reportPrivateUsage]
assert exec_instance.can_handle(
WorkflowMessage(data=ForwardRefMessage("hello"), source_id="mock")
)
assert exec_instance.can_handle(WorkflowMessage(data=ForwardRefMessage("hello"), source_id="mock"))
def test_handler_with_string_forward_reference_union(self):
"""Test that string forward references work with union types."""
@@ -846,12 +791,8 @@ class TestHandlerExplicitTypes:
exec_instance = StringUnionExecutor(id="string_union")
# Should handle both types
assert exec_instance.can_handle(
WorkflowMessage(data=ForwardRefTypeA("hello"), source_id="mock")
)
assert exec_instance.can_handle(
WorkflowMessage(data=ForwardRefTypeB(42), source_id="mock")
)
assert exec_instance.can_handle(WorkflowMessage(data=ForwardRefTypeA("hello"), source_id="mock"))
assert exec_instance.can_handle(WorkflowMessage(data=ForwardRefTypeB(42), source_id="mock"))
def test_handler_with_string_forward_reference_output_type(self):
"""Test that string forward references work for output_type."""
@@ -890,9 +831,7 @@ class TestHandlerExplicitTypes:
class PrecedenceExecutor(Executor):
@handler(input=int, output=float, workflow_output=str)
async def handle(
self, message: int, ctx: WorkflowContext[int, bool]
) -> None:
async def handle(self, message: int, ctx: WorkflowContext[int, bool]) -> None:
pass
exec_instance = PrecedenceExecutor(id="precedence")
@@ -958,9 +897,7 @@ class TestHandlerExplicitTypes:
async def handle(self, message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
pass
exec_instance = StringUnionWorkflowOutputExecutor(
id="string_union_workflow_output"
)
exec_instance = StringUnionWorkflowOutputExecutor(id="string_union_workflow_output")
# Should resolve both types from string union
assert ForwardRefTypeA in exec_instance.workflow_output_types
@@ -971,14 +908,10 @@ class TestHandlerExplicitTypes:
class IntrospectedWorkflowOutputExecutor(Executor):
@handler
async def handle(
self, message: str, ctx: WorkflowContext[int, bool]
) -> None:
async def handle(self, message: str, ctx: WorkflowContext[int, bool]) -> None:
pass
exec_instance = IntrospectedWorkflowOutputExecutor(
id="introspected_workflow_output"
)
exec_instance = IntrospectedWorkflowOutputExecutor(id="introspected_workflow_output")
# Should use introspected types from WorkflowContext[int, bool]
assert int in exec_instance.output_types
@@ -717,9 +717,23 @@ class TestWorkflowAgent:
return AgentSession()
@overload
def run(self, messages: str | Content | Message | Sequence[str | Content | Message] | None = ..., *, stream: Literal[False] = ..., session: AgentSession | None = ..., **kwargs: Any) -> Awaitable[AgentResponse[Any]]: ...
def run(
self,
messages: str | Content | Message | Sequence[str | Content | Message] | None = ...,
*,
stream: Literal[False] = ...,
session: AgentSession | None = ...,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]]: ...
@overload
def run(self, messages: str | Content | Message | Sequence[str | Content | Message] | None = ..., *, stream: Literal[True], session: AgentSession | None = ..., **kwargs: Any) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
def run(
self,
messages: str | Content | Message | Sequence[str | Content | Message] | None = ...,
*,
stream: Literal[True],
session: AgentSession | None = ...,
**kwargs: Any,
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
def run(
self,
@@ -813,9 +827,23 @@ class TestWorkflowAgent:
return AgentSession()
@overload
def run(self, messages: str | Content | Message | Sequence[str | Content | Message] | None = ..., *, stream: Literal[False] = ..., session: AgentSession | None = ..., **kwargs: Any) -> Awaitable[AgentResponse[Any]]: ...
def run(
self,
messages: str | Content | Message | Sequence[str | Content | Message] | None = ...,
*,
stream: Literal[False] = ...,
session: AgentSession | None = ...,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]]: ...
@overload
def run(self, messages: str | Content | Message | Sequence[str | Content | Message] | None = ..., *, stream: Literal[True], session: AgentSession | None = ..., **kwargs: Any) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
def run(
self,
messages: str | Content | Message | Sequence[str | Content | Message] | None = ...,
*,
stream: Literal[True],
session: AgentSession | None = ...,
**kwargs: Any,
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
def run(
self,
@@ -52,9 +52,23 @@ class _KwargsCapturingAgent(BaseAgent):
self.captured_kwargs = []
@overload
def run(self, messages: AgentRunInputs | None = ..., *, stream: Literal[False] = ..., session: AgentSession | None = ..., **kwargs: Any) -> Awaitable[AgentResponse[Any]]: ...
def run(
self,
messages: AgentRunInputs | None = ...,
*,
stream: Literal[False] = ...,
session: AgentSession | None = ...,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]]: ...
@overload
def run(self, messages: AgentRunInputs | None = ..., *, stream: Literal[True], session: AgentSession | None = ..., **kwargs: Any) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
def run(
self,
messages: AgentRunInputs | None = ...,
*,
stream: Literal[True],
session: AgentSession | None = ...,
**kwargs: Any,
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
def run(
self,
@@ -90,9 +104,23 @@ class _OptionsAwareAgent(BaseAgent):
self.captured_kwargs = []
@overload
def run(self, messages: AgentRunInputs | None = ..., *, stream: Literal[False] = ..., session: AgentSession | None = ..., **kwargs: Any) -> Awaitable[AgentResponse[Any]]: ...
def run(
self,
messages: AgentRunInputs | None = ...,
*,
stream: Literal[False] = ...,
session: AgentSession | None = ...,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]]: ...
@overload
def run(self, messages: AgentRunInputs | None = ..., *, stream: Literal[True], session: AgentSession | None = ..., **kwargs: Any) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
def run(
self,
messages: AgentRunInputs | None = ...,
*,
stream: Literal[True],
session: AgentSession | None = ...,
**kwargs: Any,
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
def run(
self,
@@ -475,9 +503,23 @@ async def test_kwargs_preserved_on_response_continuation() -> None:
self._asked = False
@overload
def run(self, messages: AgentRunInputs | None = ..., *, stream: Literal[False] = ..., session: AgentSession | None = ..., **kwargs: Any) -> Awaitable[AgentResponse[Any]]: ...
def run(
self,
messages: AgentRunInputs | None = ...,
*,
stream: Literal[False] = ...,
session: AgentSession | None = ...,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]]: ...
@overload
def run(self, messages: AgentRunInputs | None = ..., *, stream: Literal[True], session: AgentSession | None = ..., **kwargs: Any) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
def run(
self,
messages: AgentRunInputs | None = ...,
*,
stream: Literal[True],
session: AgentSession | None = ...,
**kwargs: Any,
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
def run(
self,
@@ -538,9 +580,23 @@ async def test_kwargs_overridden_on_response_continuation() -> None:
self._asked = False
@overload
def run(self, messages: AgentRunInputs | None = ..., *, stream: Literal[False] = ..., session: AgentSession | None = ..., **kwargs: Any) -> Awaitable[AgentResponse[Any]]: ...
def run(
self,
messages: AgentRunInputs | None = ...,
*,
stream: Literal[False] = ...,
session: AgentSession | None = ...,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]]: ...
@overload
def run(self, messages: AgentRunInputs | None = ..., *, stream: Literal[True], session: AgentSession | None = ..., **kwargs: Any) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
def run(
self,
messages: AgentRunInputs | None = ...,
*,
stream: Literal[True],
session: AgentSession | None = ...,
**kwargs: Any,
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
def run(
self,
@@ -605,9 +661,23 @@ async def test_kwargs_empty_value_passed_on_continuation() -> None:
self._asked = False
@overload
def run(self, messages: AgentRunInputs | None = ..., *, stream: Literal[False] = ..., session: AgentSession | None = ..., **kwargs: Any) -> Awaitable[AgentResponse[Any]]: ...
def run(
self,
messages: AgentRunInputs | None = ...,
*,
stream: Literal[False] = ...,
session: AgentSession | None = ...,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]]: ...
@overload
def run(self, messages: AgentRunInputs | None = ..., *, stream: Literal[True], session: AgentSession | None = ..., **kwargs: Any) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
def run(
self,
messages: AgentRunInputs | None = ...,
*,
stream: Literal[True],
session: AgentSession | None = ...,
**kwargs: Any,
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
def run(
self,
@@ -38,7 +38,9 @@ async def test_executor_failed_and_workflow_failed_events_streaming():
events.append(ev)
# executor_failed event (type='executor_failed') should be emitted before workflow failed event
executor_failed_events: list[WorkflowEvent[Any]] = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "executor_failed"]
executor_failed_events: list[WorkflowEvent[Any]] = [
e for e in events if isinstance(e, WorkflowEvent) and e.type == "executor_failed"
]
assert executor_failed_events, "executor_failed event should be emitted when start executor fails"
assert executor_failed_events[0].executor_id == "f"
assert executor_failed_events[0].origin is WorkflowEventSource.FRAMEWORK
@@ -96,7 +98,9 @@ async def test_executor_failed_event_from_second_executor_in_chain():
events.append(ev)
# executor_failed event should be emitted for the failing executor
executor_failed_events: list[WorkflowEvent[Any]] = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "executor_failed"]
executor_failed_events: list[WorkflowEvent[Any]] = [
e for e in events if isinstance(e, WorkflowEvent) and e.type == "executor_failed"
]
assert executor_failed_events, "executor_failed event should be emitted when second executor fails"
assert executor_failed_events[0].executor_id == "failing"
assert executor_failed_events[0].origin is WorkflowEventSource.FRAMEWORK
@@ -15,7 +15,6 @@ from agent_framework import (
from agent_framework import (
FunctionTool as AFFunctionTool,
)
from agent_framework._tools import _create_model_from_json_schema # type: ignore
from agent_framework.exceptions import AgentException
from dotenv import load_dotenv
@@ -34,7 +33,7 @@ from ._models import (
RemoteConnection,
Tool,
WebSearchTool,
_safe_mode_context,
_safe_mode_context, # type: ignore[reportPrivateUsage]
agent_schema_dispatch,
)
@@ -445,7 +444,7 @@ class AgentFactory:
if tools := self._parse_tools(prompt_agent.tools):
chat_options["tools"] = tools
if output_schema := prompt_agent.outputSchema:
chat_options["response_format"] = _create_model_from_json_schema("agent", output_schema.to_json_schema())
chat_options["response_format"] = output_schema.to_json_schema()
# Step 3: Create the agent instance
return Agent(
client=client,
@@ -563,7 +562,7 @@ class AgentFactory:
if tools := self._parse_tools(prompt_agent.tools):
chat_options["tools"] = tools
if output_schema := prompt_agent.outputSchema:
chat_options["response_format"] = _create_model_from_json_schema("agent", output_schema.to_json_schema())
chat_options["response_format"] = output_schema.to_json_schema()
return Agent(
client=client,
name=prompt_agent.name,
@@ -598,6 +597,9 @@ class AgentFactory:
case ApiKeyConnection():
if prompt_agent.model.connection.endpoint:
provider_kwargs["project_endpoint"] = prompt_agent.model.connection.endpoint
case ReferenceConnection():
# Reference connections are resolved by concrete providers when supported.
pass
# Create the provider and use it to create the agent
provider = provider_class(**provider_kwargs)
@@ -608,8 +610,7 @@ class AgentFactory:
# Parse response format into default_options
default_options: dict[str, Any] | None = None
if prompt_agent.outputSchema:
response_format = _create_model_from_json_schema("agent", prompt_agent.outputSchema.to_json_schema())
default_options = {"response_format": response_format}
default_options = {"response_format": prompt_agent.outputSchema.to_json_schema()}
# Create the agent using the provider
# The provider's create_agent returns a Agent directly
@@ -25,6 +25,7 @@ See: dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/
from __future__ import annotations
import locale
import logging
import sys
import uuid
@@ -103,6 +104,8 @@ DECLARATIVE_STATE_KEY = "_declarative_workflow_state"
# Types that PowerFx can serialize directly
# Note: Decimal is included because PowerFx returns Decimal for numeric values
_POWERFX_SAFE_TYPES = (str, int, float, bool, type(None), _Decimal)
_POWERFX_EVAL_LOCALE = "en-US"
_POWERFX_NUMERIC_LOCALE_CANDIDATES = ("en_US.UTF-8", "en_US", "C")
def _make_powerfx_safe(value: Any) -> Any:
@@ -121,10 +124,12 @@ def _make_powerfx_safe(value: Any) -> Any:
return value
if isinstance(value, dict):
return {k: _make_powerfx_safe(v) for k, v in value.items()}
value_dict = cast(Mapping[Any, Any], value)
return {str(k): _make_powerfx_safe(v) for k, v in value_dict.items()}
if isinstance(value, list):
return [_make_powerfx_safe(item) for item in value]
value_list = cast(list[Any], value) # type: ignore[redundant-cast]
return [_make_powerfx_safe(item) for item in value_list]
# Try to convert objects with __dict__ or dataclass-style attributes
if hasattr(value, "__dict__"):
@@ -382,21 +387,33 @@ class DeclarativeWorkflowState:
f"Install dotnet and the powerfx package for full PowerFx support."
)
engine = Engine()
symbols = self._to_powerfx_symbols()
# Use setlocale(category) query form so we can restore the exact prior value.
# getlocale() returns a normalized tuple and is not always a lossless
# round-trip for setlocale across platforms/locales.
original_numeric_locale = locale.setlocale(locale.LC_NUMERIC)
try:
from System.Globalization import CultureInfo
for locale_candidate in _POWERFX_NUMERIC_LOCALE_CANDIDATES:
try:
locale.setlocale(locale.LC_NUMERIC, locale_candidate)
break
except locale.Error:
continue
original_culture = CultureInfo.CurrentCulture
original_ui_culture = CultureInfo.CurrentUICulture
en_us_culture = CultureInfo("en-US")
CultureInfo.CurrentCulture = en_us_culture
CultureInfo.CurrentUICulture = en_us_culture
engine = Engine()
try:
return engine.eval(formula, symbols=symbols)
from System.Globalization import ( # pyright: ignore[reportMissingImports]
CultureInfo, # pyright: ignore[reportUnknownVariableType]
)
except ImportError:
return engine.eval(formula, symbols=symbols, locale=_POWERFX_EVAL_LOCALE)
original_culture = cast(Any, CultureInfo.CurrentCulture) # pyright: ignore[reportUnknownMemberType]
try:
CultureInfo.CurrentCulture = CultureInfo(_POWERFX_EVAL_LOCALE) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
return engine.eval(formula, symbols=symbols, locale=_POWERFX_EVAL_LOCALE)
finally:
CultureInfo.CurrentCulture = original_culture
CultureInfo.CurrentUICulture = original_ui_culture
CultureInfo.CurrentCulture = original_culture # pyright: ignore[reportUnknownMemberType]
except ValueError as e:
error_msg = str(e)
# Handle undefined variable errors gracefully by returning None
@@ -405,6 +422,8 @@ class DeclarativeWorkflowState:
logger.debug(f"PowerFx: undefined variable in expression '{formula}', returning None")
return None
raise
finally:
locale.setlocale(locale.LC_NUMERIC, original_numeric_locale)
def _eval_custom_function(self, formula: str) -> Any | None:
"""Handle custom functions not supported by the Python PowerFx library.
@@ -424,7 +443,7 @@ class DeclarativeWorkflowState:
args_str = match.group(1)
# Parse comma-separated arguments (handling nested parentheses)
args = self._parse_function_args(args_str)
evaluated_args = []
evaluated_args: list[str] = []
for arg in args:
arg = arg.strip()
if arg.startswith('"') and arg.endswith('"'):
@@ -576,37 +595,44 @@ class DeclarativeWorkflowState:
"""
messages: Any = self.eval(f"={inner_expr}")
if isinstance(messages, list) and messages:
last_msg: Any = messages[-1]
message_list = cast(list[Any], messages) # type: ignore[redundant-cast]
last_msg: Any = message_list[-1]
if isinstance(last_msg, dict):
last_msg_dict = cast(dict[str, Any], last_msg)
# Try "text" key first (simple dict format)
if "text" in last_msg:
return str(last_msg["text"])
if "text" in last_msg_dict:
return str(last_msg_dict["text"])
# Try extracting from "contents" (Message dict format)
# Message.text concatenates text from all TextContent items
contents = last_msg.get("contents", [])
if isinstance(contents, list):
text_parts = []
contents_obj = last_msg_dict.get("contents", [])
if isinstance(contents_obj, list):
contents = cast(list[Any], contents_obj) # type: ignore[redundant-cast]
text_parts: list[str] = []
for content in contents:
if isinstance(content, dict):
content_dict = cast(dict[str, Any], content)
# TextContent has a "text" key
if content.get("type") == "text" or "text" in content:
text_parts.append(str(content.get("text", "")))
elif hasattr(content, "text"):
text_parts.append(str(getattr(content, "text", "")))
if content_dict.get("type") == "text" or "text" in content_dict:
text_parts.append(str(content_dict.get("text", "")))
else:
content_obj: object = content
if hasattr(content_obj, "text"):
text_parts.append(str(getattr(content_obj, "text", "")))
if text_parts:
return " ".join(text_parts)
return ""
if hasattr(last_msg, "text"):
return str(getattr(last_msg, "text", ""))
last_msg_obj: object = last_msg
if hasattr(last_msg_obj, "text"):
return str(getattr(last_msg_obj, "text", ""))
return ""
def _parse_function_args(self, args_str: str) -> list[str]:
"""Parse comma-separated function arguments, handling nested parentheses and strings."""
args = []
current = []
args: list[str] = []
current: list[str] = []
depth = 0
in_string = False
string_char = None
string_char: str | None = None
for char in args_str:
if char in ('"', "'") and not in_string:
@@ -14,7 +14,7 @@ action definitions and creates a proper workflow graph with:
from __future__ import annotations
import logging
from typing import Any
from typing import Any, cast
from agent_framework import (
Workflow,
@@ -983,8 +983,9 @@ class DeclarativeWorkflowBuilder:
last_executor = chain[-1]
# Skip terminators — they handle their own control flow
action_def = getattr(last_executor, "_action_def", {})
if isinstance(action_def, dict) and action_def.get("kind", "") in TERMINATOR_ACTIONS:
action_def_obj = getattr(last_executor, "_action_def", {})
action_def = cast(dict[str, Any], action_def_obj) if isinstance(action_def_obj, dict) else {}
if action_def.get("kind", "") in TERMINATOR_ACTIONS:
return None
# Check if last executor is a structure with branch_exits
@@ -188,9 +188,9 @@ def _validate_conversation_history(messages: list[Message], agent_name: str) ->
tool_result_ids: set[str] = set()
for i, msg in enumerate(messages):
if not hasattr(msg, "contents") or msg.contents is None:
if not (contents := getattr(msg, "contents", None)):
continue
for content in msg.contents:
for content in contents:
if content.type == "function_call" and content.call_id:
tool_call_ids.add(content.call_id)
logger.debug(
@@ -7,7 +7,8 @@ Each action becomes a node in the workflow graph.
"""
import uuid
from typing import Any
from collections.abc import Mapping
from typing import Any, cast
from agent_framework import (
WorkflowContext,
@@ -28,9 +29,12 @@ def _get_variable_path(action_def: dict[str, Any], key: str = "variable") -> str
variable = action_def.get(key)
if isinstance(variable, str):
return variable
if isinstance(variable, dict):
return variable.get("path")
return action_def.get("path")
if isinstance(variable, Mapping):
path = variable.get("path") # type: ignore[reportUnknownVariableType]
return path if isinstance(path, str) else None
fallback_path = action_def.get("path")
return fallback_path if isinstance(fallback_path, str) else None
class SetValueExecutor(DeclarativeActionExecutor):
@@ -150,16 +154,23 @@ class SetMultipleVariablesExecutor(DeclarativeActionExecutor):
"""Handle the SetMultipleVariables action."""
state = await self._ensure_state_initialized(ctx, trigger)
assignments = self._action_def.get("assignments", [])
assignments = cast(
list[Mapping[str, Any]],
self._action_def.get("assignments") if isinstance(self._action_def.get("assignments"), list) else [],
)
for assignment in assignments:
if not isinstance(assignment, Mapping):
continue
variable = assignment.get("variable")
path: str | None
if isinstance(variable, str):
path = variable
elif isinstance(variable, dict):
path = variable.get("path")
elif isinstance(variable, Mapping):
path_value = variable.get("path") # type: ignore[reportUnknownMemberType]
path = path_value if isinstance(path_value, str) else None
else:
path = assignment.get("path")
fallback_path = assignment.get("path")
path = fallback_path if isinstance(fallback_path, str) else None
value = assignment.get("value")
if path:
evaluated_value = state.eval_if_expression(value)
@@ -249,7 +260,10 @@ class SendActivityExecutor(DeclarativeActionExecutor):
activity = self._action_def.get("activity", "")
# Activity can be a string directly or a dict with a "text" field
text = activity.get("text", "") if isinstance(activity, dict) else activity
if isinstance(activity, Mapping):
text: Any = activity.get("text", "") # type: ignore[reportUnknownMemberType]
else:
text = activity
if isinstance(text, str):
# First evaluate any =expression syntax
@@ -260,7 +274,7 @@ class SendActivityExecutor(DeclarativeActionExecutor):
# Yield the text as workflow output
if text:
await ctx.yield_output(str(text))
await ctx.yield_output(str(text)) # type: ignore[reportUnknownArgumentType]
await ctx.send_message(ActionComplete())
@@ -336,11 +350,14 @@ class EditTableExecutor(DeclarativeActionExecutor):
if table_path:
# Get current table value
current_table = state.get(table_path)
if current_table is None:
current_table_value = state.get(table_path)
current_table: list[Any]
if current_table_value is None:
current_table = []
elif not isinstance(current_table, list):
current_table = [current_table]
elif isinstance(current_table_value, list):
current_table = list(current_table_value) # type: ignore[reportUnknownArgumentType]
else:
current_table = [current_table_value]
if operation == "add" or operation == "insert":
evaluated_value = state.eval_if_expression(value)
@@ -413,11 +430,14 @@ class EditTableV2Executor(DeclarativeActionExecutor):
if table_path:
# Get current table value
current_table = state.get(table_path)
if current_table is None:
current_table_value = state.get(table_path)
current_table: list[Any]
if current_table_value is None:
current_table = []
elif not isinstance(current_table, list):
current_table = [current_table]
elif isinstance(current_table_value, list):
current_table = list(current_table_value) # type: ignore[reportUnknownArgumentType]
else:
current_table = [current_table_value]
if operation == "add":
evaluated_item = state.eval_if_expression(item)
@@ -433,9 +453,12 @@ class EditTableV2Executor(DeclarativeActionExecutor):
evaluated_item = state.eval_if_expression(item)
if key_field and isinstance(evaluated_item, dict):
# Remove by key match
key_value = evaluated_item.get(key_field)
evaluated_item_dict = cast(dict[str, Any], evaluated_item)
key_value = evaluated_item_dict.get(key_field)
current_table = [
r for r in current_table if not (isinstance(r, dict) and r.get(key_field) == key_value)
r
for r in current_table
if not (isinstance(r, dict) and cast(dict[str, Any], r).get(key_field) == key_value)
]
elif evaluated_item in current_table:
current_table.remove(evaluated_item)
@@ -451,11 +474,11 @@ class EditTableV2Executor(DeclarativeActionExecutor):
elif operation == "addorupdate":
evaluated_item = state.eval_if_expression(item)
if key_field and isinstance(evaluated_item, dict):
key_value = evaluated_item.get(key_field)
key_value = evaluated_item.get(key_field) # type: ignore[reportUnknownArgumentType]
# Find existing item with same key
found_idx = -1
for i, r in enumerate(current_table):
if isinstance(r, dict) and r.get(key_field) == key_value:
if isinstance(r, dict) and cast(dict[str, Any], r).get(key_field) == key_value:
found_idx = i
break
if found_idx >= 0:
@@ -476,9 +499,9 @@ class EditTableV2Executor(DeclarativeActionExecutor):
if 0 <= idx < len(current_table):
current_table[idx] = evaluated_item
elif key_field and isinstance(evaluated_item, dict):
key_value = evaluated_item.get(key_field)
key_value = evaluated_item.get(key_field) # type: ignore[reportUnknownArgumentType]
for i, r in enumerate(current_table):
if isinstance(r, dict) and r.get(key_field) == key_value:
if isinstance(r, dict) and cast(dict[str, Any], r).get(key_field) == key_value:
current_table[i] = evaluated_item
break
@@ -568,11 +591,13 @@ class ParseValueExecutor(DeclarativeActionExecutor):
if value is None:
return {}
if isinstance(value, dict):
return value
return cast(dict[str, Any], value)
if isinstance(value, str):
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {"value": parsed}
if isinstance(parsed, dict):
return cast(dict[str, Any], parsed)
return {"value": parsed}
except json.JSONDecodeError:
return {"value": value}
return {"value": value}
@@ -581,11 +606,13 @@ class ParseValueExecutor(DeclarativeActionExecutor):
if value is None:
return []
if isinstance(value, list):
return value
return cast(list[Any], value) # type: ignore[redundant-cast]
if isinstance(value, str):
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, list) else [parsed]
if isinstance(parsed, list):
return cast(list[Any], parsed) # type: ignore[redundant-cast]
return [parsed]
except json.JSONDecodeError:
return [value]
return [value]
@@ -15,9 +15,11 @@ import json
import logging
import uuid
from abc import abstractmethod
from collections.abc import Mapping
from dataclasses import dataclass, field
from inspect import isawaitable
from typing import Any
from typing import Any, cast
from collections.abc import Callable
from agent_framework import (
Content,
@@ -127,7 +129,7 @@ class ToolInvocationResult:
success: bool
result: Any = None
error: str | None = None
messages: list[Message] = field(default_factory=list)
messages: list[Message] = field(default_factory=cast(Callable[..., list[Message]], list))
rejected: bool = False
rejection_reason: str | None = None
@@ -267,15 +269,14 @@ class BaseToolExecutor(DeclarativeActionExecutor):
Returns:
Tuple of (messages_var, result_var, auto_send)
"""
output_config = self._action_def.get("output", {})
output_config: dict[str, str | bool] = self._action_def.get("output", {})
if not isinstance(output_config, dict):
if not isinstance(output_config, Mapping):
return None, None, True
messages_var = output_config.get("messages")
result_var = output_config.get("result")
auto_send = bool(output_config.get("autoSend", True))
return (
str(messages_var) if messages_var else None,
str(result_var) if result_var else None,
@@ -494,7 +495,7 @@ class BaseToolExecutor(DeclarativeActionExecutor):
type(arguments_def).__name__,
)
elif isinstance(arguments_def, dict):
for key, value in arguments_def.items():
for key, value in arguments_def.items(): # type: ignore[reportUnknownVariableType]
arguments[key] = state.eval_if_expression(value)
# Check if approval is required
@@ -44,14 +44,16 @@ def message_text(messages: Any) -> str:
content: Any = messages_dict.get("content", "")
if isinstance(content, str):
return content
if hasattr(content, "text"):
return str(content.text)
text_attr = getattr(content, "text", None)
if text_attr is not None:
return str(text_attr)
return str(content) if content else ""
if isinstance(messages, list):
# List of messages - concatenate all text
texts: list[str] = []
for msg in messages:
message_list = cast(list[Any], messages) # type: ignore[redundant-cast]
for msg in message_list:
if isinstance(msg, str):
texts.append(msg)
elif isinstance(msg, dict):
@@ -61,14 +63,16 @@ def message_text(messages: Any) -> str:
texts.append(msg_content)
elif msg_content:
texts.append(str(msg_content))
elif hasattr(msg, "content"):
msg_obj_content: Any = msg.content
if isinstance(msg_obj_content, str):
texts.append(msg_obj_content)
elif hasattr(msg_obj_content, "text"):
texts.append(str(msg_obj_content.text))
elif msg_obj_content:
texts.append(str(msg_obj_content))
else:
msg_obj: object = msg
if hasattr(msg_obj, "content"):
msg_obj_content: Any = getattr(msg_obj, "content", None)
if isinstance(msg_obj_content, str):
texts.append(msg_obj_content)
elif (msg_obj_text := getattr(msg_obj_content, "text", None)) is not None:
texts.append(str(msg_obj_text))
elif msg_obj_content:
texts.append(str(msg_obj_content))
return " ".join(texts)
# Try to get text attribute
@@ -191,10 +195,8 @@ def is_blank(value: Any) -> bool:
return True
if isinstance(value, str) and not value.strip():
return True
if isinstance(value, list):
return len(value) == 0
if isinstance(value, dict):
return len(value) == 0
if isinstance(value, (list, dict)):
return len(value) == 0 # type: ignore[reportUnknownArgumentType]
return False
@@ -284,8 +284,9 @@ class WorkflowState:
if existing is None:
self.set(path, [value])
elif isinstance(existing, list):
existing.append(value)
self.set(path, existing)
existing_list = cast(list[Any], existing) # type: ignore[redundant-cast]
existing_list.append(value)
self.set(path, existing_list)
else:
raise ValueError(f"Cannot append to non-list at path '{path}'")
@@ -614,9 +615,9 @@ class WorkflowState:
if isinstance(value, str):
return self.eval(value)
if isinstance(value, dict):
return {str(k): self.eval_if_expression(v) for k, v in value.items()}
return {str(k): self.eval_if_expression(v) for k, v in value.items()} # type: ignore[reportUnknownVariableType]
if isinstance(value, list):
return [self.eval_if_expression(item) for item in value]
return [self.eval_if_expression(item) for item in value] # type: ignore[reportUnknownVariableType]
return value
def reset_local(self) -> None:
+1 -1
View File
@@ -94,7 +94,7 @@ include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_declarative"
test = "pytest --cov=agent_framework_declarative --cov-report=term-missing:skip-covered tests"
test = "pytest -m \"not integration\" --cov=agent_framework_declarative --cov-report=term-missing:skip-covered tests"
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
@@ -560,8 +560,6 @@ instructions: You are a helpful assistant.
"""Test that outputSchema is passed as response_format in Agent.default_options."""
from unittest.mock import MagicMock
from pydantic import BaseModel
from agent_framework_declarative import AgentFactory
agent_def = {
@@ -580,8 +578,10 @@ instructions: You are a helpful assistant.
agent = factory.create_agent_from_dict(agent_def)
assert "response_format" in agent.default_options
assert isinstance(agent.default_options["response_format"], type)
assert issubclass(agent.default_options["response_format"], BaseModel)
response_format = agent.default_options["response_format"]
assert isinstance(response_format, dict)
assert response_format["type"] == "object"
assert response_format["properties"]["answer"]["type"] == "string"
def test_create_agent_from_dict_chat_options_in_default_options(self):
"""Test that chat options (temperature, top_p) are in Agent.default_options."""
@@ -16,6 +16,7 @@ Coverage includes:
- String interpolation: {Variable.Path}
"""
import locale
from unittest.mock import MagicMock
import pytest
@@ -494,29 +495,38 @@ class TestPowerFxUndefinedVariables:
assert result is None
async def test_undefined_variable_returns_none_with_non_english_ui_culture(self, mock_state):
"""Test that undefined variables return None even when CurrentUICulture is non-English.
"""Test that undefined variables return None even when locale is non-English.
Regression test for #4321: on non-English systems, CurrentUICulture causes
Regression test for #4321: on non-English systems, locale settings can cause
PowerFx to emit localized error messages that don't match the English
string guards ("isn't recognized", "Name isn't valid"), crashing the workflow.
The fix sets CurrentUICulture to en-US alongside CurrentCulture before eval.
The fix evaluates with locale='en-US' and restores the ambient LC_NUMERIC.
"""
from System.Globalization import CultureInfo
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# Simulate a non-English UI culture (e.g. Italian)
original_ui_culture = CultureInfo.CurrentUICulture
CultureInfo.CurrentUICulture = CultureInfo("it-IT")
# Simulate a non-English locale (e.g. Italian)
original_numeric_locale = locale.setlocale(locale.LC_NUMERIC)
test_numeric_locale: str | None = None
try:
for locale_candidate in ("it_IT.UTF-8", "it_IT", "fr_FR.UTF-8", "fr_FR", "de_DE.UTF-8", "de_DE"):
try:
locale.setlocale(locale.LC_NUMERIC, locale_candidate)
test_numeric_locale = locale.setlocale(locale.LC_NUMERIC)
break
except locale.Error:
continue
if test_numeric_locale is None:
pytest.skip("No non-English LC_NUMERIC locale available on this system")
# Should return None, not raise ValueError with Italian error text
result = state.eval("=Local.StatusConversationId")
assert result is None
# Verify the production code restored CurrentUICulture after eval
assert str(CultureInfo.CurrentUICulture) == str(CultureInfo("it-IT"))
# Verify the production code restored LC_NUMERIC after eval
assert locale.setlocale(locale.LC_NUMERIC) == test_numeric_locale
finally:
CultureInfo.CurrentUICulture = original_ui_culture
locale.setlocale(locale.LC_NUMERIC, original_numeric_locale)
class TestStringInterpolation:
@@ -73,7 +73,7 @@ def register_cleanup(entity: Any, *hooks: Callable[[], Any]) -> None:
)
def _get_registered_cleanup_hooks(entity: Any) -> list[Callable[[], Any]]:
def _get_registered_cleanup_hooks(entity: Any) -> list[Callable[[], Any]]: # type: ignore[reportUnusedFunction]
"""Get cleanup hooks registered for an entity (internal use).
Args:
@@ -193,7 +193,7 @@ def serve(
if entities:
logger.info(f"Registering {len(entities)} in-memory entities")
# Store entities for later registration during server startup
server._pending_entities = entities
server.set_pending_entities(entities)
app = server.get_app()
@@ -11,12 +11,14 @@ from __future__ import annotations
import time
import uuid
from abc import ABC, abstractmethod
from collections.abc import MutableSequence
from typing import Any, Literal, cast
from agent_framework import AgentSession, Message
from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage, WorkflowCheckpoint
from openai.types.conversations import Conversation, ConversationDeletedResource
from openai.types.conversations.conversation_item import ConversationItem
from openai.types.conversations.message import Content as OpenAIContent
from openai.types.conversations.message import Message as OpenAIMessage
from openai.types.conversations.text_content import TextContent
from openai.types.responses import (
@@ -300,12 +302,17 @@ class InMemoryConversationStore(ConversationStore):
stored_messages: list[Message] = conv_data["messages"]
# Convert items to Messages and add to storage
chat_messages = []
chat_messages: list[Message] = []
for item in items:
# Simple conversion - assume text content for now
role = item.get("role", "user")
content = item.get("content", [])
text = content[0].get("text", "") if content else ""
first_content = cast(
dict[str, Any],
content[0] if content and isinstance(content, list) and isinstance(content[0], dict) else {},
)
text_obj = first_content.get("text", "")
text = text_obj if isinstance(text_obj, str) else str(text_obj)
chat_msg = Message(role=role, text=text) # type: ignore[arg-type]
chat_messages.append(chat_msg)
@@ -318,23 +325,18 @@ class InMemoryConversationStore(ConversationStore):
for msg in chat_messages:
item_id = f"item_{uuid.uuid4().hex}"
# Extract role - handle both string and enum
role_str = msg.role if hasattr(msg.role, "value") else str(msg.role)
role = cast(MessageRole, role_str) # Safe: Agent Framework roles match OpenAI roles
# Convert Message contents to OpenAI TextContent format
message_content = []
message_content: MutableSequence[OpenAIContent] = []
for content_item in msg.contents:
if content_item.type == "text":
# Extract text from TextContent object
text_value = getattr(content_item, "text", "")
message_content.append(TextContent(type="text", text=text_value))
message_content.append(TextContent(type="text", text=content_item.text or ""))
# Create Message object (concrete type from ConversationItem union)
message = OpenAIMessage(
id=item_id,
type="message", # Required discriminator for union
role=role,
role=cast(MessageRole, msg.role), # Safe: Agent Framework roles match OpenAI roles,
content=message_content,
status="completed", # Required field
)
@@ -383,8 +385,8 @@ class InMemoryConversationStore(ConversationStore):
# A single Message may produce multiple ConversationItems
# (e.g., a message with both text and a function call)
message_contents: list[TextContent | ResponseInputImage | ResponseInputFile] = []
function_calls = []
function_results = []
function_calls: list[ResponseFunctionToolCallItem] = []
function_results: list[ResponseFunctionToolCallOutputItem] = []
for content in msg.contents:
content_type = getattr(content, "type", None)
@@ -628,7 +630,7 @@ class InMemoryConversationStore(ConversationStore):
async def list_conversations_by_metadata(self, metadata_filter: dict[str, str]) -> list[Conversation]:
"""Filter conversations by metadata (e.g., agent_id)."""
results = []
results: list[Conversation] = []
for conv_data in self._conversations.values():
conv_meta = conv_data.get("metadata", {}).copy() # Copy to avoid mutating original
@@ -704,7 +706,8 @@ class CheckpointConversationManager:
ValueError: If conversation not found
"""
# Access internal conversations dict (we know it's InMemoryConversationStore)
conv_data = self._store._conversations.get(conversation_id)
conversations_dict = cast(dict[str, dict[str, Any]], getattr(self._store, "_conversations", {}))
conv_data = conversations_dict.get(conversation_id)
if not conv_data:
raise ValueError(f"Conversation {conversation_id} not found")
@@ -10,6 +10,7 @@ import uuid
from collections.abc import AsyncGenerator
from datetime import datetime, timezone
from pathlib import Path
from typing import cast
from urllib.parse import urlparse
from .models._discovery_models import Deployment, DeploymentConfig, DeploymentEvent
@@ -175,7 +176,7 @@ class DeploymentManager:
# Check required resource providers are registered
required_providers = ["Microsoft.App", "Microsoft.ContainerRegistry", "Microsoft.OperationalInsights"]
unregistered_providers = []
unregistered_providers: list[str] = []
# Get list of registered providers
provider_check = await asyncio.create_subprocess_exec(
@@ -195,7 +196,12 @@ class DeploymentManager:
import json
try:
registered = json.loads(stdout.decode())
registered_raw = json.loads(stdout.decode())
registered: list[str] = []
if isinstance(registered_raw, list):
for item_obj in cast(list[object], registered_raw):
if isinstance(item_obj, str):
registered.append(item_obj)
for provider in required_providers:
if provider not in registered:
unregistered_providers.append(provider)
@@ -385,7 +391,7 @@ CMD ["devui", "/app/entity", "--mode", "{config.ui_mode}", "--host", "0.0.0.0",
)
# Stream output line by line
output_lines = []
output_lines: list[str] = []
try:
if not process.stdout:
raise ValueError("Failed to capture process output")
@@ -473,8 +479,11 @@ CMD ["devui", "/app/entity", "--mode", "{config.ui_mode}", "--host", "0.0.0.0",
for url in urls:
# Strip common trailing punctuation to ensure clean URL parsing
url_clean = url.rstrip(".,;:!?'\")}]")
host = urlparse(url_clean).hostname
if host and (host == "azurecontainerapps.io" or host.endswith(".azurecontainerapps.io")):
parsed_url = urlparse(str(url_clean))
host = parsed_url.hostname
if isinstance(host, str) and (
host == "azurecontainerapps.io" or host.endswith(".azurecontainerapps.io")
):
await event_queue.put(
DeploymentEvent(type="deploy.progress", message="Deployment URL generated!")
)
@@ -11,7 +11,7 @@ import logging
import sys
import uuid
from pathlib import Path
from typing import Any
from typing import Any, cast
from dotenv import load_dotenv
@@ -141,7 +141,7 @@ class EntityDiscovery:
self._loaded_objects[entity_id] = entity_obj
# Check module-level registry for cleanup hooks
from . import _get_registered_cleanup_hooks
from . import _get_registered_cleanup_hooks # type: ignore[reportPrivateUsage]
registered_hooks = _get_registered_cleanup_hooks(entity_obj)
if registered_hooks:
@@ -299,7 +299,7 @@ class EntityDiscovery:
self._loaded_objects[entity_id] = entity_object
# Check module-level registry for cleanup hooks
from . import _get_registered_cleanup_hooks
from . import _get_registered_cleanup_hooks # type: ignore[reportPrivateUsage]
registered_hooks = _get_registered_cleanup_hooks(entity_object)
if registered_hooks:
@@ -379,6 +379,8 @@ class EntityDiscovery:
deployment_supported = True
deployment_reason = "Ready for deployment (pending path verification)"
class_name = type(entity_object).__name__
# Create EntityInfo with Agent Framework specifics
return EntityInfo(
id=entity_id,
@@ -400,9 +402,7 @@ class EntityDiscovery:
deployment_reason=deployment_reason,
metadata={
"source": "agent_framework_object",
"class_name": entity_object.__class__.__name__
if hasattr(entity_object, "__class__")
else str(type(entity_object)),
"class_name": class_name,
},
)
@@ -854,7 +854,7 @@ class EntityDiscovery:
"module_path": module_path,
"entity_type": obj_type,
"source": source,
"class_name": obj.__class__.__name__ if hasattr(obj, "__class__") else str(type(obj)),
"class_name": type(obj).__name__,
},
)
@@ -874,47 +874,63 @@ class EntityDiscovery:
Returns:
List of tool/executor names
"""
tools = []
tools: list[str] = []
try:
if obj_type == "agent":
# For agents, check default_options.get("tools")
chat_options = getattr(obj, "default_options", None)
chat_options_tools = None
if chat_options:
chat_options_tools = chat_options.get("tools")
chat_options_tools: object | None = None
if isinstance(chat_options, dict):
chat_options_dict = cast(dict[str, Any], chat_options)
chat_options_tools = chat_options_dict.get("tools")
if chat_options_tools:
for tool in chat_options_tools:
if hasattr(tool, "__name__"):
tools.append(tool.__name__)
elif hasattr(tool, "name"):
tools.append(tool.name)
if chat_options_tools is not None:
tool_iterable: list[object] = (
cast(list[object], chat_options_tools)
if isinstance(chat_options_tools, list)
else [chat_options_tools]
)
for tool_obj in tool_iterable:
tool_name = getattr(tool_obj, "__name__", None)
if isinstance(tool_name, str):
tools.append(tool_name)
continue
named_tool = getattr(tool_obj, "name", None)
if isinstance(named_tool, str):
tools.append(named_tool)
else:
tools.append(str(tool))
tools.append(str(tool_obj))
else:
# Fallback to direct tools attribute
agent_tools = getattr(obj, "tools", None)
if agent_tools:
for tool in agent_tools:
if hasattr(tool, "__name__"):
tools.append(tool.__name__)
elif hasattr(tool, "name"):
tools.append(tool.name)
if isinstance(agent_tools, list):
for tool_obj in cast(list[object], agent_tools):
tool_name = getattr(tool_obj, "__name__", None)
if isinstance(tool_name, str):
tools.append(tool_name)
continue
named_tool = getattr(tool_obj, "name", None)
if isinstance(named_tool, str):
tools.append(named_tool)
else:
tools.append(str(tool))
tools.append(str(tool_obj))
elif obj_type == "workflow":
# For workflows, extract executor names
if hasattr(obj, "get_executors_list"):
executor_objects = obj.get_executors_list()
tools = [getattr(ex, "id", str(ex)) for ex in executor_objects]
if isinstance(executor_objects, list):
for executor_obj in cast(list[object], executor_objects):
tools.append(str(getattr(executor_obj, "id", executor_obj)))
elif hasattr(obj, "executors"):
executors = obj.executors
if isinstance(executors, list):
tools = [getattr(ex, "id", str(ex)) for ex in executors]
for executor_obj in cast(list[object], executors):
tools.append(str(getattr(executor_obj, "id", executor_obj)))
elif isinstance(executors, dict):
tools = list(executors.keys())
executors_dict = cast(dict[str, Any], executors)
for key_obj in executors_dict:
tools.append(str(key_obj))
except Exception as e:
logger.debug(f"Error extracting tools from {obj_type} {type(obj)}: {e}")
@@ -7,7 +7,7 @@ from __future__ import annotations
import json
import logging
from collections.abc import AsyncGenerator
from typing import Any
from typing import Any, cast
from agent_framework import Content, SupportsAgentRun, Workflow
@@ -24,7 +24,8 @@ logger = logging.getLogger(__name__)
def _get_event_type(event: Any) -> str | None:
"""Safely get the type of an event, handling both objects and dicts."""
if isinstance(event, dict):
return event.get("type")
event_type = cast(dict[str, Any], event).get("type")
return event_type if isinstance(event_type, str) else None
return getattr(event, "type", None)
@@ -71,7 +72,8 @@ class AgentFrameworkExecutor:
from opentelemetry.sdk.trace import TracerProvider
# Only set up if no provider exists yet
if not hasattr(trace, "_TRACER_PROVIDER") or trace._TRACER_PROVIDER is None:
current_provider = trace.get_tracer_provider()
if current_provider.__class__.__name__ == "ProxyTracerProvider":
resource = Resource.create({
"service.name": "agent-framework-server",
"service.version": "1.0.0",
@@ -94,21 +96,29 @@ class AgentFrameworkExecutor:
# Configure if instrumentation is enabled (via enable_instrumentation() or env var)
if OBSERVABILITY_SETTINGS.ENABLED:
# Only configure providers if not already executed
if not OBSERVABILITY_SETTINGS._executed_setup:
# Call configure_otel_providers to set up exporters.
# If OTEL_EXPORTER_OTLP_ENDPOINT is set, exporters will be created automatically.
# If not set, no exporters are created (no console spam), but DevUI's
# TracerProvider from _setup_instrumentation_provider() remains active for local capture.
configure_otel_providers(enable_sensitive_data=OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED)
logger.info("Enabled Agent Framework observability")
else:
logger.debug("Agent Framework observability already configured")
# Call configure_otel_providers to set up exporters.
# If OTEL_EXPORTER_OTLP_ENDPOINT is set, exporters will be created automatically.
# If not set, no exporters are created (no console spam), but DevUI's
# TracerProvider from _setup_instrumentation_provider() remains active for local capture.
configure_otel_providers(enable_sensitive_data=OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED)
logger.info("Enabled Agent Framework observability")
else:
logger.debug("Instrumentation not enabled, skipping observability setup")
except Exception as e:
logger.warning(f"Failed to enable Agent Framework observability: {e}")
def _get_request_conversation_id(self, request: AgentFrameworkRequest) -> str | None:
"""Read conversation id using public request fields."""
if isinstance(request.conversation, str):
return request.conversation
if isinstance(request.conversation, dict):
conversation_id = request.conversation.get("id")
if isinstance(conversation_id, str):
return conversation_id
return None
async def _ensure_mcp_connections(self, agent: Any) -> None:
"""Ensure MCP tool connections are healthy before agent execution.
@@ -317,7 +327,7 @@ class AgentFrameworkExecutor:
# Get session from conversation parameter (OpenAI standard!)
session = None
conversation_id = request._get_conversation_id()
conversation_id = self._get_request_conversation_id(request)
if conversation_id:
session = self.conversation_store.get_session(conversation_id)
if session:
@@ -344,7 +354,7 @@ class AgentFrameworkExecutor:
if session:
run_kwargs["session"] = session
stream = agent.run(user_message, **run_kwargs)
stream = cast(Any, agent.run(user_message, **run_kwargs))
async for update in stream:
for trace_event in trace_collector.get_pending_events():
yield trace_event
@@ -388,7 +398,7 @@ class AgentFrameworkExecutor:
entity_id = request.get_entity_id() or "unknown"
# Get or create session conversation for checkpoint storage
conversation_id = request._get_conversation_id()
conversation_id = self._get_request_conversation_id(request)
if not conversation_id:
# Create default session if not provided
import time
@@ -463,11 +473,14 @@ class AgentFrameworkExecutor:
logger.info(f"Resuming workflow with HIL responses for {len(hil_responses)} request(s)")
# Unwrap primitive responses if they're wrapped in {response: value} format
unwrapped_responses = {}
unwrapped_responses: dict[str, Any] = {}
for request_id, response_value in hil_responses.items():
if isinstance(response_value, dict) and "response" in response_value:
response_value = response_value["response"]
unwrapped_responses[request_id] = response_value
normalized_response: Any = response_value
if isinstance(response_value, dict):
response_dict = cast(dict[str, Any], response_value)
if "response" in response_dict:
normalized_response = response_dict["response"]
unwrapped_responses[request_id] = normalized_response
hil_responses = unwrapped_responses
@@ -568,7 +581,8 @@ class AgentFrameworkExecutor:
# Handle OpenAI ResponseInputParam (List[ResponseInputItemParam])
if isinstance(input_data, list):
return self._convert_openai_input_to_chat_message(input_data, Message, Role)
input_items: Any = cast(Any, input_data)
return self._convert_openai_input_to_chat_message(input_items, Message, Role)
# Fallback for other formats
return self._extract_user_message_fallback(input_data)
@@ -593,27 +607,31 @@ class AgentFrameworkExecutor:
for item in input_items:
# Handle dict format (from JSON)
if isinstance(item, dict):
item_type = item.get("type")
item_dict = cast(dict[str, Any], item)
item_type = item_dict.get("type")
if item_type == "message":
# Extract content from OpenAI message
message_content = item.get("content", [])
message_content = item_dict.get("content", [])
# Handle both string content and list content
if isinstance(message_content, str):
contents.append(Content.from_text(text=message_content))
elif isinstance(message_content, list):
for content_item in message_content:
message_content_items: Any = cast(Any, message_content)
for content_item in message_content_items:
# Handle dict content items
if isinstance(content_item, dict):
content_type = content_item.get("type")
content_dict = cast(dict[str, Any], content_item)
content_type = content_dict.get("type")
if content_type == "input_text":
text = content_item.get("text", "")
contents.append(Content.from_text(text=text))
text = content_dict.get("text", "")
if isinstance(text, str):
contents.append(Content.from_text(text=text))
elif content_type == "input_image":
image_url = content_item.get("image_url", "")
if image_url:
image_url = content_dict.get("image_url", "")
if isinstance(image_url, str) and image_url:
# Extract media type from data URI if possible
# Parse media type from data URL, fallback to image/png
if image_url.startswith("data:"):
@@ -631,9 +649,12 @@ class AgentFrameworkExecutor:
elif content_type == "input_file":
# Handle file input
file_data = content_item.get("file_data")
file_url = content_item.get("file_url")
filename = content_item.get("filename", "")
file_data = content_dict.get("file_data")
file_url = content_dict.get("file_url")
filename = content_dict.get("filename", "")
if not isinstance(filename, str):
filename = ""
# Determine media type from filename
media_type = "application/octet-stream" # default
@@ -656,8 +677,10 @@ class AgentFrameworkExecutor:
# Use file_data or file_url
# Include filename in additional_properties for OpenAI/Azure file handling
additional_props = {"filename": filename} if filename else None
if file_data:
additional_props: dict[str, Any] | None = (
{"filename": filename} if filename else None
)
if isinstance(file_data, str) and file_data:
# Assume file_data is base64, create data URI
data_uri = f"data:{media_type};base64,{file_data}"
contents.append(
@@ -667,7 +690,7 @@ class AgentFrameworkExecutor:
additional_properties=additional_props,
)
)
elif file_url:
elif isinstance(file_url, str) and file_url:
contents.append(
Content.from_uri(
uri=file_url,
@@ -679,15 +702,35 @@ class AgentFrameworkExecutor:
elif content_type == "function_approval_response":
# Handle function approval response (DevUI extension)
try:
request_id = content_item.get("request_id", "")
approved = content_item.get("approved", False)
function_call_data = content_item.get("function_call", {})
request_id = content_dict.get("request_id", "")
approved = content_dict.get("approved", False)
function_call_data = content_dict.get("function_call", {})
if not isinstance(request_id, str):
request_id = ""
if not isinstance(approved, bool):
approved = False
if not isinstance(function_call_data, dict):
function_call_data = {}
function_call_data_dict = cast(dict[str, Any], function_call_data)
function_call_id = function_call_data_dict.get("id", "")
function_call_name = function_call_data_dict.get("name", "")
function_call_args = function_call_data_dict.get("arguments", {})
if not isinstance(function_call_id, str):
function_call_id = ""
if not isinstance(function_call_name, str):
function_call_name = ""
if not isinstance(function_call_args, dict):
function_call_args = {}
# Create FunctionCallContent from the function_call data
function_call = Content.from_function_call(
call_id=function_call_data.get("id", ""),
name=function_call_data.get("name", ""),
arguments=function_call_data.get("arguments", {}),
call_id=function_call_id,
name=function_call_name,
arguments=cast(dict[str, Any], function_call_args),
)
# Create FunctionApprovalResponseContent with correct signature
@@ -739,12 +782,14 @@ class AgentFrameworkExecutor:
if isinstance(input_data, str):
return input_data
if isinstance(input_data, dict):
typed_input_data = cast(dict[str, Any], input_data)
# Try common field names
for field in ["message", "text", "input", "content", "query"]:
if field in input_data:
return str(input_data[field])
if field in typed_input_data:
value = typed_input_data[field]
return value if isinstance(value, str) else str(value)
# Fallback to JSON string
return json.dumps(input_data)
return json.dumps(typed_input_data)
return str(input_data)
def _is_openai_multimodal_format(self, input_data: Any) -> bool:
@@ -758,8 +803,12 @@ class AgentFrameworkExecutor:
"""
if not isinstance(input_data, list) or not input_data:
return False
first_item = input_data[0]
return isinstance(first_item, dict) and first_item.get("type") == "message"
input_data_items: Any = cast(Any, input_data)
first_item = input_data_items[0]
if not isinstance(first_item, dict):
return False
first_type = cast(dict[str, Any], first_item).get("type")
return isinstance(first_type, str) and first_type == "message"
async def _parse_workflow_input(self, workflow: Any, raw_input: Any) -> Any:
"""Parse input based on workflow's expected input type.
@@ -775,7 +824,7 @@ class AgentFrameworkExecutor:
# Handle JSON string input (from frontend api.ts JSON.stringify)
if isinstance(raw_input, str):
try:
parsed = json.loads(raw_input)
parsed: Any = json.loads(raw_input)
raw_input = parsed
except (json.JSONDecodeError, TypeError):
# Plain text string, continue with string handling
@@ -789,14 +838,14 @@ class AgentFrameworkExecutor:
# Handle structured input (dict)
if isinstance(raw_input, dict):
return self._parse_structured_workflow_input(workflow, raw_input)
return self._parse_structured_workflow_input(workflow, cast(dict[str, Any], raw_input))
# Handle string input
return self._parse_raw_workflow_input(workflow, str(raw_input))
except Exception as e:
logger.warning(f"Error parsing workflow input: {e}")
return raw_input
return cast(Any, raw_input)
def _get_start_executor_message_types(self, workflow: Any) -> tuple[Any | None, list[Any]]:
"""Return start executor and its declared input types."""
@@ -823,7 +872,8 @@ class AgentFrameworkExecutor:
try:
handlers = start_executor._handlers
if isinstance(handlers, dict):
message_types = list(handlers.keys())
handlers_dict: Any = cast(Any, handlers)
message_types = list(handlers_dict.keys())
except Exception as exc: # pragma: no cover - defensive logging path
logger.debug(f"Failed to read executor handlers: {exc}")
@@ -847,7 +897,8 @@ class AgentFrameworkExecutor:
parsed = json.loads(input_data)
# Only use parsed value if it's a list (ResponseInputParam format expected for HIL)
if isinstance(parsed, list):
input_data = parsed
parsed_list: Any = cast(Any, parsed)
input_data = parsed_list
else:
# Parsed to dict, string, or primitive - not HIL response format
return None
@@ -864,19 +915,32 @@ class AgentFrameworkExecutor:
if not isinstance(input_data, list):
return None
for item in input_data:
if isinstance(item, dict) and item.get("type") == "message":
message_content = item.get("content", [])
input_items: Any = cast(Any, input_data)
for item in input_items:
if isinstance(item, dict):
item_dict = cast(dict[str, Any], item)
if item_dict.get("type") != "message":
continue
message_content = item_dict.get("content", [])
if isinstance(message_content, list):
for content_item in message_content:
message_content_items: Any = cast(Any, message_content)
for content_item in message_content_items:
if isinstance(content_item, dict):
content_type = content_item.get("type")
content_dict = cast(dict[str, Any], content_item)
content_type = content_dict.get("type")
if content_type == "workflow_hil_response":
# Extract responses dict
# dict.get() returns Any, so we explicitly type it
responses: dict[str, Any] = content_item.get("responses", {}) # type: ignore[assignment]
responses_raw = content_dict.get("responses", {})
if not isinstance(responses_raw, dict):
continue
responses_dict: Any = cast(Any, responses_raw)
responses = {
str(response_key): response_value
for response_key, response_value in responses_dict.items()
}
logger.info(f"Found workflow HIL responses: {list(responses.keys())}")
return responses
@@ -1000,11 +1064,12 @@ class AgentFrameworkExecutor:
return
# Find the source executor in the workflow
if not hasattr(workflow, "executors") or not isinstance(workflow.executors, dict):
executors = getattr(workflow, "executors", None)
if not isinstance(executors, dict):
logger.debug("Workflow doesn't have executors dict")
return
source_executor = workflow.executors.get(source_executor_id)
source_executor = cast(dict[str, Any], executors).get(source_executor_id)
if not source_executor:
logger.debug(f"Could not find executor '{source_executor_id}' in workflow")
return
@@ -11,7 +11,7 @@ import uuid
from collections import OrderedDict
from collections.abc import Sequence
from datetime import datetime
from typing import Any, Union
from typing import Any, Union, cast
from uuid import uuid4
from agent_framework import Content, Message
@@ -61,6 +61,17 @@ EventType = Union[
]
def _to_str_dict(value: Any) -> dict[str, Any] | None:
"""Cast arbitrary dict-like payload to a string-keyed dictionary."""
if not isinstance(value, dict):
return None
return cast(dict[str, Any], value)
def _stringify_name(value: Any) -> str:
return value if isinstance(value, str) else str(value)
def _serialize_content_recursive(value: Any) -> Any:
"""Recursively serialize Agent Framework Content objects to JSON-compatible values.
@@ -88,16 +99,21 @@ def _serialize_content_recursive(value: Any) -> Any:
# Handle dictionaries - recursively process values
if isinstance(value, dict):
return {key: _serialize_content_recursive(val) for key, val in value.items()}
value_dict = cast(dict[str, Any], value)
return {str(key): _serialize_content_recursive(val) for key, val in value_dict.items()}
# Handle lists and tuples - recursively process elements
if isinstance(value, (list, tuple)):
serialized = [_serialize_content_recursive(item) for item in value]
sequence_items: Any = cast(Any, value)
serialized: list[Any] = [_serialize_content_recursive(item) for item in sequence_items]
# For single-item lists containing text Content, extract just the text
# This handles the MCP case where result = [Content.from_text(text="Hello")]
# and we want output = "Hello" not output = '[{"type": "text", "text": "Hello"}]'
if len(serialized) == 1 and isinstance(serialized[0], dict) and serialized[0].get("type") == "text":
return serialized[0].get("text", "")
if len(serialized) == 1:
first_item = _to_str_dict(serialized[0])
if first_item and first_item.get("type") == "text":
text_value = first_item.get("text", "")
return text_value if isinstance(text_value, str) else str(text_value)
return serialized
# For other objects with model_dump(), try that
@@ -156,8 +172,10 @@ class MessageMapper:
context = self._get_or_create_context(request)
# Handle error events
if isinstance(raw_event, dict) and raw_event.get("type") == "error":
return [await self._create_error_event(raw_event.get("message", "Unknown error"), context)]
raw_event_dict = _to_str_dict(raw_event)
if raw_event_dict and raw_event_dict.get("type") == "error":
message = raw_event_dict.get("message", "Unknown error")
return [await self._create_error_event(_stringify_name(message), context)]
# Handle ResponseTraceEvent objects from our trace collector
from .models import ResponseTraceEvent
@@ -185,15 +203,12 @@ class MessageMapper:
# Handle WorkflowEvent with type='output' or 'data' wrapping AgentResponseUpdate
# This must be checked BEFORE generic WorkflowEvent check
# Note: AgentExecutor uses type='output' for streaming updates
if (
isinstance(raw_event, WorkflowEvent)
and raw_event.type in ("output", "data")
and raw_event.data
and isinstance(raw_event.data, AgentResponseUpdate)
):
# Preserve executor_id in context for proper output routing
context["current_executor_id"] = raw_event.executor_id
return await self._convert_agent_update(raw_event.data, context)
if isinstance(raw_event, WorkflowEvent) and raw_event.type in ("output", "data"):
event_data = getattr(cast(Any, raw_event), "data", None)
if isinstance(event_data, AgentResponseUpdate):
# Preserve executor_id in context for proper output routing
context["current_executor_id"] = getattr(cast(Any, raw_event), "executor_id", None)
return await self._convert_agent_update(event_data, context)
# Handle complete agent response (AgentResponse) - for non-streaming agent execution
if isinstance(raw_event, AgentResponse):
@@ -210,10 +225,11 @@ class MessageMapper:
except ImportError as e:
logger.warning(f"Could not import Agent Framework types: {e}")
# Fallback to attribute-based detection
if hasattr(raw_event, "contents"):
return await self._convert_agent_update(raw_event, context)
if hasattr(raw_event, "__class__") and "Event" in raw_event.__class__.__name__:
return await self._convert_workflow_event(raw_event, context)
candidate_event = cast(Any, raw_event)
if hasattr(candidate_event, "contents"):
return await self._convert_agent_update(candidate_event, context)
if "Event" in type(candidate_event).__name__:
return await self._convert_workflow_event(candidate_event, context)
# Unknown event type
return [await self._create_unknown_event(raw_event, context)]
@@ -256,32 +272,36 @@ class MessageMapper:
item = getattr(event, "item", None)
if item:
# Handle both object and dict formats
item_type = item.get("type") if isinstance(item, dict) else getattr(item, "type", None)
item_dict = _to_str_dict(item)
item_type = item_dict.get("type") if item_dict is not None else getattr(item, "type", None)
# Track function calls to accumulate their arguments
if item_type == "function_call":
# Handle both object and dict formats
if isinstance(item, dict):
call_id = item.get("call_id") or item.get("id")
if call_id:
item_dict = _to_str_dict(item)
if item_dict is not None:
call_id_value = item_dict.get("call_id") or item_dict.get("id")
if call_id_value:
call_id = str(call_id_value)
function_calls[call_id] = {
"id": item.get("id", call_id),
"id": str(item_dict.get("id", call_id)),
"call_id": call_id,
"name": item.get("name", ""),
"arguments": item.get("arguments", ""),
"name": _stringify_name(item_dict.get("name", "")),
"arguments": _stringify_name(item_dict.get("arguments", "")),
"type": "function_call",
"status": item.get("status", "completed"),
"status": _stringify_name(item_dict.get("status", "completed")),
}
else:
call_id = getattr(item, "call_id", None) or getattr(item, "id", None)
if call_id:
call_id_value = getattr(item, "call_id", None) or getattr(item, "id", None)
if call_id_value:
call_id = str(call_id_value)
function_calls[call_id] = {
"id": getattr(item, "id", call_id),
"id": str(getattr(item, "id", call_id)),
"call_id": call_id,
"name": getattr(item, "name", ""),
"arguments": getattr(item, "arguments", ""),
"name": _stringify_name(getattr(item, "name", "")),
"arguments": _stringify_name(getattr(item, "arguments", "")),
"type": "function_call",
"status": getattr(item, "status", "completed"),
"status": _stringify_name(getattr(item, "status", "completed")),
}
# Other output items (message, etc.) - track for later
@@ -299,8 +319,9 @@ class MessageMapper:
# Handle function result complete events
elif event_type == "response.function_result.complete":
call_id = getattr(event, "call_id", None)
if call_id:
call_id_value = getattr(event, "call_id", None)
if call_id_value:
call_id = str(call_id_value)
function_results[call_id] = {
"type": "function_call_output",
"call_id": call_id,
@@ -322,7 +343,7 @@ class MessageMapper:
# Build final text message from accumulated deltas
# Combine all text parts (usually there's just one message)
all_text_parts = []
all_text_parts: list[str] = []
for _item_id, parts in text_parts_by_message.items():
all_text_parts.extend(parts)
@@ -493,14 +514,14 @@ class MessageMapper:
return value.value
# Handle lists/tuples/sets - recursively serialize elements
if isinstance(value, (list, tuple)):
return [self._serialize_value(item) for item in value]
if isinstance(value, set):
return [self._serialize_value(item) for item in value]
if isinstance(value, (list, tuple, set)):
value_items: Any = cast(Any, value)
return [self._serialize_value(item) for item in value_items]
# Handle dicts - recursively serialize values
if isinstance(value, dict):
return {k: self._serialize_value(v) for k, v in value.items()}
value_dict = cast(dict[str, Any], value)
return {str(k): self._serialize_value(v) for k, v in value_dict.items()}
# Handle SerializationMixin (like Message) - call to_dict()
if hasattr(value, "to_dict") and callable(getattr(value, "to_dict", None)):
@@ -551,14 +572,15 @@ class MessageMapper:
# Handle dict first (most common)
if isinstance(request_data, dict):
return {k: self._serialize_value(v) for k, v in request_data.items()}
request_dict = cast(dict[str, Any], request_data)
return {str(k): self._serialize_value(v) for k, v in request_dict.items()}
# Handle dataclasses with nested SerializationMixin objects
# We can't use asdict() directly because it doesn't handle Message
if is_dataclass(request_data) and not isinstance(request_data, type):
try:
# Manually serialize each field to handle nested SerializationMixin
result = {}
result: dict[str, Any] = {}
for field in fields(request_data):
field_value = getattr(request_data, field.name)
result[field.name] = self._serialize_value(field_value)
@@ -900,8 +922,9 @@ class MessageMapper:
text = str(output_data)
elif isinstance(output_data, list):
# Handle list of Message objects (from Magentic yield_output([final_answer]))
text_parts = []
for item in output_data:
text_parts: list[str] = []
output_items_list: Any = cast(Any, output_data)
for item in output_items_list:
if isinstance(item, Message):
item_text = getattr(item, "text", None)
if item_text:
@@ -912,17 +935,17 @@ class MessageMapper:
text_parts.append(item)
else:
try:
text_parts.append(json.dumps(item, indent=2))
text_parts.append(json.dumps(self._serialize_value(item), indent=2))
except (TypeError, ValueError):
text_parts.append(str(item))
text = "\n".join(text_parts) if text_parts else str(output_data)
text = "\n".join(text_parts) if text_parts else str(cast(Any, output_data))
elif isinstance(output_data, str):
# String output
text = output_data
else:
# Object/dict → JSON string
try:
text = json.dumps(output_data, indent=2)
text = json.dumps(self._serialize_value(output_data), indent=2)
except (TypeError, ValueError):
# Fallback to string representation if not JSON serializable
text = str(output_data)
@@ -1420,10 +1443,10 @@ class MessageMapper:
None - no event emitted (usage goes in final Response.usage)
"""
# Extract usage from UsageContent.usage_details (UsageDetails object)
details = content.usage_details or {}
total_tokens = details.get("total_token_count", 0)
prompt_tokens = details.get("input_token_count", 0)
completion_tokens = details.get("output_token_count", 0)
details = _to_str_dict(getattr(content, "usage_details", None)) or {}
total_tokens = int(details.get("total_token_count", 0) or 0)
prompt_tokens = int(details.get("input_token_count", 0) or 0)
completion_tokens = int(details.get("output_token_count", 0) or 0)
# Accumulate for final Response.usage
request_id = context.get("request_id", "default")
@@ -22,6 +22,26 @@ from ..models import AgentFrameworkRequest, OpenAIResponse
logger = logging.getLogger(__name__)
def _extract_error_details(body: Any) -> tuple[str | None, str | None, str | None]:
"""Extract typed OpenAI error fields from error body payload."""
if not isinstance(body, dict):
return None, None, None
error_dict: dict[str, Any] = body.get("error") # type: ignore[assignment, reportUnknownVariableType]
if not isinstance(error_dict, dict):
return None, None, None
message = error_dict.get("message")
error_type = error_dict.get("type")
code = error_dict.get("code")
return (
message if isinstance(message, str) else None,
error_type if isinstance(error_type, str) else None,
code if isinstance(code, str) else None,
)
class OpenAIExecutor:
"""Executor for OpenAI Responses API - mirrors AgentFrameworkExecutor interface.
@@ -138,68 +158,64 @@ class OpenAIExecutor:
except AuthenticationError as e:
# 401 - Invalid API key or authentication issue
logger.error(f"OpenAI authentication error: {e}", exc_info=True)
error_body = e.body if hasattr(e, "body") else {}
error_data = error_body.get("error", {}) if isinstance(error_body, dict) else {}
message, error_type, code = _extract_error_details(e.body if hasattr(e, "body") else None)
yield {
"type": "response.failed",
"response": {
"id": f"resp_{os.urandom(16).hex()}",
"status": "failed",
"error": {
"message": error_data.get("message", str(e)),
"type": error_data.get("type", "authentication_error"),
"code": error_data.get("code", "invalid_api_key"),
"message": message or str(e),
"type": error_type or "authentication_error",
"code": code or "invalid_api_key",
},
},
}
except PermissionDeniedError as e:
# 403 - Permission denied
logger.error(f"OpenAI permission denied: {e}", exc_info=True)
error_body = e.body if hasattr(e, "body") else {}
error_data = error_body.get("error", {}) if isinstance(error_body, dict) else {}
message, error_type, code = _extract_error_details(e.body if hasattr(e, "body") else None)
yield {
"type": "response.failed",
"response": {
"id": f"resp_{os.urandom(16).hex()}",
"status": "failed",
"error": {
"message": error_data.get("message", str(e)),
"type": error_data.get("type", "permission_denied"),
"code": error_data.get("code", "insufficient_permissions"),
"message": message or str(e),
"type": error_type or "permission_denied",
"code": code or "insufficient_permissions",
},
},
}
except RateLimitError as e:
# 429 - Rate limit exceeded
logger.error(f"OpenAI rate limit exceeded: {e}", exc_info=True)
error_body = e.body if hasattr(e, "body") else {}
error_data = error_body.get("error", {}) if isinstance(error_body, dict) else {}
message, error_type, code = _extract_error_details(e.body if hasattr(e, "body") else None)
yield {
"type": "response.failed",
"response": {
"id": f"resp_{os.urandom(16).hex()}",
"status": "failed",
"error": {
"message": error_data.get("message", str(e)),
"type": error_data.get("type", "rate_limit_error"),
"code": error_data.get("code", "rate_limit_exceeded"),
"message": message or str(e),
"type": error_type or "rate_limit_error",
"code": code or "rate_limit_exceeded",
},
},
}
except APIStatusError as e:
# Other OpenAI API errors
logger.error(f"OpenAI API error: {e}", exc_info=True)
error_body = e.body if hasattr(e, "body") else {}
error_data = error_body.get("error", {}) if isinstance(error_body, dict) else {}
message, error_type, code = _extract_error_details(e.body if hasattr(e, "body") else None)
yield {
"type": "response.failed",
"response": {
"id": f"resp_{os.urandom(16).hex()}",
"status": "failed",
"error": {
"message": error_data.get("message", str(e)),
"type": error_data.get("type", "api_error"),
"code": error_data.get("code", "unknown_error"),
"message": message or str(e),
"type": error_type or "api_error",
"code": code or "unknown_error",
},
},
}
@@ -31,6 +31,29 @@ from .models._discovery_models import Deployment, DeploymentConfig, DiscoveryRes
logger = logging.getLogger(__name__)
def _extract_error_details(body: object) -> tuple[str | None, str | None, str | None]:
"""Extract typed OpenAI-style error payload fields."""
if not isinstance(body, dict):
return None, None, None
body_dict = cast(dict[str, object], body)
error_obj = body_dict.get("error")
if not isinstance(error_obj, dict):
return None, None, None
error_dict = cast(dict[str, object], error_obj)
message = error_dict.get("message")
error_type = error_dict.get("type")
code = error_dict.get("code")
return (
message if isinstance(message, str) else None,
error_type if isinstance(error_type, str) else None,
code if isinstance(code, str) else None,
)
# Get package version
try:
__version__ = importlib.metadata.version("agent-framework-devui")
@@ -83,6 +106,10 @@ class DevServer:
self._pending_entities: list[Any] | None = None
self._running_tasks: dict[str, asyncio.Task[Any]] = {} # Track running response tasks for cancellation
def set_pending_entities(self, entities: list[Any]) -> None:
"""Set in-memory entities to register on startup."""
self._pending_entities = entities
def _is_dev_mode(self) -> bool:
"""Check if running in developer mode.
@@ -378,6 +405,8 @@ class DevServer:
# Token valid, proceed
return await call_next(request)
_ = auth_middleware
self._register_routes(app)
self._mount_ui(app)
@@ -452,7 +481,7 @@ class DevServer:
if entity_info.type == "workflow" and entity_obj:
# Entity object already loaded by load_entity() above
# Get workflow structure
workflow_dump = None
workflow_dump: dict[str, Any] | str | None = None
if hasattr(entity_obj, "to_dict") and callable(getattr(entity_obj, "to_dict", None)):
try:
workflow_dump = entity_obj.to_dict() # type: ignore[attr-defined]
@@ -475,7 +504,11 @@ class DevServer:
except Exception:
workflow_dump = raw_dump
else:
workflow_dump = parsed_dump if isinstance(parsed_dump, dict) else raw_dump
if isinstance(parsed_dump, dict):
parsed_dump_dict = cast(dict[str, Any], parsed_dump)
workflow_dump = {str(k): v for k, v in parsed_dump_dict.items()}
else:
workflow_dump = raw_dump
else:
workflow_dump = raw_dump
elif hasattr(entity_obj, "__dict__"):
@@ -838,34 +871,31 @@ class DevServer:
except AuthenticationError as e:
# 401 - Invalid API key or authentication issue
logger.error(f"OpenAI authentication error creating conversation: {e}")
error_body = e.body if hasattr(e, "body") else {}
error_data = error_body.get("error", {}) if isinstance(error_body, dict) else {}
message, error_type, code = _extract_error_details(e.body if hasattr(e, "body") else None)
error = OpenAIError.create(
message=error_data.get("message", str(e)),
type=error_data.get("type", "authentication_error"),
code=error_data.get("code", "invalid_api_key"),
message=message or str(e),
type=error_type or "authentication_error",
code=code or "invalid_api_key",
)
return JSONResponse(status_code=401, content=error.to_dict())
except PermissionDeniedError as e:
# 403 - Permission denied
logger.error(f"OpenAI permission denied creating conversation: {e}")
error_body = e.body if hasattr(e, "body") else {}
error_data = error_body.get("error", {}) if isinstance(error_body, dict) else {}
message, error_type, code = _extract_error_details(e.body if hasattr(e, "body") else None)
error = OpenAIError.create(
message=error_data.get("message", str(e)),
type=error_data.get("type", "permission_denied"),
code=error_data.get("code", "insufficient_permissions"),
message=message or str(e),
type=error_type or "permission_denied",
code=code or "insufficient_permissions",
)
return JSONResponse(status_code=403, content=error.to_dict())
except APIStatusError as e:
# Other OpenAI API errors (rate limit, etc.)
logger.error(f"OpenAI API error creating conversation: {e}")
error_body = e.body if hasattr(e, "body") else {}
error_data = error_body.get("error", {}) if isinstance(error_body, dict) else {}
message, error_type, code = _extract_error_details(e.body if hasattr(e, "body") else None)
error = OpenAIError.create(
message=error_data.get("message", str(e)),
type=error_data.get("type", "api_error"),
code=error_data.get("code", "unknown_error"),
message=message or str(e),
type=error_type or "api_error",
code=code or "unknown_error",
)
return JSONResponse(
status_code=e.status_code if hasattr(e, "status_code") else 500, content=error.to_dict()
@@ -902,7 +932,7 @@ class DevServer:
executor = await self._ensure_executor()
# Build filter criteria
filters = {}
filters: dict[str, str] = {}
if agent_id:
filters["agent_id"] = agent_id
if entity_id:
@@ -997,15 +1027,16 @@ class DevServer:
conversation_id, limit=limit, after=after, order=order
)
# Handle both Pydantic models and dicts (some stores return raw dicts)
serialized_items = []
serialized_items: list[dict[str, Any]] = []
for item in items:
if hasattr(item, "model_dump"):
serialized_items.append(item.model_dump())
elif isinstance(item, dict):
serialized_items.append(item)
item_dict = cast(dict[str, Any], item)
serialized_items.append({str(k): v for k, v in item_dict.items()})
else:
logger.warning(f"Unexpected item type: {type(item)}, converting to dict")
serialized_items.append(dict(item))
serialized_items.append({str(k): v for k, v in dict(item).items()})
# Get stored traces for context inspection (DevUI extension)
traces = executor.conversation_store.get_traces(conversation_id)
@@ -1038,9 +1069,14 @@ class DevServer:
if not item:
raise HTTPException(status_code=404, detail="Item not found")
# Handle both Pydantic models and dicts
result: dict[str, Any] = (
item.model_dump() if hasattr(item, "model_dump") else cast(dict[str, Any], item)
)
result: dict[str, Any]
if hasattr(item, "model_dump"):
result = item.model_dump()
elif isinstance(item, dict):
item_dict = cast(dict[str, Any], item)
result = {str(k): v for k, v in item_dict.items()}
else:
result = {"value": item}
return result
except HTTPException:
raise
@@ -1085,16 +1121,42 @@ class DevServer:
# Checkpoints are exposed as conversation items with type="checkpoint"
# ============================================================================
registered_route_handlers = (
health_check,
get_meta,
discover_entities,
get_entity_info,
reload_entity,
create_deployment,
list_deployments,
get_deployment,
delete_deployment,
deploy_entity,
create_response,
cancel_response,
create_conversation,
list_conversations,
retrieve_conversation,
update_conversation,
delete_conversation,
create_conversation_items,
list_conversation_items,
retrieve_conversation_item,
delete_conversation_item,
)
_ = registered_route_handlers
async def _stream_execution(
self, executor: AgentFrameworkExecutor, request: AgentFrameworkRequest
) -> AsyncGenerator[str]:
"""Stream execution directly through executor."""
try:
# Collect events for final response.completed event
events = []
events: list[Any] = []
# Get conversation_id for trace storage
conversation_id = request._get_conversation_id()
conversation_getter = getattr(request, "_get_conversation_id", None)
conversation_id = conversation_getter() if callable(conversation_getter) else None
# Stream all events
async for event in executor.execute_streaming(request):
@@ -1104,7 +1166,7 @@ class DevServer:
if conversation_id and hasattr(event, "type") and event.type == "response.trace.completed":
try:
trace_data = event.data if hasattr(event, "data") else None
if trace_data:
if trace_data and isinstance(conversation_id, str):
executor.conversation_store.add_trace(conversation_id, trace_data)
except Exception as e:
logger.debug(f"Failed to store trace event: {e}")
@@ -1136,8 +1198,9 @@ class DevServer:
# We need to increment from that
last_seq = 0
for event in reversed(events):
if hasattr(event, "sequence_number") and event.sequence_number is not None:
last_seq = event.sequence_number
sequence_number = getattr(event, "sequence_number", None)
if isinstance(sequence_number, int):
last_seq = sequence_number
break
completed_event = ResponseCompletedEvent(
@@ -5,13 +5,37 @@
import logging
import uuid
from datetime import datetime
from typing import Any
from typing import Any, TypedDict, cast
from typing_extensions import NotRequired
logger = logging.getLogger(__name__)
# Type aliases for better readability
SessionData = dict[str, Any]
RequestRecord = dict[str, Any]
class RequestRecord(TypedDict):
"""Tracked execution request data."""
id: str
timestamp: datetime
entity_id: str
executor: str
input: Any
model_id: str
stream: bool
execution_time: NotRequired[float]
status: NotRequired[str]
class SessionData(TypedDict):
"""Stored session state."""
id: str
created_at: datetime
requests: list[RequestRecord]
context: dict[str, Any]
active: bool
SessionSummary = dict[str, Any]
@@ -95,7 +119,7 @@ class SessionManager:
"stream": True,
}
session["requests"].append(request_record)
return str(request_record["id"])
return request_record["id"]
def update_request_record(self, session_id: str, request_id: str, updates: dict[str, Any]) -> None:
"""Update a request record in a session.
@@ -111,7 +135,8 @@ class SessionManager:
for request in session["requests"]:
if request["id"] == request_id:
request.update(updates)
request_data = cast(dict[str, Any], request)
request_data.update(updates)
break
def get_session_history(self, session_id: str) -> SessionSummary | None:
@@ -138,7 +163,7 @@ class SessionManager:
"timestamp": req["timestamp"].isoformat(),
"entity_id": req["entity_id"],
"executor": req["executor"],
"model": req["model"],
"model": req["model_id"],
"input_length": len(str(req["input"])) if req["input"] else 0,
"execution_time": req.get("execution_time"),
"status": req.get("status", "unknown"),
@@ -153,7 +178,7 @@ class SessionManager:
Returns:
List of active session summaries
"""
active_sessions = []
active_sessions: list[SessionSummary] = []
for session_id, session in self.sessions.items():
if session["active"]:
@@ -178,7 +203,7 @@ class SessionManager:
"""
cutoff_time = datetime.now().timestamp() - (max_age_hours * 3600)
sessions_to_remove = []
sessions_to_remove: list[str] = []
for session_id, session in self.sessions.items():
if session["created_at"].timestamp() < cutoff_time:
sessions_to_remove.append(session_id)
@@ -7,12 +7,20 @@ import json
import logging
from dataclasses import fields, is_dataclass
from types import UnionType
from typing import Any, Union, get_args, get_origin, get_type_hints
from typing import Any, Union, cast, get_args, get_origin, get_type_hints
from agent_framework import Message
logger = logging.getLogger(__name__)
def _string_key_dict(value: object) -> dict[str, Any] | None:
"""Cast value to a dict."""
if not isinstance(value, dict):
return None
return cast(dict[str, Any], value)
# ============================================================================
# Agent Metadata Extraction
# ============================================================================
@@ -39,18 +47,21 @@ def extract_agent_metadata(entity_object: Any) -> dict[str, Any]:
# Try to get instructions
if hasattr(entity_object, "default_options"):
chat_opts = entity_object.default_options
if isinstance(chat_opts, dict):
if "instructions" in chat_opts:
metadata["instructions"] = chat_opts.get("instructions")
chat_opts_dict = _string_key_dict(chat_opts)
if chat_opts_dict is not None:
if "instructions" in chat_opts_dict:
metadata["instructions"] = chat_opts_dict.get("instructions")
elif hasattr(chat_opts, "instructions"):
metadata["instructions"] = chat_opts.instructions
# Try to get model - check both default_options and client
if hasattr(entity_object, "default_options"):
chat_opts = entity_object.default_options
if isinstance(chat_opts, dict):
if chat_opts.get("model_id"):
metadata["model"] = chat_opts.get("model_id")
chat_opts_dict = _string_key_dict(chat_opts)
if chat_opts_dict is not None:
model_id = chat_opts_dict.get("model_id")
if model_id:
metadata["model"] = model_id
elif hasattr(chat_opts, "model_id") and chat_opts.model_id:
metadata["model"] = chat_opts.model_id
if metadata["model"] is None and hasattr(entity_object, "client") and hasattr(entity_object.client, "model_id"):
@@ -112,7 +123,7 @@ def extract_executor_message_types(executor: Any) -> list[Any]:
try:
handlers = executor._handlers
if isinstance(handlers, dict):
message_types = list(handlers.keys())
message_types = list(handlers.keys()) # type: ignore[arg-type] # pyright: ignore[reportUnknownArgumentType]
except Exception as exc: # pragma: no cover - defensive logging path
logger.debug(f"Failed to read executor handlers: {exc}")
@@ -366,11 +377,10 @@ def extract_response_type_from_executor(executor: Any, request_type: type) -> ty
_, second_param_type = param_items[1] if len(param_items) > 1 else (None, None)
# Check if first param matches request_type
first_matches_request = first_param_type == request_type or (
hasattr(first_param_type, "__name__")
and hasattr(request_type, "__name__")
and first_param_type.__name__ == request_type.__name__
)
first_matches_request = first_param_type == request_type
if not first_matches_request and isinstance(first_param_type, type):
request_type_name = request_type.__name__
first_matches_request = first_param_type.__name__ == request_type_name
# Verify we have a matching request type and valid response type (must be a type class)
if first_matches_request and second_param_type is not None and isinstance(second_param_type, type):
@@ -432,7 +442,7 @@ def generate_input_schema(input_type: type) -> dict[str, Any]:
return generate_schema_from_dataclass(input_type)
# 5. Fallback to string
type_name = getattr(input_type, "__name__", str(input_type))
type_name = input_type.__name__ if isinstance(input_type, type) else str(cast(Any, input_type))
return {"type": "string", "description": f"Input type: {type_name}"}
@@ -466,8 +476,9 @@ def parse_input_for_type(input_data: Any, target_type: type) -> Any:
return _parse_string_input(input_data, target_type)
# Handle dict input
if isinstance(input_data, dict):
return _parse_dict_input(input_data, target_type)
parsed_dict = _string_key_dict(input_data)
if parsed_dict is not None:
return _parse_dict_input(parsed_dict, target_type)
# Fallback: return original
return input_data
@@ -2,8 +2,11 @@
"""Discovery API models for entity information."""
from __future__ import annotations
import re
from typing import Any
from typing import Any, cast
from collections.abc import Callable
from pydantic import BaseModel, Field, field_validator
@@ -57,7 +60,7 @@ class EntityInfo(BaseModel):
class DiscoveryResponse(BaseModel):
"""Response model for entity discovery."""
entities: list[EntityInfo] = Field(default_factory=list)
entities: list[EntityInfo] = Field(default_factory=cast(Callable[..., list[EntityInfo]], list))
# ============================================================================
+1 -1
View File
@@ -94,7 +94,7 @@ include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_devui"
test = "pytest --cov=agent_framework_devui --cov-report=term-missing:skip-covered tests"
test = "pytest -m \"not integration\" --cov=agent_framework_devui --cov-report=term-missing:skip-covered tests"
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
@@ -206,9 +206,7 @@ class AgentEntity:
request_message=request_message,
)
run_callable = getattr(self.agent, "run", None)
if run_callable is None or not callable(run_callable):
raise AttributeError("Agent does not implement run() method")
run_callable = self.agent.run
# Try streaming first with run(stream=True)
try:
@@ -58,8 +58,8 @@ def ensure_response_format(
"""
if response_format is not None:
# Set the response format on the response so .value knows how to parse
response._response_format = response_format
response._value_parsed = False # Reset to allow re-parsing with new format
response._response_format = response_format # pyright: ignore[reportPrivateUsage]
response._value_parsed = False # pyright: ignore[reportPrivateUsage] # Reset to allow re-parsing with new format
# Access response.value to trigger parsing (may raise ValidationError)
# Validate that parsing succeeded
+3 -2
View File
@@ -73,6 +73,7 @@ omit = [
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_durabletask"]
[tool.mypy]
plugins = ['pydantic.mypy']
@@ -98,8 +99,8 @@ include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_durabletask"
test = "pytest --cov=agent_framework_durabletask --cov-report=term-missing:skip-covered tests"
test = "pytest -m \"not integration\" --cov=agent_framework_durabletask --cov-report=term-missing:skip-covered tests"
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
build-backend = "flit_core.buildapi"
@@ -248,18 +248,19 @@ class FoundryLocalClient(
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
model_id_setting: str = settings["model_id"] # type: ignore[assignment] # pyright: ignore[reportTypedDictNotRequiredAccess]
manager = FoundryLocalManager(bootstrap=bootstrap, timeout=timeout)
model_info = manager.get_model_info(
alias_or_model_id=settings["model_id"],
alias_or_model_id=model_id_setting,
device=device,
)
if model_info is None:
message = (
f"Model with ID or alias '{settings['model_id']}:{device.value}' not found in Foundry Local."
f"Model with ID or alias '{model_id_setting}:{device.value}' not found in Foundry Local."
if device
else (
f"Model with ID or alias '{settings['model_id']}' for your current device "
"not found in Foundry Local."
f"Model with ID or alias '{model_id_setting}' for your current device not found in Foundry Local."
)
)
raise ValueError(message)
+2 -1
View File
@@ -59,6 +59,7 @@ omit = [
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_foundry_local"]
exclude = ['tests']
[tool.mypy]
@@ -85,7 +86,7 @@ include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_foundry_local"
test = "pytest --cov=agent_framework_foundry_local --cov-report=term-missing:skip-covered tests"
test = "pytest -m \"not integration\" --cov=agent_framework_foundry_local --cov-report=term-missing:skip-covered tests"
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
@@ -7,7 +7,7 @@ import contextlib
import logging
import sys
from collections.abc import AsyncIterable, Awaitable, Callable, MutableMapping, Sequence
from typing import Any, ClassVar, Generic, Literal, TypedDict, overload
from typing import Any, ClassVar, Generic, Literal, TypedDict, cast, overload
from agent_framework import (
AgentMiddlewareTypes,
@@ -30,6 +30,7 @@ from copilot.generated.session_events import SessionEvent, SessionEventType
from copilot.types import (
CopilotClientOptions,
MCPServerConfig,
MessageOptions,
PermissionRequest,
PermissionRequestResult,
ResumeSessionConfig,
@@ -266,10 +267,13 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
if self._client is None:
client_options: CopilotClientOptions = {}
if self._settings["cli_path"]:
client_options["cli_path"] = self._settings["cli_path"]
if self._settings["log_level"]:
client_options["log_level"] = self._settings["log_level"] # type: ignore[typeddict-item]
cli_path = self._settings.get("cli_path")
if cli_path:
client_options["cli_path"] = cli_path
log_level = self._settings.get("log_level")
if log_level:
client_options["log_level"] = log_level # type: ignore[typeddict-item]
self._client = CopilotClient(client_options if client_options else None)
@@ -372,14 +376,15 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
session = self.create_session()
opts: dict[str, Any] = dict(options) if options else {}
timeout = opts.pop("timeout", None) or self._settings["timeout"] or DEFAULT_TIMEOUT_SECONDS
timeout = opts.pop("timeout", None) or self._settings.get("timeout") or DEFAULT_TIMEOUT_SECONDS
copilot_session = await self._get_or_create_session(session, streaming=False, runtime_options=opts)
input_messages = normalize_messages(messages)
prompt = "\n".join([message.text for message in input_messages])
message_options = cast(MessageOptions, {"prompt": prompt})
try:
response_event = await copilot_session.send_and_wait({"prompt": prompt}, timeout=timeout)
response_event = await copilot_session.send_and_wait(message_options, timeout=timeout)
except Exception as ex:
raise AgentException(f"GitHub Copilot request failed: {ex}") from ex
@@ -439,6 +444,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
copilot_session = await self._get_or_create_session(session, streaming=True, runtime_options=opts)
input_messages = normalize_messages(messages)
prompt = "\n".join([message.text for message in input_messages])
message_options = cast(MessageOptions, {"prompt": prompt})
queue: asyncio.Queue[AgentResponseUpdate | Exception | None] = asyncio.Queue()
@@ -462,7 +468,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
unsubscribe = copilot_session.on(event_handler)
try:
await copilot_session.send({"prompt": prompt})
await copilot_session.send(message_options)
while (item := await queue.get()) is not None:
if isinstance(item, Exception):
@@ -597,7 +603,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
opts = runtime_options or {}
config: SessionConfig = {"streaming": streaming}
model = opts.get("model") or self._settings["model"]
model = opts.get("model") or self._settings.get("model")
if model:
config["model"] = model # type: ignore[typeddict-item]
@@ -61,6 +61,7 @@ omit = [
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_github_copilot"]
[tool.mypy]
plugins = ['pydantic.mypy']
@@ -86,7 +87,7 @@ include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_github_copilot"
test = "pytest --cov=agent_framework_github_copilot --cov-report=term-missing:skip-covered tests"
test = "pytest -m \"not integration\" --cov=agent_framework_github_copilot --cov-report=term-missing:skip-covered tests"
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
@@ -13,7 +13,7 @@ import time
from collections.abc import Iterable
from datetime import datetime
from pathlib import Path
from typing import Any
from typing import Any, cast
from opentelemetry.trace import NoOpTracer, SpanKind, get_tracer
from tqdm import tqdm
@@ -163,7 +163,7 @@ def _normalize_str(s: str, remove_punct: bool = True) -> str:
return no_spaces.lower()
def gaia_scorer(model_answer: str, ground_truth: str) -> bool:
def gaia_scorer(model_answer: str | None, ground_truth: str) -> bool:
"""Official GAIA scoring function.
Args:
@@ -193,7 +193,7 @@ def gaia_scorer(model_answer: str, ground_truth: str) -> bool:
ma_elems = _split_string(model_answer)
if len(gt_elems) != len(ma_elems):
return False
comparisons = []
comparisons: list[bool] = []
for ma, gt in zip(ma_elems, gt_elems, strict=False):
if is_float(gt):
comparisons.append(abs(_normalize_number_str(ma) - float(gt)) < 1e-6)
@@ -204,18 +204,39 @@ def gaia_scorer(model_answer: str, ground_truth: str) -> bool:
return _normalize_str(model_answer) == _normalize_str(ground_truth)
def _coerce_record(raw: object) -> dict[str, Any] | None:
if isinstance(raw, dict):
raw_dict = cast(dict[object, Any], raw)
if all(isinstance(key, str) for key in raw_dict):
return cast(dict[str, Any], raw_dict)
return None
def _parse_level(level: object) -> int | None:
if isinstance(level, int):
return level
if isinstance(level, str) and level.isdigit():
return int(level)
return None
def _read_jsonl(path: Path) -> Iterable[dict[str, Any]]:
"""Read JSONL file and yield parsed records."""
with path.open("rb") as f:
for line in f:
if not line.strip():
continue
parsed: object
try:
import orjson
yield orjson.loads(line)
parsed = orjson.loads(line)
except Exception:
yield json.loads(line)
parsed = json.loads(line)
record = _coerce_record(parsed)
if record is not None:
yield record
def _load_gaia_local(repo_dir: Path, wanted_levels: list[int] | None = None, max_n: int | None = None) -> list[Task]:
@@ -232,41 +253,43 @@ def _load_gaia_local(repo_dir: Path, wanted_levels: list[int] | None = None, max
try:
import pyarrow.parquet as pq
table = pq.read_table(p)
for row in table.to_pylist():
pq_any = cast(Any, pq)
table: Any = pq_any.read_table(p)
rows = cast(list[object], table.to_pylist())
for row in rows:
record = _coerce_record(row)
if record is None:
continue
# Robustly extract fields used across variants
q = row.get("Question") or row.get("question") or row.get("query") or row.get("prompt")
ans = row.get("Final answer") or row.get("answer") or row.get("final_answer")
q_obj = record.get("Question") or record.get("question") or record.get("query") or record.get("prompt")
ans = record.get("Final answer") or record.get("answer") or record.get("final_answer")
if not isinstance(q_obj, str):
continue
q = q_obj
qid = str(
row.get("task_id")
or row.get("question_id")
or row.get("id")
or row.get("uuid")
record.get("task_id")
or record.get("question_id")
or record.get("id")
or record.get("uuid")
or f"{p.stem}:{len(tasks)}"
)
lvl = row.get("Level") or row.get("level")
# Convert level to int if it's a string
def _parse_level(lvl: Any) -> int | None:
"""Parse level value to integer if possible."""
if isinstance(lvl, int):
return lvl
if isinstance(lvl, str) and lvl.isdigit():
return int(lvl)
return None
lvl = _parse_level(lvl)
fname = row.get("file_name") or row.get("filename") or None
lvl = _parse_level(record.get("Level") or record.get("level"))
fname_obj = record.get("file_name") or record.get("filename")
fname = fname_obj if isinstance(fname_obj, str) else None
# Only evaluate examples with public answers (dev/validation split)
# Skip if no question, no answer, or answer is placeholder like "?"
if not q or ans is None or str(ans).strip() in ["?", ""]:
if ans is None or str(ans).strip() in ["?", ""]:
continue
if wanted_levels and (lvl not in wanted_levels):
continue
tasks.append(Task(task_id=qid, question=q, answer=str(ans), level=lvl, file_name=fname, metadata=row))
tasks.append(
Task(task_id=qid, question=q, answer=str(ans), level=lvl, file_name=fname, metadata=record)
)
except ImportError:
print("Warning: pyarrow not installed. Install with: pip install pyarrow")
continue
@@ -279,8 +302,12 @@ def _load_gaia_local(repo_dir: Path, wanted_levels: list[int] | None = None, max
for p in repo_dir.rglob("metadata.jsonl"):
for rec in _read_jsonl(p):
# Robustly extract fields used across variants
q = rec.get("Question") or rec.get("question") or rec.get("query") or rec.get("prompt")
q_obj = rec.get("Question") or rec.get("question") or rec.get("query") or rec.get("prompt")
ans = rec.get("Final answer") or rec.get("answer") or rec.get("final_answer")
if not isinstance(q_obj, str):
continue
q = q_obj
qid = str(
rec.get("task_id")
or rec.get("question_id")
@@ -288,15 +315,13 @@ def _load_gaia_local(repo_dir: Path, wanted_levels: list[int] | None = None, max
or rec.get("uuid")
or f"{p.stem}:{len(tasks)}"
)
lvl = rec.get("Level") or rec.get("level")
# Convert level to int if it's a string
if isinstance(lvl, str) and lvl.isdigit():
lvl = int(lvl)
fname = rec.get("file_name") or rec.get("filename") or None
lvl = _parse_level(rec.get("Level") or rec.get("level"))
fname_obj = rec.get("file_name") or rec.get("filename")
fname = fname_obj if isinstance(fname_obj, str) else None
# Only evaluate examples with public answers (dev/validation split)
# Skip if no question, no answer, or answer is placeholder like "?"
if not q or ans is None or str(ans).strip() in ["?", ""]:
if ans is None or str(ans).strip() in ["?", ""]:
continue
if wanted_levels and (lvl not in wanted_levels):
@@ -366,9 +391,10 @@ class GAIA:
"with access to gaia-benchmark/GAIA."
)
from huggingface_hub import snapshot_download
import huggingface_hub
local_dir = snapshot_download( # type: ignore
hf_hub = cast(Any, huggingface_hub)
local_dir = hf_hub.snapshot_download(
repo_id="gaia-benchmark/GAIA",
repo_type="dataset",
revision="682dd723ee1e1697e00360edccf2366dc8418dd9",
@@ -376,6 +402,8 @@ class GAIA:
local_dir=str(self.data_dir),
force_download=False,
)
if not isinstance(local_dir, str):
raise TypeError("snapshot_download returned unexpected non-string path")
return Path(local_dir)
async def _run_single_task(
@@ -522,7 +550,7 @@ class GAIA:
# Run tasks
semaphore = asyncio.Semaphore(parallel)
results = []
results: list[TaskResult] = []
tasks_coroutines = [self._run_single_task(task, task_runner, semaphore, timeout) for task in tasks]
@@ -561,7 +589,7 @@ class GAIA:
with open(output_path, "w", encoding="utf-8") as f:
for result in results:
# Convert messages to serializable format
serializable_messages = []
serializable_messages: list[dict[str, Any] | str] = []
if result.prediction.messages:
for msg in result.prediction.messages:
if hasattr(msg, "model_dump"):
@@ -569,7 +597,7 @@ class GAIA:
serializable_messages.append(msg.model_dump())
elif hasattr(msg, "__dict__"):
# Regular object with attributes
serializable_messages.append(vars(msg))
serializable_messages.append(cast(dict[str, Any], getattr(msg, "__dict__", {})))
else:
# Fallback to string representation
serializable_messages.append(str(msg))
@@ -614,16 +642,20 @@ def viewer_main() -> None:
args = parser.parse_args()
# Load results
results = []
results: list[dict[str, Any]] = []
with open(args.results_file, encoding="utf-8") as f:
for line in f:
if line.strip():
try:
import orjson
results.append(orjson.loads(line))
parsed: object = orjson.loads(line)
except ImportError:
results.append(json.loads(line))
parsed = json.loads(line)
record = _coerce_record(parsed)
if record is not None:
results.append(record)
# Apply filters
if args.level is not None:

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