Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/specs/004-python-function-calling-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,7 @@ that manually replay messages own the equivalent rule: do not resend an approval
| Streaming message injection with per-service-call persistence | A streaming response rebuilt from updates mirrors the inner conversation id exactly, including clearing it, and keeps its internal marker, so the next iteration appends only the latest message rather than replaying the whole turn on top of provider-held history, and never persists a conversation id from an earlier injected service call. | `packages/core/tests/core/test_middleware_with_chat.py::TestChatMiddleware::test_message_injection_middleware_streaming_preserves_inner_continuation_state`, `test_message_injection_middleware_streaming_keeps_service_conversation_id_external`, `test_message_injection_middleware_streaming_clears_conversation_id_when_final_call_has_none`, `test_message_injection_middleware_conversation_id_matches_across_streaming_modes`, `packages/core/tests/core/test_harness_agent.py::test_streaming_harness_tool_call_does_not_duplicate_transcript` |
| Service-side approval decision | Stored hosted request is skipped; the current approved or rejected hosted response is sent, while local approval controls are omitted from provider input. | `packages/openai/tests/openai/test_openai_chat_client.py::test_prepare_messages_strips_approval_request_but_keeps_response_under_storage`, `test_prepare_messages_drops_local_approval_controls` |
| OpenAI approval serialization | Hosted approval id and decision serialize to `mcp_approval_response`; local approvals remain in-process. | `test_prepare_message_for_openai_with_function_approval_response`, `test_prepare_content_for_opentool_approval_response`, `test_function_approval_response_with_mcp_tool_call` |
| OpenAI function-result serialization without call ID | A function result without a `call_id` is accepted and serialized without a null `call_id` field, while supplied IDs remain available for pairing. | `packages/openai/tests/openai/test_openai_chat_client.py::test_prepare_content_for_openai_function_result_without_call_id` |
| OpenAI end-to-end hosted approval | Hosted request parses, response sends, and continuation completes. | `test_end_to_end_mcp_approval_flow` |
| Stored function call/result | Service-side storage drops server-issued calls but keeps new outputs. | `test_prepare_options_with_conversation_id_strips_server_issued_items`, `test_prepare_messages_for_openai_full_conversation_with_reasoning` |
| Stateless reasoning replay | Replay reconstructs reasoning, call, and result together; missing required reasoning fails before the request. | `test_tool_loop_store_false_replays_encrypted_reasoning_group`, `test_stateless_request_rejects_non_replayable_reasoning_bound_mcp_output`, `test_prepare_messages_for_openai_full_conversation_with_reasoning` |
Expand Down
4 changes: 2 additions & 2 deletions python/packages/core/agent_framework/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -856,7 +856,7 @@ def from_function_call(
@classmethod
def from_function_result(
cls: type[ContentT],
call_id: str,
call_id: str | None = None,
*,
result: Any = None,
exception: str | None = None,
Expand All @@ -871,7 +871,7 @@ def from_function_result(
text from text items for backwards compatibility.

Args:
call_id: The ID of the function call this result corresponds to.
call_id: The optional ID of the function call this result corresponds to.

Keyword Args:
result: The tool output. Accepts a ``list[Content]`` (the canonical
Expand Down
9 changes: 9 additions & 0 deletions python/packages/core/tests/core/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -3220,6 +3220,15 @@ def test_from_function_result_with_string():
assert result.items[0].text == "just text"


def test_from_function_result_without_call_id():
"""Test Content.from_function_result accepts results without a call ID."""
result = Content.from_function_result(result="just text")

assert result.type == "function_result"
assert result.call_id is None
assert result.result == "just text"


def test_content_from_function_result_items_in_to_dict():
"""Test that items are included in to_dict serialization."""
content_list = [
Expand Down
11 changes: 6 additions & 5 deletions python/packages/openai/agent_framework_openai/_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1700,8 +1700,8 @@ def _prepare_message_for_openai(
# local-shell-call IDs) must not be re-sent inline when the request carries
# previous_response_id / conversation_id / conversation: the server already has them via
# the prior response and rejects duplicates with "Duplicate item found with id ...".
# function_result keeps its call_id and the server pairs it to the prior function_call via
# that key. See microsoft/agent-framework#3295. The strip is gated on the request-level
# function_result keeps its call_id when present and the server pairs it to the prior
# function_call via that key. See microsoft/agent-framework#3295. The strip is gated on the request-level
# flag, not a message-level one: HistoryProvider-attributed messages
# (replays_local_storage) still need stripping when the request also carries a continuation
# marker, since the server-stored items would otherwise duplicate the inline ones. Without
Expand Down Expand Up @@ -1984,7 +1984,6 @@ def _prepare_content_for_openai(
"type": OPENAI_SHELL_OUTPUT_TYPE_LOCAL_SHELL_CALL,
"output": self._to_local_shell_output_payload(content),
}
# call_id for the result needs to be the same as the call_id for the function call
output: str | list[dict[str, Any]] = content.result or ""
if (
self.SUPPORTS_RICH_FUNCTION_OUTPUT
Expand All @@ -2001,11 +2000,13 @@ def _prepare_content_for_openai(
output_parts.append(part)
if output_parts:
output = output_parts
return {
"call_id": content.call_id,
function_call_output: dict[str, Any] = {
"type": "function_call_output",
"output": output,
}
if content.call_id is not None:
function_call_output["call_id"] = content.call_id
return function_call_output
case "function_approval_request":
return {
"type": "mcp_approval_request",
Expand Down
13 changes: 13 additions & 0 deletions python/packages/openai/tests/openai/test_openai_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5601,6 +5601,19 @@ def test_prepare_content_for_openai_function_result_without_items() -> None:
assert result["output"] == "Simple result"


def test_prepare_content_for_openai_function_result_without_call_id() -> None:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in commit 69bba19: added the OpenAI function-result-without-call-ID row to docs/specs/004-python-function-calling-loop.md and linked it to test_prepare_content_for_openai_function_result_without_call_id.

"""Test Responses API function output omits an optional call ID."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
content = Content.from_function_result(result="Simple result")

result = client._prepare_content_for_openai("user", content)

assert result == {
"type": "function_call_output",
"output": "Simple result",
}


def test_parse_chunk_from_openai_code_interpreter() -> None:
"""Test _parse_chunk_from_openai with code_interpreter_call."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
Expand Down
Loading