Skip to content

Commit cdb51e6

Browse files
Python: fix thread serialization for multi-turn tool calls (#4684)
* Python: strip fc_id from loaded history * Move fc_id replay handling into Responses client Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove unnecessary pytest asyncio marker Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add Responses integration test for fc_id replay Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * removed old arg --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent cbcdb2d commit cdb51e6

4 files changed

Lines changed: 373 additions & 56 deletions

File tree

python/packages/core/agent_framework/openai/_responses_client.py

Lines changed: 37 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1032,24 +1032,27 @@ def _prepare_messages_for_openai(self, chat_messages: Sequence[Message]) -> list
10321032
Returns:
10331033
The prepared chat messages for a request.
10341034
"""
1035-
call_id_to_id: dict[str, str] = {}
1036-
for message in chat_messages:
1037-
for content in message.contents:
1038-
if (
1039-
content.type == "function_call"
1040-
and content.additional_properties
1041-
and "fc_id" in content.additional_properties
1042-
and content.additional_properties["fc_id"]
1043-
):
1044-
call_id_to_id[content.call_id] = content.additional_properties["fc_id"] # type: ignore[attr-defined, index]
1045-
list_of_list = [self._prepare_message_for_openai(message, call_id_to_id) for message in chat_messages]
1035+
list_of_list = [self._prepare_message_for_openai(message) for message in chat_messages]
10461036
# Flatten the list of lists into a single list
10471037
return list(chain.from_iterable(list_of_list))
10481038

1039+
@staticmethod
1040+
def _message_replays_provider_context(message: Message) -> bool:
1041+
"""Return whether the message came from provider-attributed replay context.
1042+
1043+
Responses ``fc_id`` values are response-scoped and only valid while replaying
1044+
the same live tool loop. Once a message comes back through a context provider
1045+
(for example, loaded session history), that message is historical input and
1046+
must not reuse the original response-scoped ``fc_id``.
1047+
"""
1048+
additional_properties = getattr(message, "additional_properties", None)
1049+
if not additional_properties:
1050+
return False
1051+
return "_attribution" in additional_properties
1052+
10491053
def _prepare_message_for_openai(
10501054
self,
10511055
message: Message,
1052-
call_id_to_id: dict[str, str],
10531056
) -> list[dict[str, Any]]:
10541057
"""Prepare a chat message for the OpenAI Responses API format."""
10551058
all_messages: list[dict[str, Any]] = []
@@ -1067,39 +1070,41 @@ def _prepare_message_for_openai(
10671070
case "text_reasoning":
10681071
if not has_function_call:
10691072
continue # reasoning not followed by a function_call is invalid in input
1070-
reasoning = self._prepare_content_for_openai(message.role, content, call_id_to_id) # type: ignore[arg-type]
1073+
reasoning = self._prepare_content_for_openai(message.role, content, message=message)
10711074
if reasoning:
10721075
all_messages.append(reasoning)
10731076
case "function_result":
10741077
new_args: dict[str, Any] = {}
1075-
new_args.update(self._prepare_content_for_openai(message.role, content, call_id_to_id)) # type: ignore[arg-type]
1078+
new_args.update(self._prepare_content_for_openai(message.role, content, message=message))
10761079
if new_args:
10771080
all_messages.append(new_args)
10781081
case "function_call":
1079-
function_call = self._prepare_content_for_openai(message.role, content, call_id_to_id) # type: ignore[arg-type]
1082+
function_call = self._prepare_content_for_openai(message.role, content, message=message)
10801083
if function_call:
1081-
all_messages.append(function_call) # type: ignore
1084+
all_messages.append(function_call)
10821085
case "function_approval_response" | "function_approval_request":
1083-
prepared = self._prepare_content_for_openai(Role(message.role), content, call_id_to_id)
1086+
prepared = self._prepare_content_for_openai(message.role, content, message=message)
10841087
if prepared:
1085-
all_messages.append(prepared) # type: ignore
1088+
all_messages.append(prepared)
10861089
case _:
1087-
prepared_content = self._prepare_content_for_openai(message.role, content, call_id_to_id) # type: ignore
1090+
prepared_content = self._prepare_content_for_openai(message.role, content, message=message)
10881091
if prepared_content:
10891092
if "content" not in args:
10901093
args["content"] = []
1091-
args["content"].append(prepared_content) # type: ignore
1094+
args["content"].append(prepared_content) # type: ignore[reportUnknownMemberType]
10921095
if "content" in args or "tool_calls" in args:
10931096
all_messages.append(args)
10941097
return all_messages
10951098

10961099
def _prepare_content_for_openai(
10971100
self,
1098-
role: Role,
1101+
role: Role | str,
10991102
content: Content,
1100-
call_id_to_id: dict[str, str],
1103+
*,
1104+
message: Message | None = None,
11011105
) -> dict[str, Any]:
11021106
"""Prepare content for the OpenAI Responses API format."""
1107+
role = Role(role)
11031108
match content.type:
11041109
case "text":
11051110
if role == "assistant":
@@ -1174,8 +1179,15 @@ def _prepare_content_for_openai(
11741179
if not content.call_id:
11751180
logger.warning(f"FunctionCallContent missing call_id for function '{content.name}'")
11761181
return {}
1177-
# Use fc_id from additional_properties if available, otherwise fallback to call_id
1178-
fc_id = call_id_to_id.get(content.call_id, content.call_id)
1182+
fc_id = content.call_id
1183+
if (
1184+
message is not None
1185+
and not self._message_replays_provider_context(message)
1186+
and content.additional_properties
1187+
):
1188+
live_fc_id = content.additional_properties.get("fc_id")
1189+
if isinstance(live_fc_id, str) and live_fc_id:
1190+
fc_id = live_fc_id
11791191
# OpenAI Responses API requires IDs to start with `fc_`
11801192
if not fc_id.startswith("fc_"):
11811193
fc_id = f"fc_{fc_id}"
@@ -1221,7 +1233,7 @@ def _prepare_content_for_openai(
12211233
if item.type == "text":
12221234
output_parts.append({"type": "input_text", "text": item.text or ""})
12231235
else:
1224-
part = self._prepare_content_for_openai("user", item, call_id_to_id) # type: ignore[arg-type]
1236+
part = self._prepare_content_for_openai("user", item)
12251237
if part:
12261238
output_parts.append(part)
12271239
if output_parts:

python/packages/core/tests/core/test_agents.py

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
import contextlib
44
import inspect
5+
import json
56
from collections.abc import AsyncIterable, MutableSequence
67
from typing import Any
7-
from unittest.mock import AsyncMock, MagicMock
8+
from unittest.mock import AsyncMock, MagicMock, patch
89
from uuid import uuid4
910

1011
import pytest
@@ -1943,6 +1944,128 @@ async def test_stores_by_default_with_store_false_in_default_options_injects_inm
19431944
assert any(isinstance(p, InMemoryHistoryProvider) for p in agent.context_providers)
19441945

19451946

1947+
async def test_shared_local_storage_cross_provider_responses_history_does_not_leak_fc_id() -> None:
1948+
"""Responses-specific replay metadata should stay local to Responses when session storage is shared."""
1949+
from openai.types.chat.chat_completion import ChatCompletion, Choice
1950+
from openai.types.chat.chat_completion_message import ChatCompletionMessage
1951+
1952+
from agent_framework._sessions import InMemoryHistoryProvider
1953+
from agent_framework.openai import OpenAIChatClient, OpenAIResponsesClient
1954+
1955+
@tool(approval_mode="never_require")
1956+
def search_hotels(city: str) -> str:
1957+
return f"Found 3 hotels in {city}"
1958+
1959+
responses_client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
1960+
responses_agent = Agent(
1961+
client=responses_client,
1962+
tools=[search_hotels],
1963+
default_options={"store": False},
1964+
)
1965+
session = responses_agent.create_session()
1966+
1967+
responses_tool_call = MagicMock()
1968+
responses_tool_call.type = "function_call"
1969+
responses_tool_call.id = "fc_provider123"
1970+
responses_tool_call.call_id = "call_1"
1971+
responses_tool_call.name = "search_hotels"
1972+
responses_tool_call.arguments = '{"city": "Paris"}'
1973+
responses_tool_call.status = "completed"
1974+
1975+
responses_first = MagicMock()
1976+
responses_first.output_parsed = None
1977+
responses_first.metadata = {}
1978+
responses_first.usage = None
1979+
responses_first.id = "resp_1"
1980+
responses_first.model = "test-model"
1981+
responses_first.created_at = 1000000000
1982+
responses_first.status = "completed"
1983+
responses_first.finish_reason = "tool_calls"
1984+
responses_first.incomplete = None
1985+
responses_first.output = [responses_tool_call]
1986+
1987+
responses_text_item = MagicMock()
1988+
responses_text_item.type = "message"
1989+
responses_text_content = MagicMock()
1990+
responses_text_content.type = "output_text"
1991+
responses_text_content.text = "Hotel Lutetia is the cheapest option."
1992+
responses_text_item.content = [responses_text_content]
1993+
1994+
responses_second = MagicMock()
1995+
responses_second.output_parsed = None
1996+
responses_second.metadata = {}
1997+
responses_second.usage = None
1998+
responses_second.id = "resp_2"
1999+
responses_second.model = "test-model"
2000+
responses_second.created_at = 1000000001
2001+
responses_second.status = "completed"
2002+
responses_second.finish_reason = "stop"
2003+
responses_second.incomplete = None
2004+
responses_second.output = [responses_text_item]
2005+
2006+
with patch.object(
2007+
responses_client.client.responses,
2008+
"create",
2009+
side_effect=[responses_first, responses_second],
2010+
) as mock_responses_create:
2011+
responses_result = await responses_agent.run("Find me a hotel in Paris", session=session)
2012+
2013+
assert responses_result.text == "Hotel Lutetia is the cheapest option."
2014+
assert any(isinstance(provider, InMemoryHistoryProvider) for provider in responses_agent.context_providers)
2015+
2016+
shared_messages = session.state[InMemoryHistoryProvider.DEFAULT_SOURCE_ID]["messages"]
2017+
shared_function_call = next(
2018+
content for message in shared_messages for content in message.contents if content.type == "function_call"
2019+
)
2020+
assert shared_function_call.additional_properties is not None
2021+
assert shared_function_call.additional_properties.get("fc_id") == "fc_provider123"
2022+
2023+
responses_replay_input = mock_responses_create.call_args_list[1].kwargs["input"]
2024+
responses_replay_call = next(item for item in responses_replay_input if item.get("type") == "function_call")
2025+
assert responses_replay_call["id"] == "fc_provider123"
2026+
2027+
chat_client = OpenAIChatClient(model_id="test-model", api_key="test-key")
2028+
chat_agent = Agent(client=chat_client)
2029+
2030+
chat_response = ChatCompletion(
2031+
id="chatcmpl-test",
2032+
object="chat.completion",
2033+
created=1234567890,
2034+
model="gpt-4o-mini",
2035+
choices=[
2036+
Choice(
2037+
index=0,
2038+
message=ChatCompletionMessage(role="assistant", content="The cheapest option is still Hotel Lutetia."),
2039+
finish_reason="stop",
2040+
)
2041+
],
2042+
)
2043+
2044+
with patch.object(
2045+
chat_client.client.chat.completions,
2046+
"create",
2047+
new=AsyncMock(return_value=chat_response),
2048+
) as mock_chat_create:
2049+
chat_result = await chat_agent.run("Which option is cheapest?", session=session)
2050+
2051+
assert chat_result.text == "The cheapest option is still Hotel Lutetia."
2052+
2053+
chat_request_messages = mock_chat_create.call_args.kwargs["messages"]
2054+
assistant_tool_call_message = next(
2055+
message for message in chat_request_messages if message.get("role") == "assistant" and message.get("tool_calls")
2056+
)
2057+
assert assistant_tool_call_message["tool_calls"][0]["id"] == "call_1"
2058+
assert assistant_tool_call_message["tool_calls"][0]["function"]["name"] == "search_hotels"
2059+
2060+
tool_result_message = next(
2061+
message
2062+
for message in chat_request_messages
2063+
if message.get("role") == "tool" and message.get("tool_call_id") == "call_1"
2064+
)
2065+
assert tool_result_message["content"] == "Found 3 hotels in Paris"
2066+
assert "fc_provider123" not in json.dumps(chat_request_messages)
2067+
2068+
19462069
# region as_tool user_input_request propagation
19472070

19482071

0 commit comments

Comments
 (0)