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

* Fix Python pyright package scoping and typing remediation

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

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

* Reduce pyright cost in handoff cloning

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

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

* fix types

* Fix lint and type-check regressions

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

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

* fixed hooks

* Stabilize package tests and test tasks

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

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

* lots of small fixes

* Fix current Python test regressions

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

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

* small fixes

* small fixes

* removed pydantic from json

* final updates

* fix core

* fix tests

* fix obser

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Eduard van Valkenburg
2026-03-05 16:32:24 +01:00
committed by GitHub
Unverified
parent 4a043c6c66
commit 55ddd841b7
122 changed files with 2328 additions and 2407 deletions
@@ -33,24 +33,25 @@ import inspect
import json
import logging
import sys
from collections.abc import Awaitable, Callable, Sequence
from collections.abc import Awaitable, Callable, Mapping, Sequence
from copy import deepcopy
from dataclasses import dataclass
from typing import Any, cast
from typing import Any
from agent_framework import Agent, SupportsAgentRun
from agent_framework._middleware import FunctionInvocationContext, FunctionMiddleware
from agent_framework._sessions import AgentSession
from agent_framework._tools import FunctionTool, tool
from agent_framework._types import AgentResponse, AgentResponseUpdate, Content, Message
from agent_framework._workflows._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse
from agent_framework._types import AgentResponse, Content, Message
from agent_framework._workflows._agent_executor import AgentExecutor, AgentExecutorRequest
from agent_framework._workflows._agent_utils import resolve_agent_id
from agent_framework._workflows._checkpoint import CheckpointStorage
from agent_framework._workflows._events import WorkflowEvent
from agent_framework._workflows._request_info_mixin import response_handler
from agent_framework._workflows._typing_utils import is_chat_agent
from agent_framework._workflows._workflow import Workflow
from agent_framework._workflows._workflow_builder import WorkflowBuilder
from agent_framework._workflows._workflow_context import WorkflowContext
from typing_extensions import Never
from ._base_group_chat_orchestrator import TerminationCondition
from ._orchestrator_helpers import clean_conversation_for_handoff
@@ -252,9 +253,8 @@ class HandoffAgentExecutor(AgentExecutor):
Returns:
A cloned ``Agent`` instance with handoff tools added
"""
# Clone the agent to avoid mutating the original
cloned_agent = self._clone_chat_agent(agent) # type: ignore
cloned_agent = self._clone_chat_agent(agent)
# Add handoff tools to the cloned agent
self._apply_auto_tools(cloned_agent, handoffs)
# Add middleware to handle handoff tool invocations
@@ -347,46 +347,26 @@ class HandoffAgentExecutor(AgentExecutor):
)
)
def _clone_chat_agent(self, agent: Agent) -> Agent:
def _clone_chat_agent(self, agent: Agent[Any]) -> Agent[Any]:
"""Produce a deep copy of the Agent while preserving runtime configuration."""
options = agent.default_options
middleware = list(agent.middleware or [])
# Reconstruct the original tools list by combining regular tools with MCP tools.
# Agent.__init__ separates MCP tools during initialization,
# so we need to recombine them here to pass the complete tools list to the constructor.
# This makes sure MCP tools are preserved when cloning agents for handoff workflows.
tools_from_options = options.get("tools")
all_tools = list(tools_from_options) if tools_from_options else []
if agent.mcp_tools:
all_tools.extend(agent.mcp_tools)
logit_bias = options.get("logit_bias")
metadata = options.get("metadata")
tools_from_options = options.pop("tools", [])
new_tools = [*tools_from_options, *(agent.mcp_tools if agent.mcp_tools else [])]
# this ensures all options (including custom ones) are kept
cloned_options = deepcopy(options)
# Disable parallel tool calls to prevent the agent from invoking multiple handoff tools at once.
cloned_options: dict[str, Any] = {
"allow_multiple_tool_calls": False,
# Handoff workflows already manage full conversation context explicitly
# across executors. Keep provider-side conversation storage disabled to
# avoid stale tool-call state (Responses API previous_response chains).
"store": False,
"frequency_penalty": options.get("frequency_penalty"),
"instructions": options.get("instructions"),
"logit_bias": dict(logit_bias) if logit_bias else None,
"max_tokens": options.get("max_tokens"),
"metadata": dict(metadata) if metadata else None,
"model_id": options.get("model_id"),
"presence_penalty": options.get("presence_penalty"),
"response_format": options.get("response_format"),
"seed": options.get("seed"),
"stop": options.get("stop"),
"temperature": options.get("temperature"),
"tool_choice": options.get("tool_choice"),
"tools": all_tools if all_tools else None,
"top_p": options.get("top_p"),
"user": options.get("user"),
}
cloned_options["allow_multiple_tool_calls"] = False
cloned_options["store"] = False
cloned_options["tools"] = new_tools
# restore the original tools, in case they are shared between agents
options["tools"] = tools_from_options
return Agent(
client=agent.client,
@@ -394,8 +374,8 @@ class HandoffAgentExecutor(AgentExecutor):
name=agent.name,
description=agent.description,
context_providers=agent.context_providers,
middleware=middleware,
default_options=cloned_options, # type: ignore[arg-type]
middleware=agent.agent_middleware,
default_options=cloned_options, # type: ignore[assignment]
)
def _apply_auto_tools(self, agent: Agent, targets: Sequence[HandoffConfiguration]) -> None:
@@ -445,9 +425,7 @@ class HandoffAgentExecutor(AgentExecutor):
return _handoff_tool
@override
async def _run_agent_and_emit(
self, ctx: WorkflowContext[AgentExecutorResponse, AgentResponse | AgentResponseUpdate]
) -> None:
async def _run_agent_and_emit(self, ctx: WorkflowContext[Any, Any]) -> None:
"""Override to support handoff."""
incoming_messages = list(self._cache)
cleaned_incoming_messages = clean_conversation_for_handoff(incoming_messages)
@@ -469,7 +447,7 @@ class HandoffAgentExecutor(AgentExecutor):
# Broadcast the initial cache to all other agents. Subsequent runs won't
# need this since responses are broadcast after each agent run and user input.
if self._is_start_agent and not self._full_conversation:
await self._broadcast_messages(cleaned_incoming_messages, cast(WorkflowContext[AgentExecutorRequest], ctx))
await self._broadcast_messages(cleaned_incoming_messages, ctx)
# Persist only cleaned chat history between turns to avoid replaying stale tool calls.
self._full_conversation.extend(cleaned_incoming_messages)
@@ -483,29 +461,30 @@ class HandoffAgentExecutor(AgentExecutor):
# If an existing session still has a service conversation id, clear it to avoid
# replaying stale unresolved tool calls across resumed turns.
if (
cast(Agent, self._agent).default_options.get("store") is False
is_chat_agent(self._agent)
and self._agent.default_options.get("store") is False
and self._session.service_session_id is not None
):
self._session.service_session_id = None
# Check termination condition before running the agent
if await self._check_terminate_and_yield(cast(WorkflowContext[Never, list[Message]], ctx)):
if await self._check_terminate_and_yield(ctx):
return
# Run the agent
if ctx.is_streaming():
# Streaming mode: emit incremental updates
response = await self._run_agent_streaming(cast(WorkflowContext[Never, AgentResponseUpdate], ctx))
response = await self._run_agent_streaming(ctx)
else:
# Non-streaming mode: use run() and emit single event
response = await self._run_agent(cast(WorkflowContext[Never, AgentResponse], ctx))
response = await self._run_agent(ctx)
# Clear the cache after running the agent
self._cache.clear()
# A function approval request is issued by the base AgentExecutor
if response is None:
if cast(Agent, self._agent).default_options.get("store") is False:
if is_chat_agent(self._agent) and self._agent.default_options.get("store") is False:
self._persist_pending_approval_function_calls()
# Agent did not complete (e.g., waiting for user input); do not emit response
logger.debug("AgentExecutor %s: Agent did not complete, awaiting user input", self.id)
@@ -525,7 +504,7 @@ class HandoffAgentExecutor(AgentExecutor):
)
# Broadcast only the cleaned response to other agents (without function_calls/results)
await self._broadcast_messages(cleaned_response, cast(WorkflowContext[AgentExecutorRequest], ctx))
await self._broadcast_messages(cleaned_response, ctx)
# Check if a handoff was requested
if handoff_target := self._is_handoff_requested(response):
@@ -535,7 +514,7 @@ class HandoffAgentExecutor(AgentExecutor):
f"target '{handoff_target}'. Valid targets are: {', '.join(self._handoff_targets)}"
)
await cast(WorkflowContext[AgentExecutorRequest], ctx).send_message(
await ctx.send_message(
AgentExecutorRequest(messages=[], should_respond=True),
target_id=handoff_target,
)
@@ -548,7 +527,7 @@ class HandoffAgentExecutor(AgentExecutor):
# Re-evaluate termination after appending and broadcasting this response.
# Without this check, workflows that become terminal due to the latest assistant
# message would still emit request_info and require an unnecessary extra resume.
if await self._check_terminate_and_yield(cast(WorkflowContext[Never, list[Message]], ctx)):
if await self._check_terminate_and_yield(ctx):
return
# Handle case where no handoff was requested
@@ -570,7 +549,7 @@ class HandoffAgentExecutor(AgentExecutor):
self,
original_request: HandoffAgentUserRequest,
response: list[Message],
ctx: WorkflowContext[AgentExecutorResponse, AgentResponse],
ctx: WorkflowContext[Any, Any],
) -> None:
"""Handle user response for a request that is issued after agent runs.
@@ -588,22 +567,20 @@ class HandoffAgentExecutor(AgentExecutor):
If the response is empty, it indicates termination of the handoff workflow.
"""
if not response:
await cast(WorkflowContext[Never, list[Message]], ctx).yield_output(self._full_conversation)
await ctx.yield_output(self._full_conversation)
return
# Broadcast the user response to all other agents
await self._broadcast_messages(response, cast(WorkflowContext[AgentExecutorRequest], ctx))
await self._broadcast_messages(response, ctx)
# Append the user response messages to the cache
self._cache.extend(response)
await self._run_agent_and_emit(
cast(WorkflowContext[AgentExecutorResponse, AgentResponse | AgentResponseUpdate], ctx)
)
await self._run_agent_and_emit(ctx)
async def _broadcast_messages(
self,
messages: list[Message],
ctx: WorkflowContext[AgentExecutorRequest],
ctx: WorkflowContext[Any, Any],
) -> None:
"""Broadcast the workflow cache to the agent before running."""
agent_executor_request = AgentExecutorRequest(
@@ -628,15 +605,15 @@ class HandoffAgentExecutor(AgentExecutor):
if content.type == "function_result":
payload = content.result
parsed_payload: dict[str, Any] | None = None
if isinstance(payload, dict):
parsed_payload = payload
if isinstance(payload, Mapping):
parsed_payload = {key: value for key, value in payload.items() if isinstance(key, str)} # pyright: ignore[reportUnknownVariableType]
elif isinstance(payload, str):
try:
maybe_payload = json.loads(payload)
except json.JSONDecodeError:
maybe_payload = None
if isinstance(maybe_payload, dict):
parsed_payload = maybe_payload
if isinstance(maybe_payload, Mapping):
parsed_payload = {key: value for key, value in maybe_payload.items() if isinstance(key, str)} # pyright: ignore[reportUnknownVariableType]
if parsed_payload:
handoff_target = parsed_payload.get(HANDOFF_FUNCTION_RESULT_KEY)
@@ -647,7 +624,7 @@ class HandoffAgentExecutor(AgentExecutor):
return None
async def _check_terminate_and_yield(self, ctx: WorkflowContext[Never, list[Message]]) -> bool:
async def _check_terminate_and_yield(self, ctx: WorkflowContext[Any, Any]) -> bool:
"""Check termination conditions and yield completion if met.
Args:
@@ -58,6 +58,7 @@ omit = [
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_orchestrations"]
exclude = ['tests']
[tool.mypy]
@@ -84,7 +85,7 @@ include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_orchestrations"
test = "pytest --cov=agent_framework_orchestrations --cov-report=term-missing:skip-covered -n auto --dist worksteal tests"
test = "pytest -m \"not integration\" --cov=agent_framework_orchestrations --cov-report=term-missing:skip-covered -n auto --dist worksteal tests"
[build-system]
requires = ["flit-core >= 3.11,<4.0"]