Skip to content

Commit d02051d

Browse files
giles17Copilot
andauthored
Python: Add propagate_session to as_tool() for session sharing in agent-as-tool scenarios (#4439)
* Python: Add propagate_session parameter to as_tool() for session sharing Add opt-in session propagation in agent-as-tool scenarios. When propagate_session=True, the parent agent's AgentSession is forwarded to the sub-agent's run() call, allowing both agents to share session state (history, metadata, session_id). - Add propagate_session parameter to BaseAgent.as_tool() (default False) - Include session in additional_function_arguments so it flows to tools - Add 3 tests for propagation on/off and shared state verification - Add sample showing session propagation with observability middleware Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Clarify propagate_session docstring per review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 23644ac commit d02051d

3 files changed

Lines changed: 191 additions & 5 deletions

File tree

python/packages/core/agent_framework/_agents.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,7 @@ def as_tool(
454454
stream_callback: Callable[[AgentResponseUpdate], None]
455455
| Callable[[AgentResponseUpdate], Awaitable[None]]
456456
| None = None,
457+
propagate_session: bool = False,
457458
) -> FunctionTool:
458459
"""Create a FunctionTool that wraps this agent.
459460
@@ -464,6 +465,12 @@ def as_tool(
464465
arg_description: The description for the function argument.
465466
If None, defaults to "Task for {tool_name}".
466467
stream_callback: Optional callback for streaming responses. If provided, uses run(..., stream=True).
468+
propagate_session: If True, the parent agent's ``AgentSession`` is
469+
forwarded to this sub-agent's ``run()`` call, so both agents
470+
operate within the same logical session (sharing the same
471+
``session_id`` and provider-managed state, such as any stored
472+
conversation history or metadata). Defaults to False, meaning
473+
the sub-agent runs with a new, independent session.
467474
468475
Returns:
469476
A FunctionTool that can be used as a tool by other agents.
@@ -480,9 +487,12 @@ def as_tool(
480487
# Create an agent
481488
agent = Agent(client=client, name="research-agent", description="Performs research tasks")
482489
483-
# Convert the agent to a tool
490+
# Convert the agent to a tool (independent session)
484491
research_tool = agent.as_tool()
485492
493+
# Convert the agent to a tool (shared session with parent)
494+
research_tool = agent.as_tool(propagate_session=True)
495+
486496
# Use the tool with another agent
487497
coordinator = Agent(client=client, name="coordinator", tools=research_tool)
488498
"""
@@ -509,16 +519,21 @@ async def agent_wrapper(**kwargs: Any) -> str:
509519
# Extract the input from kwargs using the specified arg_name
510520
input_text = kwargs.get(arg_name, "")
511521

512-
# Forward runtime context kwargs, excluding arg_name and conversation_id.
513-
forwarded_kwargs = {k: v for k, v in kwargs.items() if k not in (arg_name, "conversation_id", "options")}
522+
# Extract parent session when propagate_session is enabled
523+
parent_session = kwargs.get("session") if propagate_session else None
524+
525+
# Forward runtime context kwargs, excluding framework-internal keys.
526+
forwarded_kwargs = {
527+
k: v for k, v in kwargs.items() if k not in (arg_name, "conversation_id", "options", "session")
528+
}
514529

515530
if stream_callback is None:
516531
# Use non-streaming mode
517-
return (await self.run(input_text, stream=False, **forwarded_kwargs)).text
532+
return (await self.run(input_text, stream=False, session=parent_session, **forwarded_kwargs)).text
518533

519534
# Use streaming mode - accumulate updates and create final response
520535
response_updates: list[AgentResponseUpdate] = []
521-
async for update in self.run(input_text, stream=True, **forwarded_kwargs):
536+
async for update in self.run(input_text, stream=True, session=parent_session, **forwarded_kwargs):
522537
response_updates.append(update)
523538
if is_async_callback:
524539
await stream_callback(update) # type: ignore[misc]
@@ -1061,6 +1076,9 @@ async def _prepare_run_context(
10611076
# in function middleware context and tool invocation.
10621077
existing_additional_args = opts.pop("additional_function_arguments", None) or {}
10631078
additional_function_arguments = {**kwargs, **existing_additional_args}
1079+
# Include session so as_tool() wrappers with propagate_session=True can access it.
1080+
if active_session is not None:
1081+
additional_function_arguments["session"] = active_session
10641082

10651083
# Build options dict from run() options merged with provided options
10661084
run_opts: dict[str, Any] = {

python/packages/core/tests/core/test_agents.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,81 @@ async def test_chat_agent_as_tool_name_sanitization(client: SupportsChatGetRespo
707707
assert tool.name == expected_tool_name, f"Expected {expected_tool_name}, got {tool.name} for input {agent_name}"
708708

709709

710+
async def test_chat_agent_as_tool_propagate_session_true(client: SupportsChatGetResponse) -> None:
711+
"""Test that propagate_session=True forwards the parent's session to the sub-agent."""
712+
agent = Agent(client=client, name="SubAgent", description="Sub agent")
713+
tool = agent.as_tool(propagate_session=True)
714+
715+
parent_session = AgentSession(session_id="parent-session-123")
716+
parent_session.state["shared_key"] = "shared_value"
717+
718+
# Spy on the agent's run method to capture the session argument
719+
original_run = agent.run
720+
captured_session = None
721+
722+
def capturing_run(*args: Any, **kwargs: Any) -> Any:
723+
nonlocal captured_session
724+
captured_session = kwargs.get("session")
725+
return original_run(*args, **kwargs)
726+
727+
agent.run = capturing_run # type: ignore[assignment, method-assign]
728+
729+
await tool.invoke(arguments=tool.input_model(task="Hello"), session=parent_session)
730+
731+
assert captured_session is parent_session
732+
assert captured_session.session_id == "parent-session-123"
733+
assert captured_session.state["shared_key"] == "shared_value"
734+
735+
736+
async def test_chat_agent_as_tool_propagate_session_false_by_default(client: SupportsChatGetResponse) -> None:
737+
"""Test that propagate_session defaults to False and does not forward the session."""
738+
agent = Agent(client=client, name="SubAgent", description="Sub agent")
739+
tool = agent.as_tool() # default: propagate_session=False
740+
741+
parent_session = AgentSession(session_id="parent-session-456")
742+
743+
original_run = agent.run
744+
captured_session = None
745+
746+
def capturing_run(*args: Any, **kwargs: Any) -> Any:
747+
nonlocal captured_session
748+
captured_session = kwargs.get("session")
749+
return original_run(*args, **kwargs)
750+
751+
agent.run = capturing_run # type: ignore[assignment, method-assign]
752+
753+
await tool.invoke(arguments=tool.input_model(task="Hello"), session=parent_session)
754+
755+
assert captured_session is None
756+
757+
758+
async def test_chat_agent_as_tool_propagate_session_shares_state(client: SupportsChatGetResponse) -> None:
759+
"""Test that shared session allows the sub-agent to read and write parent's state."""
760+
agent = Agent(client=client, name="SubAgent", description="Sub agent")
761+
tool = agent.as_tool(propagate_session=True)
762+
763+
parent_session = AgentSession(session_id="shared-session")
764+
parent_session.state["counter"] = 0
765+
766+
# The sub-agent receives the same session object, so mutations are shared
767+
original_run = agent.run
768+
captured_session = None
769+
770+
def capturing_run(*args: Any, **kwargs: Any) -> Any:
771+
nonlocal captured_session
772+
captured_session = kwargs.get("session")
773+
if captured_session:
774+
captured_session.state["counter"] += 1
775+
return original_run(*args, **kwargs)
776+
777+
agent.run = capturing_run # type: ignore[assignment, method-assign]
778+
779+
await tool.invoke(arguments=tool.input_model(task="Hello"), session=parent_session)
780+
781+
# The parent's state should reflect the sub-agent's mutation
782+
assert parent_session.state["counter"] == 1
783+
784+
710785
async def test_chat_agent_as_mcp_server_basic(client: SupportsChatGetResponse) -> None:
711786
"""Test basic as_mcp_server functionality."""
712787
agent = Agent(client=client, name="TestAgent", description="Test agent for MCP")
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# Copyright (c) Microsoft. All rights reserved.
2+
3+
import asyncio
4+
from collections.abc import Awaitable, Callable
5+
6+
from agent_framework import AgentContext, AgentSession
7+
from agent_framework.openai import OpenAIResponsesClient
8+
from dotenv import load_dotenv
9+
10+
load_dotenv()
11+
12+
"""
13+
Agent-as-Tool: Session Propagation Example
14+
15+
Demonstrates how to share an AgentSession between a coordinator agent and a
16+
sub-agent invoked as a tool using ``propagate_session=True``.
17+
18+
When session propagation is enabled, both agents share the same session object,
19+
including session_id and the mutable state dict. This allows correlated
20+
conversation tracking and shared state across the agent hierarchy.
21+
22+
The middleware functions below are purely for observability — they are NOT
23+
required for session propagation to work.
24+
"""
25+
26+
27+
async def log_session(
28+
context: AgentContext,
29+
call_next: Callable[[], Awaitable[None]],
30+
) -> None:
31+
"""Agent middleware that logs the session received by each agent.
32+
33+
NOT required for session propagation — only used to observe the flow.
34+
If propagation is working, both agents will show the same session_id.
35+
"""
36+
session: AgentSession | None = context.session
37+
agent_name = context.agent.name or "unknown"
38+
session_id = session.session_id if session else None
39+
state = dict(session.state) if session else {}
40+
print(f" [{agent_name}] session_id={session_id}, state={state}")
41+
await call_next()
42+
43+
44+
async def main() -> None:
45+
print("=== Agent-as-Tool: Session Propagation ===\n")
46+
47+
client = OpenAIResponsesClient()
48+
49+
# --- Sub-agent: a research specialist ---
50+
# The sub-agent has the same log_session middleware to prove it receives the session.
51+
research_agent = client.as_agent(
52+
name="ResearchAgent",
53+
instructions="You are a research assistant. Provide concise answers.",
54+
middleware=[log_session],
55+
)
56+
57+
# propagate_session=True: the coordinator's session will be forwarded
58+
research_tool = research_agent.as_tool(
59+
name="research",
60+
description="Research a topic and return findings",
61+
arg_name="query",
62+
arg_description="The research query",
63+
propagate_session=True,
64+
)
65+
66+
# --- Coordinator agent ---
67+
coordinator = client.as_agent(
68+
name="CoordinatorAgent",
69+
instructions="You coordinate research. Use the 'research' tool to look up information.",
70+
tools=[research_tool],
71+
middleware=[log_session],
72+
)
73+
74+
# Create a shared session and put some state in it
75+
session = coordinator.create_session()
76+
session.state["request_source"] = "demo"
77+
print(f"Session ID: {session.session_id}")
78+
print(f"Session state before run: {session.state}\n")
79+
80+
query = "What are the latest developments in quantum computing?"
81+
print(f"User: {query}\n")
82+
83+
result = await coordinator.run(query, session=session)
84+
85+
print(f"\nCoordinator: {result}\n")
86+
print(f"Session state after run: {session.state}")
87+
print(
88+
"\nIf both agents show the same session_id above, session propagation is working."
89+
)
90+
91+
92+
if __name__ == "__main__":
93+
asyncio.run(main())

0 commit comments

Comments
 (0)