mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Fix hosted MCP replay producing orphan function_call_output (#5581)
* Python: Fix hosted MCP replay producing orphan function_call_output Resolves part of #5546. After a turn ran a hosted MCP / Foundry-toolbox-MCP tool, the next turn's replayed input array carried a function_call_output with an mcp_* call_id and no matching function_call, and the Responses API returned a 400. Two layers covered here: * Chat-client serialize layer (packages/openai): adds mcp_server_tool_call and mcp_server_tool_result cases to _prepare_message_for_openai and _prepare_content_for_openai. Pairs are coalesced via a post-pass into a single mcp_call input item carrying both arguments and output. Orphan results are dropped (debug-logged) rather than serialized as orphan function_call_output, which is what the Responses API rejected. * Host read layer (packages/foundry_hosting): _item_to_message and _output_item_to_message now route custom_tool_call_output whose call_id.startswith("mcp_") to Content.from_mcp_server_tool_result. Non-mcp_ call_ids continue to produce Content.from_function_result. Symmetric with the host write-side choice for hosted-MCP results. Two further fixes (agentserver SDK additions, host write-side single-item emission) remain tracked on the issue and depend on an SDK release. * Python: Fix pyright unknown-type in _stringify_mcp_output cast(Sequence[Any], output) after the isinstance check so pyright stops flagging the loop variable as unknown. Also normalizes a couple of em-dashes in docstrings I introduced in the prior commit. * Python: Harden _stringify_mcp_output for dict-shaped MCP outputs Address Copilot review on PR #5581. Today the helper falls back to str() for any non-string, non-text-attribute entry, which produces Python repr (single-quoted dicts) for the canonical MCP raw-JSON text-content shape `{"type": "text", "text": "..."}` and any other dict-shaped output. Three small changes: * List-entry path: prefer plain string entries, then `.text` attribute (Content objects), then `entry["text"]` for Mapping entries in the canonical MCP shape, then JSON-encode anything else. * Final fallback: `json.dumps(output, default=str)` so Mappings and scalars produce valid JSON rather than Python repr. * Two new unit tests covering the dict-with-text shape and the non-text-dict JSON fallback. * Python: Suppress mypy redundant-cast on _stringify_mcp_output narrowing The cast is needed by pyright (reportUnknownVariableType) but mypy considers it redundant after the preceding isinstance narrowing. Pyright's behavior is correct for the strict-mode reporting we run, so keep the cast and silence mypy on the line.
This commit is contained in:
committed by
GitHub
Unverified
parent
6cd81286a9
commit
317ef4491e
@@ -806,6 +806,18 @@ def _item_to_message(item: Item) -> Message:
|
||||
if item.type == "custom_tool_call_output":
|
||||
cto = cast(ItemCustomToolCallOutput, item)
|
||||
output = cto.output if isinstance(cto.output, str) else str(cto.output)
|
||||
# Hosted-MCP results land here because the host writes them via
|
||||
# `aoutput_item_custom_tool_call_output` (see `_to_outputs` for
|
||||
# `mcp_server_tool_result`). The persisted `call_id` keeps its
|
||||
# `mcp_*` prefix; on read, route those back to a hosted-MCP result
|
||||
# Content so the chat-client serialize layer can coalesce them
|
||||
# onto a single `mcp_call` input item with `output` populated.
|
||||
# Issue #5546.
|
||||
if cto.call_id and cto.call_id.startswith("mcp_"):
|
||||
return Message(
|
||||
role="tool",
|
||||
contents=[Content.from_mcp_server_tool_result(call_id=cto.call_id, output=output)],
|
||||
)
|
||||
return Message(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(cto.call_id, result=output)],
|
||||
@@ -1054,6 +1066,16 @@ def _output_item_to_message(item: OutputItem) -> Message:
|
||||
if item.type == "custom_tool_call_output":
|
||||
cto = cast(OutputItemCustomToolCallOutput, item)
|
||||
output = cto.output if isinstance(cto.output, str) else str(cto.output)
|
||||
# Hosted-MCP results land here because the host writes them via
|
||||
# `aoutput_item_custom_tool_call_output`. Route `mcp_*` call_ids
|
||||
# back to a hosted-MCP result Content so the chat-client serialize
|
||||
# layer can coalesce onto the matching `mcp_call` input item.
|
||||
# Issue #5546.
|
||||
if cto.call_id and cto.call_id.startswith("mcp_"):
|
||||
return Message(
|
||||
role="tool",
|
||||
contents=[Content.from_mcp_server_tool_result(call_id=cto.call_id, output=output)],
|
||||
)
|
||||
return Message(
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(cto.call_id, result=output)],
|
||||
|
||||
@@ -879,6 +879,30 @@ class TestOutputItemToMessage:
|
||||
assert msg.contents[0].type == "function_result"
|
||||
assert msg.contents[0].result == "result text"
|
||||
|
||||
def test_custom_tool_call_output_with_mcp_call_id_routes_to_mcp_server_tool_result(self) -> None:
|
||||
"""When the host wrote a hosted-MCP result via
|
||||
`aoutput_item_custom_tool_call_output`, the persisted call_id keeps
|
||||
its `mcp_*` prefix. On read, that result must reconstruct as a
|
||||
`mcp_server_tool_result` Content (not `function_result`), so the
|
||||
chat-client serialize layer treats it as a hosted-MCP result and
|
||||
does not produce an orphan `function_call_output`.
|
||||
"""
|
||||
from azure.ai.agentserver.responses.models import OutputItemCustomToolCallOutput
|
||||
|
||||
item = OutputItemCustomToolCallOutput({
|
||||
"type": "custom_tool_call_output",
|
||||
"call_id": "mcp_06b686e11f118cf40169f0e5badb3081979842929d5cf04920",
|
||||
"output": "found 10 cats",
|
||||
})
|
||||
msg = _output_item_to_message(item)
|
||||
assert msg.role == "tool"
|
||||
assert len(msg.contents) == 1
|
||||
c = msg.contents[0]
|
||||
assert c.type == "mcp_server_tool_result", (
|
||||
f"expected mcp_server_tool_result for mcp_-prefixed call_id; got {c.type}"
|
||||
)
|
||||
assert c.call_id == "mcp_06b686e11f118cf40169f0e5badb3081979842929d5cf04920"
|
||||
|
||||
def test_apply_patch_call(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import ApplyPatchUpdateFileOperation, OutputItemApplyPatchToolCall
|
||||
|
||||
@@ -1329,6 +1353,32 @@ class TestItemToMessage:
|
||||
assert msg is not None
|
||||
assert msg.contents[0].result == "123"
|
||||
|
||||
def test_custom_tool_call_output_with_mcp_call_id_routes_to_mcp_server_tool_result(self) -> None:
|
||||
"""Issue #5546: input items carrying a hosted-MCP result (from a
|
||||
prior turn that the framework wrote via
|
||||
`aoutput_item_custom_tool_call_output`) must reconstruct as a
|
||||
`mcp_server_tool_result` Content, not `function_result`. Otherwise
|
||||
the chat-client serialize layer turns it into an orphan
|
||||
`function_call_output` with `mcp_*` call_id and the Responses API
|
||||
rejects the next turn.
|
||||
"""
|
||||
from azure.ai.agentserver.responses.models import ItemCustomToolCallOutput
|
||||
|
||||
item = ItemCustomToolCallOutput({
|
||||
"type": "custom_tool_call_output",
|
||||
"call_id": "mcp_06b686e11f118cf40169f0e5badb3081979842929d5cf04920",
|
||||
"output": "found 10 cats",
|
||||
})
|
||||
msg = _item_to_message(item)
|
||||
assert msg is not None
|
||||
assert msg.role == "tool"
|
||||
assert len(msg.contents) == 1
|
||||
c = msg.contents[0]
|
||||
assert c.type == "mcp_server_tool_result", (
|
||||
f"expected mcp_server_tool_result for mcp_-prefixed call_id; got {c.type}"
|
||||
)
|
||||
assert c.call_id == "mcp_06b686e11f118cf40169f0e5badb3081979842929d5cf04920"
|
||||
|
||||
def test_apply_patch_call(self) -> None:
|
||||
from azure.ai.agentserver.responses.models import ApplyPatchToolCallItemParam, ApplyPatchUpdateFileOperation
|
||||
|
||||
|
||||
Reference in New Issue
Block a user