Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions python/packages/core/agent_framework/_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -549,12 +549,30 @@ async def _agent_wrapper(ctx: FunctionInvocationContext, **kwargs: Any) -> str:
ctx: the function invocation context used
**kwargs: only used to dynamically load the argument that is defined for this tool.
"""
stream = self.run(
str(kwargs.get(arg_name, "")),
stream=True,
session=ctx.session if propagate_session else None,
function_invocation_kwargs=dict(ctx.kwargs),
)
# Check if this invocation carries approval responses that need to be
# forwarded to the inner agent (sub-agent approval re-routing).
approval_messages: list[Any] | None = ctx.metadata.get("_approval_messages") if ctx else None
if approval_messages:
input_messages: list[Message] = []
for item in approval_messages:
# Include the function call as an assistant message so the inner
# agent has proper conversation context for the function result.
if hasattr(item, "function_call") and item.function_call is not None:
input_messages.append(Message("assistant", [item.function_call]))
input_messages.append(Message("user", [item]))
stream = self.run(
input_messages,
stream=True,
session=ctx.session if propagate_session else None,
function_invocation_kwargs=dict(ctx.kwargs),
)
else:
stream = self.run(
str(kwargs.get(arg_name, "")),
stream=True,
session=ctx.session if propagate_session else None,
function_invocation_kwargs=dict(ctx.kwargs),
)
if stream_callback is not None:
stream.with_transform_hook(stream_callback)
final_response = await stream.get_final_response()
Expand Down
45 changes: 42 additions & 3 deletions python/packages/core/agent_framework/_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1359,9 +1359,31 @@ async def _auto_invoke_function(
return function_call_content
tool = tool_map.get(inner_call.name) # type: ignore[attr-defined, union-attr, arg-type]
if tool is None:
# we assume it is a hosted tool
return function_call_content
function_call_content = inner_call # type: ignore[assignment]
# Check if this is a sub-agent approval that needs re-routing
# through the parent tool (the sub-agent wrapper).
parent_tool_name = function_call_content.additional_properties.get("_parent_tool_name")
if parent_tool_name:
tool = tool_map.get(parent_tool_name)
if tool is None:
# we assume it is a hosted tool
return function_call_content
# Re-invoke through the parent sub-agent tool with approval context.
parent_tool_call_id = (
function_call_content.additional_properties.get("_parent_tool_call_id")
or function_call_content.id
or function_call_content.call_id
)
function_call_content = Content(
type="function_call",
name=parent_tool_name,
call_id=parent_tool_call_id,
arguments=function_call_content.additional_properties.get("_parent_tool_args"),
additional_properties={
"_approval_messages": [function_call_content],
},
Comment thread
giles17 marked this conversation as resolved.
)
else:
function_call_content = inner_call # type: ignore[assignment]

parsed_args: dict[str, Any] = dict(function_call_content.parse_arguments() or {})

Expand Down Expand Up @@ -1397,6 +1419,15 @@ async def _auto_invoke_function(

from ._middleware import FunctionInvocationContext

# Carry sub-agent approval context through to the FunctionInvocationContext
# so _agent_wrapper can forward approval messages to the inner agent.
approval_metadata: dict[str, Any] | None = None
approval_msgs = function_call_content.additional_properties.get("_approval_messages")
if approval_msgs:
approval_metadata = {"_approval_messages": approval_msgs}
# Remove internal routing metadata so it doesn't persist in session messages.
function_call_content.additional_properties.pop("_approval_messages", None)

if middleware_pipeline is None or not middleware_pipeline.has_middlewares:
# No middleware - execute directly
try:
Expand All @@ -1406,6 +1437,7 @@ async def _auto_invoke_function(
function=tool,
arguments=args,
session=invocation_session,
metadata=approval_metadata,
kwargs=runtime_kwargs.copy(),
)
function_result = await tool.invoke(
Expand Down Expand Up @@ -1435,6 +1467,7 @@ async def _auto_invoke_function(
function=tool,
arguments=args,
session=invocation_session,
metadata=approval_metadata,
kwargs=runtime_kwargs.copy(),
)

Expand Down Expand Up @@ -1613,6 +1646,12 @@ async def invoke_with_termination_handling(
item.call_id = function_call.call_id # type: ignore[attr-defined]
if not item.id: # type: ignore[attr-defined]
item.id = function_call.call_id # type: ignore[attr-defined]
# Tag with parent tool info so approval responses can be
# routed back through the sub-agent on re-invocation.
# Use setdefault to preserve inner routing metadata for nested sub-agents.
item.additional_properties.setdefault("_parent_tool_name", function_call.name)
item.additional_properties.setdefault("_parent_tool_args", function_call.arguments)
item.additional_properties.setdefault("_parent_tool_call_id", function_call.call_id)
propagated.append(item)
if propagated:
extra_user_input_contents.extend(propagated[1:])
Expand Down
215 changes: 215 additions & 0 deletions python/packages/core/tests/core/test_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -2653,3 +2653,218 @@ async def test_as_tool_raises_on_user_input_request(client: SupportsChatGetRespo
assert len(exc_info.value.contents) == 1
assert exc_info.value.contents[0].type == "oauth_consent_request"
assert exc_info.value.contents[0].consent_link == "https://login.microsoftonline.com/consent"

Comment thread
giles17 marked this conversation as resolved.

async def test_chat_agent_as_tool_inner_approval_executes_tool() -> None:
"""Test that approving a sub-agent's inner tool actually executes it.

When a sub-agent used via as_tool() has an inner tool with approval_mode="always_require",
the approval request propagates to the outer caller. Sending the approval response back
must re-invoke the sub-agent and execute the approved inner tool.
"""
from unittest.mock import patch as mock_patch

from tests.core.conftest import MockBaseChatClient

inner_tool_executed = False

@tool(approval_mode="always_require")
def get_detail(location: str) -> str:
"""Get details for a location."""
nonlocal inner_tool_executed
inner_tool_executed = True
return f"Details for {location}: cloudy, 15C."

# Inner agent uses streaming (as_tool always calls run with stream=True)
inner_client = MockBaseChatClient()
inner_client.streaming_responses = [
# Inner LLM requests get_detail (needs approval)
[
ChatResponseUpdate(
contents=[
Content.from_function_call(
call_id="inner_call_1",
name="get_detail",
arguments='{"location": "Amsterdam"}',
)
],
role="assistant",
finish_reason="tool_calls",
)
],
# After approval + execution, inner LLM produces final text
[
ChatResponseUpdate(
contents=[Content.from_text("Details for Amsterdam: cloudy, 15C.")],
role="assistant",
finish_reason="stop",
)
],
]

inner_agent = Agent(
client=inner_client,
name="detail_agent",
description="Agent that provides detail information.",
instructions="You are a helpful detail assistant.",
tools=[get_detail],
)

# Outer agent uses non-streaming run_responses
outer_client = MockBaseChatClient()
outer_client.run_responses = [
# Outer LLM requests the sub-agent tool
ChatResponse(
messages=Message(
role="assistant",
contents=[
Content.from_function_call(
call_id="outer_call_1",
name="detail_agent_tool",
arguments='{"task": "Get details for Amsterdam"}',
)
],
),
),
# After tool result, outer LLM generates final text
ChatResponse(
messages=Message(role="assistant", contents=["Details for Amsterdam: cloudy, 15C."]),
),
]

with mock_patch("agent_framework._tools.DEFAULT_MAX_ITERATIONS", 5):
outer_agent = Agent(
client=outer_client,
name="coordinator_agent",
description="Coordinator agent.",
instructions="Use the detail_agent_tool for detail questions.",
tools=[
inner_agent.as_tool(
name="detail_agent_tool",
description="A detail agent tool.",
approval_mode="never_require",
)
],
)

session = outer_agent.create_session()

# First run: should propagate approval request from inner tool
response1 = await outer_agent.run("Get details for Amsterdam", session=session, stream=False)

assert response1.user_input_requests, "Expected approval request from inner tool"
approval_request = response1.user_input_requests[0]
assert approval_request.type == "function_approval_request"
assert approval_request.function_call.name == "get_detail"

# Second run: send approval response — inner tool must execute
approval_response_content = approval_request.to_function_approval_response(True)
response2 = await outer_agent.run(
[Message("user", [approval_response_content])],
session=session,
stream=False,
)

assert inner_tool_executed, (
"Inner tool was never executed after approval. The approval response was not routed through the sub-agent."
)
assert "Amsterdam" in response2.text

# Verify the outer tool result has the correct call_id matching the original tool call.
messages = session.state.get("in_memory", {}).get("messages", [])
assert any(
getattr(content, "type", None) == "function_result" and getattr(content, "call_id", None) == "outer_call_1"
for message in messages
for content in (getattr(message, "contents", None) or [])
), "Expected persisted outer tool result with call_id='outer_call_1' in session history"


Comment thread
giles17 marked this conversation as resolved.
async def test_chat_agent_as_tool_inner_approval_rejected() -> None:
"""Test that rejecting a sub-agent's inner tool approval does not execute it."""
from unittest.mock import patch as mock_patch

from tests.core.conftest import MockBaseChatClient

inner_tool_executed = False
Comment thread
giles17 marked this conversation as resolved.

@tool(approval_mode="always_require")
def get_detail(location: str) -> str:
"""Get details for a location."""
nonlocal inner_tool_executed
inner_tool_executed = True
return f"Details for {location}: cloudy, 15C."

inner_client = MockBaseChatClient()
inner_client.streaming_responses = [
[
ChatResponseUpdate(
contents=[
Content.from_function_call(
call_id="inner_call_1",
name="get_detail",
arguments='{"location": "Amsterdam"}',
)
],
role="assistant",
finish_reason="tool_calls",
)
],
]

inner_agent = Agent(
client=inner_client,
name="detail_agent",
description="Agent that provides detail information.",
instructions="You are a helpful detail assistant.",
tools=[get_detail],
)

outer_client = MockBaseChatClient()
outer_client.run_responses = [
ChatResponse(
messages=Message(
role="assistant",
contents=[
Content.from_function_call(
call_id="outer_call_1",
name="detail_agent_tool",
arguments='{"task": "Get details for Amsterdam"}',
)
],
),
),
ChatResponse(
messages=Message(role="assistant", contents=["I could not get the details."]),
),
]

with mock_patch("agent_framework._tools.DEFAULT_MAX_ITERATIONS", 5):
outer_agent = Agent(
client=outer_client,
name="coordinator_agent",
description="Coordinator agent.",
tools=[
inner_agent.as_tool(
name="detail_agent_tool",
description="A detail agent tool.",
approval_mode="never_require",
)
],
)

session = outer_agent.create_session()
response1 = await outer_agent.run("Get details for Amsterdam", session=session, stream=False)

assert response1.user_input_requests
approval_request = response1.user_input_requests[0]

# Reject the approval
rejection_content = approval_request.to_function_approval_response(False)
response2 = await outer_agent.run(
[Message("user", [rejection_content])],
session=session,
stream=False,
)

assert not inner_tool_executed, "Inner tool should NOT execute when approval is rejected"
Comment thread
giles17 marked this conversation as resolved.
assert response2.text, "Outer agent should still produce a response after rejection"
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from pydantic import Field

try:
import orjson
import orjson # type: ignore[reportMissingImports]
except ImportError:
orjson = None

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from pydantic import Field

try:
import orjson
import orjson # type: ignore[reportMissingImports]
except ImportError:
orjson = None

Expand Down
Loading