Python: Flow custom kwargs to agents via Workflow SharedState (#2894)

* Flow custom kwargs to agents via SharedState

* Address Copilot feedback

* Improve sample typing

* Fix test
This commit is contained in:
Evan Mattson
2025-12-17 09:04:00 +09:00
committed by GitHub
Unverified
parent 8fca71e5ad
commit 6adcac2e97
8 changed files with 921 additions and 17 deletions
@@ -11,6 +11,7 @@ from .._agents import AgentProtocol, ChatAgent
from .._threads import AgentThread
from .._types import AgentRunResponse, AgentRunResponseUpdate, ChatMessage
from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
from ._const import WORKFLOW_RUN_KWARGS_KEY
from ._conversation_state import encode_chat_messages
from ._events import (
AgentRunEvent,
@@ -309,9 +310,12 @@ class AgentExecutor(Executor):
Returns:
The complete AgentRunResponse, or None if waiting for user input.
"""
run_kwargs: dict[str, Any] = await ctx.get_shared_state(WORKFLOW_RUN_KWARGS_KEY)
response = await self._agent.run(
self._cache,
thread=self._agent_thread,
**run_kwargs,
)
await ctx.add_event(AgentRunEvent(self.id, response))
@@ -333,11 +337,14 @@ class AgentExecutor(Executor):
Returns:
The complete AgentRunResponse, or None if waiting for user input.
"""
run_kwargs: dict[str, Any] = await ctx.get_shared_state(WORKFLOW_RUN_KWARGS_KEY)
updates: list[AgentRunResponseUpdate] = []
user_input_requests: list[FunctionApprovalRequestContent] = []
async for update in self._agent.run_stream(
self._cache,
thread=self._agent_thread,
**run_kwargs,
):
updates.append(update)
await ctx.add_event(AgentRunUpdateEvent(self.id, update))
@@ -9,6 +9,11 @@ EXECUTOR_STATE_KEY = "_executor_state"
# Source identifier for internal workflow messages.
INTERNAL_SOURCE_PREFIX = "internal"
# SharedState key for storing run kwargs that should be passed to agent invocations.
# Used by all orchestration patterns (Sequential, Concurrent, GroupChat, Handoff, Magentic)
# to pass kwargs from workflow.run_stream() through to agent.run_stream() and @ai_function tools.
WORKFLOW_RUN_KWARGS_KEY = "_workflow_run_kwargs"
def INTERNAL_SOURCE_ID(executor_id: str) -> str:
"""Generate an internal source ID for a given executor."""
@@ -25,7 +25,7 @@ from agent_framework import (
from ._base_group_chat_orchestrator import BaseGroupChatOrchestrator
from ._checkpoint import CheckpointStorage, WorkflowCheckpoint
from ._const import EXECUTOR_STATE_KEY
from ._const import EXECUTOR_STATE_KEY, WORKFLOW_RUN_KWARGS_KEY
from ._events import AgentRunUpdateEvent, WorkflowEvent
from ._executor import Executor, handler
from ._group_chat import (
@@ -286,12 +286,14 @@ class _MagenticStartMessage(DictConvertible):
"""Internal: A message to start a magentic workflow."""
messages: list[ChatMessage] = field(default_factory=_new_chat_message_list)
run_kwargs: dict[str, Any] = field(default_factory=dict)
def __init__(
self,
messages: str | ChatMessage | Sequence[str] | Sequence[ChatMessage] | None = None,
*,
task: ChatMessage | None = None,
run_kwargs: dict[str, Any] | None = None,
) -> None:
normalized = normalize_messages_input(messages)
if task is not None:
@@ -299,6 +301,7 @@ class _MagenticStartMessage(DictConvertible):
if not normalized:
raise ValueError("MagenticStartMessage requires at least one message input.")
self.messages: list[ChatMessage] = normalized
self.run_kwargs: dict[str, Any] = run_kwargs or {}
@property
def task(self) -> ChatMessage:
@@ -1179,6 +1182,10 @@ class MagenticOrchestratorExecutor(BaseGroupChatOrchestrator):
return
logger.info("Magentic Orchestrator: Received start message")
# Store run_kwargs in SharedState so agent executors can access them
# Always store (even empty dict) so retrieval is deterministic
await context.set_shared_state(WORKFLOW_RUN_KWARGS_KEY, message.run_kwargs or {})
self._context = MagenticContext(
task=message.task,
participant_descriptions=self._participants,
@@ -2004,10 +2011,12 @@ class MagenticAgentExecutor(Executor):
"""
logger.debug(f"Agent {self._agent_id}: Running with {len(self._chat_history)} messages")
run_kwargs: dict[str, Any] = await ctx.get_shared_state(WORKFLOW_RUN_KWARGS_KEY)
updates: list[AgentRunResponseUpdate] = []
# The wrapped participant is guaranteed to be an BaseAgent when this is called.
agent = cast("AgentProtocol", self._agent)
async for update in agent.run_stream(messages=self._chat_history): # type: ignore[attr-defined]
async for update in agent.run_stream(messages=self._chat_history, **run_kwargs): # type: ignore[attr-defined]
updates.append(update)
await self._emit_agent_delta_event(ctx, update)
@@ -2604,38 +2613,48 @@ class MagenticWorkflow:
"""Access the underlying workflow."""
return self._workflow
async def run_streaming_with_string(self, task_text: str) -> AsyncIterable[WorkflowEvent]:
async def run_streaming_with_string(self, task_text: str, **kwargs: Any) -> AsyncIterable[WorkflowEvent]:
"""Run the workflow with a task string.
Args:
task_text: The task description as a string.
**kwargs: Additional keyword arguments to pass through to agent invocations.
These kwargs will be available in @ai_function tools via **kwargs.
Yields:
WorkflowEvent: The events generated during the workflow execution.
"""
start_message = _MagenticStartMessage.from_string(task_text)
start_message.run_kwargs = kwargs
async for event in self._workflow.run_stream(start_message):
yield event
async def run_streaming_with_message(self, task_message: ChatMessage) -> AsyncIterable[WorkflowEvent]:
async def run_streaming_with_message(
self, task_message: ChatMessage, **kwargs: Any
) -> AsyncIterable[WorkflowEvent]:
"""Run the workflow with a ChatMessage.
Args:
task_message: The task as a ChatMessage.
**kwargs: Additional keyword arguments to pass through to agent invocations.
These kwargs will be available in @ai_function tools via **kwargs.
Yields:
WorkflowEvent: The events generated during the workflow execution.
"""
start_message = _MagenticStartMessage(task_message)
start_message = _MagenticStartMessage(task_message, run_kwargs=kwargs)
async for event in self._workflow.run_stream(start_message):
yield event
async def run_stream(self, message: Any | None = None) -> AsyncIterable[WorkflowEvent]:
async def run_stream(self, message: Any | None = None, **kwargs: Any) -> AsyncIterable[WorkflowEvent]:
"""Run the workflow with either a message object or the preset task string.
Args:
message: The message to send. If None and task_text was provided during construction,
uses the preset task string.
**kwargs: Additional keyword arguments to pass through to agent invocations.
These kwargs will be available in @ai_function tools via **kwargs.
Example: workflow.run_stream("task", user_id="123", custom_data={...})
Yields:
WorkflowEvent: The events generated during the workflow execution.
@@ -2643,13 +2662,19 @@ class MagenticWorkflow:
if message is None:
if self._task_text is None:
raise ValueError("No message provided and no preset task text available")
message = _MagenticStartMessage.from_string(self._task_text)
start_message = _MagenticStartMessage.from_string(self._task_text)
elif isinstance(message, str):
message = _MagenticStartMessage.from_string(message)
start_message = _MagenticStartMessage.from_string(message)
elif isinstance(message, (ChatMessage, list)):
message = _MagenticStartMessage(message) # type: ignore[arg-type]
start_message = _MagenticStartMessage(message) # type: ignore[arg-type]
else:
start_message = message
async for event in self._workflow.run_stream(message):
# Attach kwargs to the start message
if isinstance(start_message, _MagenticStartMessage):
start_message.run_kwargs = kwargs
async for event in self._workflow.run_stream(start_message):
yield event
async def _validate_checkpoint_participants(
@@ -2730,46 +2755,49 @@ class MagenticWorkflow:
f"Missing names: {missing}; unexpected names: {unexpected}."
)
async def run_with_string(self, task_text: str) -> WorkflowRunResult:
async def run_with_string(self, task_text: str, **kwargs: Any) -> WorkflowRunResult:
"""Run the workflow with a task string and return all events.
Args:
task_text: The task description as a string.
**kwargs: Additional keyword arguments to pass through to agent invocations.
Returns:
WorkflowRunResult: All events generated during the workflow execution.
"""
events: list[WorkflowEvent] = []
async for event in self.run_streaming_with_string(task_text):
async for event in self.run_streaming_with_string(task_text, **kwargs):
events.append(event)
return WorkflowRunResult(events)
async def run_with_message(self, task_message: ChatMessage) -> WorkflowRunResult:
async def run_with_message(self, task_message: ChatMessage, **kwargs: Any) -> WorkflowRunResult:
"""Run the workflow with a ChatMessage and return all events.
Args:
task_message: The task as a ChatMessage.
**kwargs: Additional keyword arguments to pass through to agent invocations.
Returns:
WorkflowRunResult: All events generated during the workflow execution.
"""
events: list[WorkflowEvent] = []
async for event in self.run_streaming_with_message(task_message):
async for event in self.run_streaming_with_message(task_message, **kwargs):
events.append(event)
return WorkflowRunResult(events)
async def run(self, message: Any | None = None) -> WorkflowRunResult:
async def run(self, message: Any | None = None, **kwargs: Any) -> WorkflowRunResult:
"""Run the workflow and return all events.
Args:
message: The message to send. If None and task_text was provided during construction,
uses the preset task string.
**kwargs: Additional keyword arguments to pass through to agent invocations.
Returns:
WorkflowRunResult: All events generated during the workflow execution.
"""
events: list[WorkflowEvent] = []
async for event in self.run_stream(message):
async for event in self.run_stream(message, **kwargs):
events.append(event)
return WorkflowRunResult(events)
@@ -13,7 +13,7 @@ from typing import Any
from ..observability import OtelAttr, capture_exception, create_workflow_span
from ._agent import WorkflowAgent
from ._checkpoint import CheckpointStorage
from ._const import DEFAULT_MAX_ITERATIONS
from ._const import DEFAULT_MAX_ITERATIONS, WORKFLOW_RUN_KWARGS_KEY
from ._edge import (
EdgeGroup,
FanOutEdgeGroup,
@@ -291,6 +291,7 @@ class Workflow(DictConvertible):
initial_executor_fn: Callable[[], Awaitable[None]] | None = None,
reset_context: bool = True,
streaming: bool = False,
run_kwargs: dict[str, Any] | None = None,
) -> AsyncIterable[WorkflowEvent]:
"""Private method to run workflow with proper tracing.
@@ -301,6 +302,7 @@ class Workflow(DictConvertible):
initial_executor_fn: Optional function to execute initial executor
reset_context: Whether to reset the context for a new run
streaming: Whether to enable streaming mode for agents
run_kwargs: Optional kwargs to store in SharedState for agent invocations
Yields:
WorkflowEvent: The events generated during the workflow execution.
@@ -335,6 +337,10 @@ class Workflow(DictConvertible):
self._runner.context.reset_for_new_run()
await self._shared_state.clear()
# Store run kwargs in SharedState so executors can access them
# Always store (even empty dict) so retrieval is deterministic
await self._shared_state.set(WORKFLOW_RUN_KWARGS_KEY, run_kwargs or {})
# Set streaming mode after reset
self._runner_context.set_streaming(streaming)
@@ -442,6 +448,7 @@ class Workflow(DictConvertible):
*,
checkpoint_id: str | None = None,
checkpoint_storage: CheckpointStorage | None = None,
**kwargs: Any,
) -> AsyncIterable[WorkflowEvent]:
"""Run the workflow and stream events.
@@ -457,6 +464,9 @@ class Workflow(DictConvertible):
- With checkpoint_id: Used to load and restore the specified checkpoint
- Without checkpoint_id: Enables checkpointing for this run, overriding
build-time configuration
**kwargs: Additional keyword arguments to pass through to agent invocations.
These are stored in SharedState and accessible in @ai_function tools
via the **kwargs parameter.
Yields:
WorkflowEvent: Events generated during workflow execution.
@@ -475,6 +485,17 @@ class Workflow(DictConvertible):
async for event in workflow.run_stream("start message"):
process(event)
With custom context for ai_functions:
.. code-block:: python
async for event in workflow.run_stream(
"analyze data",
custom_data={"endpoint": "https://api.example.com"},
user_token={"user": "alice"},
):
process(event)
Enable checkpointing at runtime:
.. code-block:: python
@@ -524,6 +545,7 @@ class Workflow(DictConvertible):
),
reset_context=reset_context,
streaming=True,
run_kwargs=kwargs if kwargs else None,
):
yield event
finally:
@@ -559,6 +581,7 @@ class Workflow(DictConvertible):
checkpoint_id: str | None = None,
checkpoint_storage: CheckpointStorage | None = None,
include_status_events: bool = False,
**kwargs: Any,
) -> WorkflowRunResult:
"""Run the workflow to completion and return all events.
@@ -575,6 +598,9 @@ class Workflow(DictConvertible):
- Without checkpoint_id: Enables checkpointing for this run, overriding
build-time configuration
include_status_events: Whether to include WorkflowStatusEvent instances in the result list.
**kwargs: Additional keyword arguments to pass through to agent invocations.
These are stored in SharedState and accessible in @ai_function tools
via the **kwargs parameter.
Returns:
A WorkflowRunResult instance containing events generated during workflow execution.
@@ -593,6 +619,16 @@ class Workflow(DictConvertible):
result = await workflow.run("start message")
outputs = result.get_outputs()
With custom context for ai_functions:
.. code-block:: python
result = await workflow.run(
"analyze data",
custom_data={"endpoint": "https://api.example.com"},
user_token={"user": "alice"},
)
Enable checkpointing at runtime:
.. code-block:: python
@@ -637,6 +673,7 @@ class Workflow(DictConvertible):
self._execute_with_message_or_checkpoint, message, checkpoint_id, checkpoint_storage
),
reset_context=reset_context,
run_kwargs=kwargs if kwargs else None,
)
]
finally:
@@ -876,3 +876,204 @@ def test_magentic_builder_does_not_have_human_input_hook():
"MagenticBuilder should not have with_human_input_hook - "
"use with_plan_review() or with_human_input_on_stall() instead"
)
# region Message Deduplication Tests
async def test_magentic_no_duplicate_messages_with_conversation_history():
"""Test that passing list[ChatMessage] does not create duplicate messages in chat_history.
When a frontend passes conversation history as list[ChatMessage], the last message
(task) should not be duplicated in the orchestrator's chat_history.
"""
manager = FakeManager(max_round_count=10)
manager.satisfied_after_signoff = True # Complete immediately after first agent response
wf = MagenticBuilder().participants(agentA=_DummyExec("agentA")).with_standard_manager(manager).build()
# Simulate frontend passing conversation history
conversation: list[ChatMessage] = [
ChatMessage(role=Role.USER, text="previous question"),
ChatMessage(role=Role.ASSISTANT, text="previous answer"),
ChatMessage(role=Role.USER, text="current task"),
]
# Get orchestrator to inspect chat_history after run
orchestrator = None
for executor in wf.executors.values():
if isinstance(executor, MagenticOrchestratorExecutor):
orchestrator = executor
break
events: list[WorkflowEvent] = []
async for event in wf.run_stream(conversation):
events.append(event)
if isinstance(event, WorkflowStatusEvent) and event.state in (
WorkflowRunState.IDLE,
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
):
break
assert orchestrator is not None
assert orchestrator._context is not None # type: ignore[reportPrivateUsage]
# Count occurrences of each message text in chat_history
history = orchestrator._context.chat_history # type: ignore[reportPrivateUsage]
user_task_count = sum(1 for msg in history if msg.text == "current task")
prev_question_count = sum(1 for msg in history if msg.text == "previous question")
prev_answer_count = sum(1 for msg in history if msg.text == "previous answer")
# Each input message should appear exactly once (no duplicates)
assert prev_question_count == 1, f"Expected 1 'previous question', got {prev_question_count}"
assert prev_answer_count == 1, f"Expected 1 'previous answer', got {prev_answer_count}"
assert user_task_count == 1, f"Expected 1 'current task', got {user_task_count}"
async def test_magentic_agent_executor_no_duplicate_messages_on_broadcast():
"""Test that MagenticAgentExecutor does not duplicate messages from broadcasts.
When the orchestrator broadcasts the task ledger to all agents, each agent
should receive it exactly once, not multiple times.
"""
backing_executor = _DummyExec("backing")
agent_exec = MagenticAgentExecutor(backing_executor, "agentA")
# Simulate orchestrator sending a broadcast message
broadcast_msg = ChatMessage(
role=Role.ASSISTANT,
text="Task ledger content",
author_name="magentic_manager",
)
# Simulate the same message being received multiple times (e.g., from checkpoint restore + live)
from agent_framework._workflows._magentic import _MagenticResponseMessage
response1 = _MagenticResponseMessage(body=broadcast_msg, broadcast=True)
response2 = _MagenticResponseMessage(body=broadcast_msg, broadcast=True)
# Create a mock context
from unittest.mock import AsyncMock, MagicMock
mock_context = MagicMock()
mock_context.send_message = AsyncMock()
# Call the handler twice with the same message
await agent_exec.handle_response_message(response1, mock_context) # type: ignore[arg-type]
await agent_exec.handle_response_message(response2, mock_context) # type: ignore[arg-type]
# Count how many times the broadcast message appears
history = agent_exec._chat_history # type: ignore[reportPrivateUsage]
broadcast_count = sum(1 for msg in history if msg.text == "Task ledger content")
# Each broadcast should be recorded (this is expected behavior - broadcasts are additive)
# The test documents current behavior. If dedup is needed, this assertion would change.
assert broadcast_count == 2, (
f"Expected 2 broadcasts (current behavior is additive), got {broadcast_count}. "
"If deduplication is required, update the handler logic."
)
async def test_magentic_context_no_duplicate_on_reset():
"""Test that MagenticContext.reset() clears chat_history without leaving duplicates."""
ctx = MagenticContext(
task=ChatMessage(role=Role.USER, text="task"),
participant_descriptions={"Alice": "Researcher"},
)
# Add some history
ctx.chat_history.append(ChatMessage(role=Role.ASSISTANT, text="response1"))
ctx.chat_history.append(ChatMessage(role=Role.ASSISTANT, text="response2"))
assert len(ctx.chat_history) == 2
# Reset
ctx.reset()
# Verify clean slate
assert len(ctx.chat_history) == 0, "chat_history should be empty after reset"
# Add new history
ctx.chat_history.append(ChatMessage(role=Role.ASSISTANT, text="new_response"))
assert len(ctx.chat_history) == 1, "Should have exactly 1 message after adding to reset context"
async def test_magentic_start_message_messages_list_integrity():
"""Test that _MagenticStartMessage preserves message list without internal duplication."""
conversation: list[ChatMessage] = [
ChatMessage(role=Role.USER, text="msg1"),
ChatMessage(role=Role.ASSISTANT, text="msg2"),
ChatMessage(role=Role.USER, text="msg3"),
]
start_msg = _MagenticStartMessage(conversation)
# Verify messages list is preserved
assert len(start_msg.messages) == 3, f"Expected 3 messages, got {len(start_msg.messages)}"
# Verify task is the last message (not a copy)
assert start_msg.task is start_msg.messages[-1], "task should be the same object as messages[-1]"
assert start_msg.task.text == "msg3"
async def test_magentic_checkpoint_restore_no_duplicate_history():
"""Test that checkpoint restore does not create duplicate messages in chat_history."""
manager = FakeManager(max_round_count=10)
storage = InMemoryCheckpointStorage()
wf = (
MagenticBuilder()
.participants(agentA=_DummyExec("agentA"))
.with_standard_manager(manager)
.with_checkpointing(storage)
.build()
)
# Run with conversation history to create initial checkpoint
conversation: list[ChatMessage] = [
ChatMessage(role=Role.USER, text="history_msg"),
ChatMessage(role=Role.USER, text="task_msg"),
]
async for event in wf.run_stream(conversation):
if isinstance(event, WorkflowStatusEvent) and event.state in (
WorkflowRunState.IDLE,
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
):
break
# Get checkpoint
checkpoints = await storage.list_checkpoints()
assert len(checkpoints) > 0, "Should have created checkpoints"
latest_checkpoint = checkpoints[-1]
# Load checkpoint and verify no duplicates in shared state
checkpoint_data = await storage.load_checkpoint(latest_checkpoint.checkpoint_id)
assert checkpoint_data is not None
# Check the magentic_context in the checkpoint
for _, executor_state in checkpoint_data.metadata.items():
if isinstance(executor_state, dict) and "magentic_context" in executor_state:
ctx_data = executor_state["magentic_context"]
chat_history = ctx_data.get("chat_history", [])
# Count unique messages by text
texts = [
msg.get("text") or (msg.get("contents", [{}])[0].get("text") if msg.get("contents") else None)
for msg in chat_history
]
text_counts: dict[str, int] = {}
for text in texts:
if text:
text_counts[text] = text_counts.get(text, 0) + 1
# Input messages should not be duplicated
assert text_counts.get("history_msg", 0) <= 1, (
f"'history_msg' appears {text_counts.get('history_msg', 0)} times in checkpoint - expected <= 1"
)
assert text_counts.get("task_msg", 0) <= 1, (
f"'task_msg' appears {text_counts.get('task_msg', 0)} times in checkpoint - expected <= 1"
)
# endregion
@@ -0,0 +1,492 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import AsyncIterable
from typing import Annotated, Any
from agent_framework import (
AgentRunResponse,
AgentRunResponseUpdate,
AgentThread,
BaseAgent,
ChatMessage,
ConcurrentBuilder,
GroupChatBuilder,
GroupChatStateSnapshot,
HandoffBuilder,
Role,
SequentialBuilder,
TextContent,
WorkflowRunState,
WorkflowStatusEvent,
ai_function,
)
from agent_framework._workflows._const import WORKFLOW_RUN_KWARGS_KEY
# Track kwargs received by tools during test execution
_received_kwargs: list[dict[str, Any]] = []
def _reset_received_kwargs() -> None:
"""Reset the kwargs tracker before each test."""
_received_kwargs.clear()
@ai_function
def tool_with_kwargs(
action: Annotated[str, "The action to perform"],
**kwargs: Any,
) -> str:
"""A test tool that captures kwargs for verification."""
_received_kwargs.append(dict(kwargs))
custom_data = kwargs.get("custom_data", {})
user_token = kwargs.get("user_token", {})
return f"Executed {action} with custom_data={custom_data}, user={user_token.get('user_name', 'unknown')}"
class _KwargsCapturingAgent(BaseAgent):
"""Test agent that captures kwargs passed to run/run_stream."""
captured_kwargs: list[dict[str, Any]]
def __init__(self, name: str = "test_agent") -> None:
super().__init__(name=name, description="Test agent for kwargs capture")
self.captured_kwargs = []
async def run(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
**kwargs: Any,
) -> AgentRunResponse:
self.captured_kwargs.append(dict(kwargs))
return AgentRunResponse(messages=[ChatMessage(role=Role.ASSISTANT, text=f"{self.display_name} response")])
async def run_stream(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
**kwargs: Any,
) -> AsyncIterable[AgentRunResponseUpdate]:
self.captured_kwargs.append(dict(kwargs))
yield AgentRunResponseUpdate(contents=[TextContent(text=f"{self.display_name} response")])
class _EchoAgent(BaseAgent):
"""Simple agent that echoes back for workflow completion."""
async def run(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
**kwargs: Any,
) -> AgentRunResponse:
return AgentRunResponse(messages=[ChatMessage(role=Role.ASSISTANT, text=f"{self.display_name} reply")])
async def run_stream(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
**kwargs: Any,
) -> AsyncIterable[AgentRunResponseUpdate]:
yield AgentRunResponseUpdate(contents=[TextContent(text=f"{self.display_name} reply")])
# region Sequential Builder Tests
async def test_sequential_kwargs_flow_to_agent() -> None:
"""Test that kwargs passed to SequentialBuilder workflow flow through to agent."""
agent = _KwargsCapturingAgent(name="seq_agent")
workflow = SequentialBuilder().participants([agent]).build()
custom_data = {"endpoint": "https://api.example.com", "version": "v1"}
user_token = {"user_name": "alice", "access_level": "admin"}
async for event in workflow.run_stream(
"test message",
custom_data=custom_data,
user_token=user_token,
):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
break
# Verify agent received kwargs
assert len(agent.captured_kwargs) >= 1, "Agent should have been invoked at least once"
received = agent.captured_kwargs[0]
assert "custom_data" in received, "Agent should receive custom_data kwarg"
assert "user_token" in received, "Agent should receive user_token kwarg"
assert received["custom_data"] == custom_data
assert received["user_token"] == user_token
async def test_sequential_kwargs_flow_to_multiple_agents() -> None:
"""Test that kwargs flow to all agents in a sequential workflow."""
agent1 = _KwargsCapturingAgent(name="agent1")
agent2 = _KwargsCapturingAgent(name="agent2")
workflow = SequentialBuilder().participants([agent1, agent2]).build()
custom_data = {"key": "value"}
async for event in workflow.run_stream("test", custom_data=custom_data):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
break
# Both agents should have received kwargs
assert len(agent1.captured_kwargs) >= 1, "First agent should be invoked"
assert len(agent2.captured_kwargs) >= 1, "Second agent should be invoked"
assert agent1.captured_kwargs[0].get("custom_data") == custom_data
assert agent2.captured_kwargs[0].get("custom_data") == custom_data
async def test_sequential_run_kwargs_flow() -> None:
"""Test that kwargs flow through workflow.run() (non-streaming)."""
agent = _KwargsCapturingAgent(name="run_agent")
workflow = SequentialBuilder().participants([agent]).build()
_ = await workflow.run("test message", custom_data={"test": True})
assert len(agent.captured_kwargs) >= 1
assert agent.captured_kwargs[0].get("custom_data") == {"test": True}
# endregion
# region Concurrent Builder Tests
async def test_concurrent_kwargs_flow_to_agents() -> None:
"""Test that kwargs flow to all agents in a concurrent workflow."""
agent1 = _KwargsCapturingAgent(name="concurrent1")
agent2 = _KwargsCapturingAgent(name="concurrent2")
workflow = ConcurrentBuilder().participants([agent1, agent2]).build()
custom_data = {"batch_id": "123"}
user_token = {"user_name": "bob"}
async for event in workflow.run_stream(
"concurrent test",
custom_data=custom_data,
user_token=user_token,
):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
break
# Both agents should have received kwargs
assert len(agent1.captured_kwargs) >= 1, "First concurrent agent should be invoked"
assert len(agent2.captured_kwargs) >= 1, "Second concurrent agent should be invoked"
for agent in [agent1, agent2]:
received = agent.captured_kwargs[0]
assert received.get("custom_data") == custom_data
assert received.get("user_token") == user_token
# endregion
# region GroupChat Builder Tests
async def test_groupchat_kwargs_flow_to_agents() -> None:
"""Test that kwargs flow to agents in a group chat workflow."""
agent1 = _KwargsCapturingAgent(name="chat1")
agent2 = _KwargsCapturingAgent(name="chat2")
# Simple selector that takes GroupChatStateSnapshot
turn_count = 0
def simple_selector(state: GroupChatStateSnapshot) -> str | None:
nonlocal turn_count
turn_count += 1
if turn_count > 2: # Stop after 2 turns
return None
# state is a Mapping - access via dict syntax
names = list(state["participants"].keys())
return names[(turn_count - 1) % len(names)]
workflow = (
GroupChatBuilder().participants(chat1=agent1, chat2=agent2).set_select_speakers_func(simple_selector).build()
)
custom_data = {"session_id": "group123"}
async for event in workflow.run_stream("group chat test", custom_data=custom_data):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
break
# At least one agent should have received kwargs
all_kwargs = agent1.captured_kwargs + agent2.captured_kwargs
assert len(all_kwargs) >= 1, "At least one agent should be invoked in group chat"
for received in all_kwargs:
assert received.get("custom_data") == custom_data
# endregion
# region SharedState Verification Tests
async def test_kwargs_stored_in_shared_state() -> None:
"""Test that kwargs are stored in SharedState with the correct key."""
from agent_framework import Executor, WorkflowContext, handler
stored_kwargs: dict[str, Any] | None = None
class _SharedStateInspector(Executor):
@handler
async def inspect(self, msgs: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None:
nonlocal stored_kwargs
stored_kwargs = await ctx.get_shared_state(WORKFLOW_RUN_KWARGS_KEY)
await ctx.send_message(msgs)
inspector = _SharedStateInspector(id="inspector")
workflow = SequentialBuilder().participants([inspector]).build()
async for event in workflow.run_stream("test", my_kwarg="my_value", another=123):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
break
assert stored_kwargs is not None, "kwargs should be stored in SharedState"
assert stored_kwargs.get("my_kwarg") == "my_value"
assert stored_kwargs.get("another") == 123
async def test_empty_kwargs_stored_as_empty_dict() -> None:
"""Test that empty kwargs are stored as empty dict in SharedState."""
from agent_framework import Executor, WorkflowContext, handler
stored_kwargs: Any = "NOT_CHECKED"
class _SharedStateChecker(Executor):
@handler
async def check(self, msgs: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None:
nonlocal stored_kwargs
stored_kwargs = await ctx.get_shared_state(WORKFLOW_RUN_KWARGS_KEY)
await ctx.send_message(msgs)
checker = _SharedStateChecker(id="checker")
workflow = SequentialBuilder().participants([checker]).build()
# Run without any kwargs
async for event in workflow.run_stream("test"):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
break
# SharedState should have empty dict when no kwargs provided
assert stored_kwargs == {}, f"Expected empty dict, got: {stored_kwargs}"
# endregion
# region Edge Cases
async def test_kwargs_with_none_values() -> None:
"""Test that kwargs with None values are passed through correctly."""
agent = _KwargsCapturingAgent(name="none_test")
workflow = SequentialBuilder().participants([agent]).build()
async for event in workflow.run_stream("test", optional_param=None, other_param="value"):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
break
assert len(agent.captured_kwargs) >= 1
received = agent.captured_kwargs[0]
assert "optional_param" in received
assert received["optional_param"] is None
assert received["other_param"] == "value"
async def test_kwargs_with_complex_nested_data() -> None:
"""Test that complex nested data structures flow through correctly."""
agent = _KwargsCapturingAgent(name="nested_test")
workflow = SequentialBuilder().participants([agent]).build()
complex_data = {
"level1": {
"level2": {
"level3": ["a", "b", "c"],
"number": 42,
},
"list": [1, 2, {"nested": True}],
},
"tuple_like": [1, 2, 3],
}
async for event in workflow.run_stream("test", complex_data=complex_data):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
break
assert len(agent.captured_kwargs) >= 1
received = agent.captured_kwargs[0]
assert received.get("complex_data") == complex_data
async def test_kwargs_preserved_across_workflow_reruns() -> None:
"""Test that kwargs are correctly isolated between workflow runs."""
agent = _KwargsCapturingAgent(name="rerun_test")
# Build separate workflows for each run to avoid "already running" error
workflow1 = SequentialBuilder().participants([agent]).build()
workflow2 = SequentialBuilder().participants([agent]).build()
# First run
async for event in workflow1.run_stream("run1", run_id="first"):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
break
# Second run with different kwargs (using fresh workflow)
async for event in workflow2.run_stream("run2", run_id="second"):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
break
assert len(agent.captured_kwargs) >= 2
assert agent.captured_kwargs[0].get("run_id") == "first"
assert agent.captured_kwargs[1].get("run_id") == "second"
# endregion
# region Handoff Builder Tests
async def test_handoff_kwargs_flow_to_agents() -> None:
"""Test that kwargs flow to agents in a handoff workflow."""
agent1 = _KwargsCapturingAgent(name="coordinator")
agent2 = _KwargsCapturingAgent(name="specialist")
workflow = (
HandoffBuilder()
.participants([agent1, agent2])
.set_coordinator(agent1)
.with_interaction_mode("autonomous")
.build()
)
custom_data = {"session_id": "handoff123"}
async for event in workflow.run_stream("handoff test", custom_data=custom_data):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
break
# Coordinator agent should have received kwargs
assert len(agent1.captured_kwargs) >= 1, "Coordinator should be invoked in handoff"
assert agent1.captured_kwargs[0].get("custom_data") == custom_data
# endregion
# region Magentic Builder Tests
async def test_magentic_kwargs_flow_to_agents() -> None:
"""Test that kwargs flow to agents in a magentic workflow via MagenticAgentExecutor."""
from agent_framework import MagenticBuilder
from agent_framework._workflows._magentic import (
MagenticContext,
MagenticManagerBase,
_MagenticProgressLedger,
_MagenticProgressLedgerItem,
)
# Create a mock manager that completes after one round
class _MockManager(MagenticManagerBase):
def __init__(self) -> None:
super().__init__(max_stall_count=3, max_reset_count=None, max_round_count=2)
self.task_ledger = None
async def plan(self, context: MagenticContext) -> ChatMessage:
return ChatMessage(role=Role.ASSISTANT, text="Plan: Test task", author_name="manager")
async def replan(self, context: MagenticContext) -> ChatMessage:
return ChatMessage(role=Role.ASSISTANT, text="Replan: Test task", author_name="manager")
async def create_progress_ledger(self, context: MagenticContext) -> _MagenticProgressLedger:
# Return completed on first call
return _MagenticProgressLedger(
is_request_satisfied=_MagenticProgressLedgerItem(answer=True, reason="Done"),
is_progress_being_made=_MagenticProgressLedgerItem(answer=True, reason="Progress"),
is_in_loop=_MagenticProgressLedgerItem(answer=False, reason="Not looping"),
instruction_or_question=_MagenticProgressLedgerItem(answer="Complete", reason="Done"),
next_speaker=_MagenticProgressLedgerItem(answer="agent1", reason="First"),
)
async def prepare_final_answer(self, context: MagenticContext) -> ChatMessage:
return ChatMessage(role=Role.ASSISTANT, text="Final answer", author_name="manager")
agent = _KwargsCapturingAgent(name="agent1")
manager = _MockManager()
workflow = MagenticBuilder().participants(agent1=agent).with_standard_manager(manager=manager).build()
custom_data = {"session_id": "magentic123"}
async for event in workflow.run_stream("magentic test", custom_data=custom_data):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
break
# The workflow completes immediately via prepare_final_answer without invoking agents
# because is_request_satisfied=True. This test verifies the kwargs storage path works.
# A more comprehensive integration test would require the manager to select an agent.
async def test_magentic_kwargs_stored_in_shared_state() -> None:
"""Test that kwargs are stored in SharedState when using MagenticWorkflow.run_stream()."""
from agent_framework import MagenticBuilder
from agent_framework._workflows._magentic import (
MagenticContext,
MagenticManagerBase,
_MagenticProgressLedger,
_MagenticProgressLedgerItem,
)
class _MockManager(MagenticManagerBase):
def __init__(self) -> None:
super().__init__(max_stall_count=3, max_reset_count=None, max_round_count=1)
self.task_ledger = None
async def plan(self, context: MagenticContext) -> ChatMessage:
return ChatMessage(role=Role.ASSISTANT, text="Plan", author_name="manager")
async def replan(self, context: MagenticContext) -> ChatMessage:
return ChatMessage(role=Role.ASSISTANT, text="Replan", author_name="manager")
async def create_progress_ledger(self, context: MagenticContext) -> _MagenticProgressLedger:
return _MagenticProgressLedger(
is_request_satisfied=_MagenticProgressLedgerItem(answer=True, reason="Done"),
is_progress_being_made=_MagenticProgressLedgerItem(answer=True, reason="Progress"),
is_in_loop=_MagenticProgressLedgerItem(answer=False, reason="Not looping"),
instruction_or_question=_MagenticProgressLedgerItem(answer="Done", reason="Done"),
next_speaker=_MagenticProgressLedgerItem(answer="agent1", reason="First"),
)
async def prepare_final_answer(self, context: MagenticContext) -> ChatMessage:
return ChatMessage(role=Role.ASSISTANT, text="Final", author_name="manager")
agent = _KwargsCapturingAgent(name="agent1")
manager = _MockManager()
magentic_workflow = MagenticBuilder().participants(agent1=agent).with_standard_manager(manager=manager).build()
# Use MagenticWorkflow.run_stream() which goes through the kwargs attachment path
custom_data = {"magentic_key": "magentic_value"}
async for event in magentic_workflow.run_stream("test task", custom_data=custom_data):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
break
# Verify the workflow completed (kwargs were stored, even if agent wasn't invoked)
# The test validates the code path through MagenticWorkflow.run_stream -> _MagenticStartMessage
# endregion