mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
co-authored by
Copilot
parent
4a043c6c66
commit
55ddd841b7
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
+5
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 '"' in elem
|
||||
assert """ in elem
|
||||
|
||||
def test_xml_escapes_description(self) -> None:
|
||||
r = SkillResource(name="ref", description='Uses <tags> & "quotes"', content="data")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+54
-28
@@ -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:
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
+2
-2
@@ -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(
|
||||
|
||||
+55
-28
@@ -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]
|
||||
|
||||
+7
-6
@@ -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
|
||||
|
||||
+17
-15
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
+5
-4
@@ -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)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user