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:
committed by
GitHub
Unverified
parent
4a043c6c66
commit
55ddd841b7
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user