Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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)],
Expand Down Expand Up @@ -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)],
Expand Down
50 changes: 50 additions & 0 deletions python/packages/foundry_hosting/tests/test_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,30 @@ def test_custom_tool_call_output(self) -> None:
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

Expand Down Expand Up @@ -1329,6 +1353,32 @@ def test_custom_tool_call_output_non_string(self) -> None:
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

Expand Down
128 changes: 127 additions & 1 deletion python/packages/openai/agent_framework_openai/_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,14 @@
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."""
Expand Down Expand Up @@ -1363,7 +1371,10 @@ def _prepare_messages_for_openai(
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,
Expand Down Expand Up @@ -1428,6 +1439,18 @@ def _prepare_message_for_openai(
)
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,
Expand Down Expand Up @@ -1606,6 +1629,24 @@ def _prepare_content_for_openai(
"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
Expand Down Expand Up @@ -1681,6 +1722,91 @@ def _join_shell_commands(commands: Sequence[str]) -> str:
"""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."""
Expand Down
Loading
Loading