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:
@@ -121,6 +121,14 @@ OPENAI_LOCAL_SHELL_COMMAND_PARTS_KEY = "openai.local_shell_command_parts"
|
||||
OPENAI_SHELL_OUTPUT_TYPE_SHELL_CALL = "shell_call_output"
|
||||
OPENAI_SHELL_OUTPUT_TYPE_LOCAL_SHELL_CALL = "local_shell_call_output"
|
||||
|
||||
# Internal marker emitted by `_prepare_content_for_openai` for an
|
||||
# `mcp_server_tool_result` Content. The Responses API expects an `mcp_call`
|
||||
# input item to carry both arguments and output as one item, so result
|
||||
# Contents cannot be serialized standalone. `_prepare_messages_for_openai`
|
||||
# coalesces these markers into the most recent matching `mcp_call` input
|
||||
# item before returning, dropping any that are unmatched.
|
||||
_AF_MCP_PENDING_OUTPUT_KEY = "__af_pending_mcp_result__"
|
||||
|
||||
|
||||
class OpenAIContinuationToken(ContinuationToken):
|
||||
"""Continuation token for OpenAI Responses API background operations."""
|
||||
@@ -1363,7 +1371,10 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
for message in chat_messages
|
||||
]
|
||||
# Flatten the list of lists into a single list
|
||||
return list(chain.from_iterable(list_of_list))
|
||||
flat = list(chain.from_iterable(list_of_list))
|
||||
# Coalesce hosted-MCP result markers onto matching mcp_call input
|
||||
# items (drop unmatched). See `_AF_MCP_PENDING_OUTPUT_KEY`.
|
||||
return self._coalesce_pending_mcp_results(flat)
|
||||
|
||||
def _prepare_message_for_openai(
|
||||
self,
|
||||
@@ -1428,6 +1439,18 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
)
|
||||
if prepared:
|
||||
all_messages.append(prepared)
|
||||
case "mcp_server_tool_call" | "mcp_server_tool_result":
|
||||
# Hosted MCP call/result contents serialize as a single
|
||||
# top-level mcp_call input item; the result side emits an
|
||||
# internal marker that `_prepare_messages_for_openai`
|
||||
# coalesces onto the matching call (or drops if unmatched).
|
||||
prepared_mcp = self._prepare_content_for_openai(
|
||||
message.role,
|
||||
content,
|
||||
replays_local_storage=replays_local_storage,
|
||||
)
|
||||
if prepared_mcp:
|
||||
all_messages.append(prepared_mcp)
|
||||
case _:
|
||||
prepared_content = self._prepare_content_for_openai(
|
||||
message.role,
|
||||
@@ -1606,6 +1629,24 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
"approval_request_id": content.id,
|
||||
"approve": content.approved,
|
||||
}
|
||||
case "mcp_server_tool_call":
|
||||
if not content.call_id:
|
||||
return {}
|
||||
return {
|
||||
"type": "mcp_call",
|
||||
"id": content.call_id,
|
||||
"server_label": content.server_name or "",
|
||||
"name": content.tool_name or "",
|
||||
"arguments": self._stringify_mcp_arguments(content.arguments),
|
||||
}
|
||||
case "mcp_server_tool_result":
|
||||
if not content.call_id:
|
||||
return {}
|
||||
return {
|
||||
_AF_MCP_PENDING_OUTPUT_KEY: True,
|
||||
"call_id": content.call_id,
|
||||
"output": self._stringify_mcp_output(content.output),
|
||||
}
|
||||
case "hosted_file":
|
||||
# `input_file` is an input-only content type in the Responses API and is rejected
|
||||
# inside an assistant message. Hosted-file content on an assistant message
|
||||
@@ -1681,6 +1722,91 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
"""Join shell commands into a single executable command string."""
|
||||
return "\n".join(command for command in commands if command).strip()
|
||||
|
||||
@staticmethod
|
||||
def _stringify_mcp_arguments(arguments: Any) -> str:
|
||||
"""Render hosted-MCP tool-call arguments as a JSON string for the Responses API."""
|
||||
if arguments is None:
|
||||
return ""
|
||||
if isinstance(arguments, str):
|
||||
return arguments
|
||||
try:
|
||||
return json.dumps(arguments)
|
||||
except (TypeError, ValueError):
|
||||
return str(arguments)
|
||||
|
||||
@staticmethod
|
||||
def _stringify_mcp_output(output: Any) -> str:
|
||||
"""Render a hosted-MCP tool-call result into the string `mcp_call.output` field.
|
||||
|
||||
Accepts a string, a list of text-bearing Content objects (the form
|
||||
the chat client produces when parsing an `mcp_call` Responses item),
|
||||
or any other value. List entries that are dicts with the canonical
|
||||
MCP text-content shape (`{"text": "..."}`) are unwrapped to their
|
||||
text. Anything else falls back to JSON encoding rather than Python
|
||||
`repr`, so the wire payload stays parseable for downstream callers.
|
||||
"""
|
||||
if output is None:
|
||||
return ""
|
||||
if isinstance(output, str):
|
||||
return output
|
||||
if isinstance(output, Sequence) and not isinstance(output, (str, bytes, bytearray)):
|
||||
# cast is for pyright (reportUnknownVariableType); mypy considers
|
||||
# it redundant after the isinstance narrowing.
|
||||
entries = cast(Sequence[Any], output) # type: ignore[redundant-cast]
|
||||
parts: list[str] = []
|
||||
for entry in entries:
|
||||
if isinstance(entry, str):
|
||||
parts.append(entry)
|
||||
continue
|
||||
text = getattr(entry, "text", None)
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
continue
|
||||
if isinstance(entry, Mapping):
|
||||
mapping_text = cast(Any, entry).get("text")
|
||||
if isinstance(mapping_text, str):
|
||||
parts.append(mapping_text)
|
||||
continue
|
||||
parts.append(json.dumps(entry, default=str))
|
||||
return "".join(parts)
|
||||
return json.dumps(output, default=str)
|
||||
|
||||
@staticmethod
|
||||
def _coalesce_pending_mcp_results(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Merge pending hosted-MCP result markers onto matching mcp_call input items.
|
||||
|
||||
See `_AF_MCP_PENDING_OUTPUT_KEY`. The Responses API expects a single
|
||||
`mcp_call` input item carrying both `arguments` and `output`, so a
|
||||
result Content cannot be its own input item. Any unmatched markers
|
||||
are dropped (debug-logged); surfacing them as standalone items
|
||||
would produce the orphan `function_call_output` / `mcp_call_output`
|
||||
the API rejects.
|
||||
"""
|
||||
out: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
if item.get(_AF_MCP_PENDING_OUTPUT_KEY):
|
||||
target_call_id = item.get("call_id")
|
||||
target = next(
|
||||
(
|
||||
existing
|
||||
for existing in reversed(out)
|
||||
if existing.get("type") == "mcp_call" and existing.get("id") == target_call_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if target is not None:
|
||||
if target.get("output") is None:
|
||||
target["output"] = item.get("output")
|
||||
else:
|
||||
logger.debug(
|
||||
"Dropping orphan mcp_server_tool_result for call_id=%s; "
|
||||
"no matching mcp_call appeared in input.",
|
||||
target_call_id,
|
||||
)
|
||||
continue
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _serialize_provider_payload(value: Any) -> Any:
|
||||
"""Convert OpenAI SDK objects into JSON-serializable Python values."""
|
||||
|
||||
Reference in New Issue
Block a user