From 5d0471b3c94d909542745c88750182643026d04a Mon Sep 17 00:00:00 2001 From: Sudeep Maskebail Date: Mon, 3 Aug 2026 17:08:11 +0530 Subject: [PATCH] Fix agent-openai-agents-sdk multi-turn failure on 2nd+ prompt Apps built from the agent-openai-agents-sdk template fail on the second and later prompts with: agents.exceptions.UserError: Unhandled item type or structure: {... 'content': [{'text': ..., 'type': 'output_text'}], 'role': 'assistant', 'type': 'message'} openai-agents >= 0.19 tightened Converter.maybe_response_output_message in chatcmpl_converter.py to require {"id","content"} on assistant history items. The built-in chat UI replays the prior assistant turn as an id-less {"type":"message","role":"assistant","content":[output_text]} item, and MLflow's ResponsesAgentRequest strips any client-supplied id during normalization, so the replayed item fails recognition and falls through to the catch-all raise. The first prompt has no assistant history to replay, so it never triggers. The template pins openai-agents>=0.4.1 with no upper bound and no lockfile, so fresh installs resolve onto a build containing the change. Fix: collapse replayed id-less assistant output_text items to the easy-input {"role","content"} form before Runner.run, in both invoke_handler and stream_handler. That form is recognized by maybe_easy_input_message (no id required), so the fix is independent of the installed openai-agents version. Multiple output_text segments are joined with a newline to match the SDK converter byte-for-byte. The helper lives in a dependency-free agent_server/history.py so it is unit-testable in isolation. Note: pinning openai-agents below 0.18 is not a viable workaround -- it resolves to 0.17.8, whose older model layer is incompatible with the current openai token-usage schema and fails every prompt with "InputTokensDetails: cache_write_tokens Field required". Co-authored-by: Isaac --- agent-openai-agents-sdk/agent_server/agent.py | 5 +- .../agent_server/history.py | 40 +++++++++ .../tests/test_history_normalization.py | 85 +++++++++++++++++++ 3 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 agent-openai-agents-sdk/agent_server/history.py create mode 100644 agent-openai-agents-sdk/tests/test_history_normalization.py diff --git a/agent-openai-agents-sdk/agent_server/agent.py b/agent-openai-agents-sdk/agent_server/agent.py index 4cdf2987..d7b7989a 100644 --- a/agent-openai-agents-sdk/agent_server/agent.py +++ b/agent-openai-agents-sdk/agent_server/agent.py @@ -16,6 +16,7 @@ ResponsesAgentStreamEvent, ) +from agent_server.history import normalize_history_items from agent_server.utils import ( build_mcp_url, get_session_id, @@ -99,7 +100,7 @@ async def invoke_handler(request: ResponsesAgentRequest) -> ResponsesAgentRespon # for on-behalf-of user authentication. async with AsyncExitStack() as stack: agent = create_agent() - messages = [i.model_dump() for i in request.input] + messages = normalize_history_items([i.model_dump() for i in request.input]) result = await Runner.run(agent, messages) return ResponsesAgentResponse(output=[item.to_input_item() for item in result.new_items]) @@ -121,7 +122,7 @@ async def stream_handler( # for on-behalf-of user authentication. async with AsyncExitStack() as stack: agent = create_agent() - messages = [i.model_dump() for i in request.input] + messages = normalize_history_items([i.model_dump() for i in request.input]) result = Runner.run_streamed(agent, input=messages) async for event in process_agent_stream_events(result.stream_events()): diff --git a/agent-openai-agents-sdk/agent_server/history.py b/agent-openai-agents-sdk/agent_server/history.py new file mode 100644 index 00000000..1558b729 --- /dev/null +++ b/agent-openai-agents-sdk/agent_server/history.py @@ -0,0 +1,40 @@ +"""Conversation-history normalization helpers. + +Kept dependency-free (no Databricks SDK / MLflow imports) so it can be unit +tested in isolation. +""" + + +def normalize_history_items(messages: list[dict]) -> list[dict]: + """Normalize replayed assistant history items for Chat Completions. + + openai-agents >= 0.19 tightened ``Converter.maybe_response_output_message`` + to require ``{"id", "content"}`` on assistant history items. The built-in + chat UI replays the prior assistant turn as an id-less + ``{"type": "message", "role": "assistant", "content": [output_text]}`` item, + and MLflow's ``ResponsesAgentRequest`` strips any client-supplied ``id``, so + on the second and later prompts the item fails recognition and hits the + catch-all ``UserError: Unhandled item type or structure``. + + Collapse those replayed assistant items to the easy-input + ``{"role", "content"}`` form, which is recognized by + ``maybe_easy_input_message`` (no id required), sidestepping the tightened + guard regardless of SDK version. Multiple ``output_text`` segments are joined + with ``"\\n"`` to match the SDK converter's own behavior byte-for-byte. + """ + normalized: list[dict] = [] + for m in messages: + if ( + m.get("type") == "message" + and m.get("role") == "assistant" + and isinstance(m.get("content"), list) + ): + text = "\n".join( + part.get("text", "") + for part in m["content"] + if isinstance(part, dict) and part.get("type") == "output_text" + ) + normalized.append({"role": "assistant", "content": text}) + else: + normalized.append(m) + return normalized diff --git a/agent-openai-agents-sdk/tests/test_history_normalization.py b/agent-openai-agents-sdk/tests/test_history_normalization.py new file mode 100644 index 00000000..f006e2d8 --- /dev/null +++ b/agent-openai-agents-sdk/tests/test_history_normalization.py @@ -0,0 +1,85 @@ +"""Regression tests for multi-turn assistant-history normalization. + +openai-agents >= 0.19 tightened ``Converter.maybe_response_output_message`` to +require ``{"id", "content"}`` on assistant history items. The built-in chat UI +replays the prior assistant turn as an id-less ``{"type": "message", +"role": "assistant", "content": [output_text]}`` item, and MLflow's +``ResponsesAgentRequest`` strips any client-supplied ``id``, so on the second +and later prompts the item failed recognition and hit the catch-all +``UserError: Unhandled item type or structure``. + +``normalize_history_items`` collapses those replayed assistant items to the +easy-input ``{"role", "content"}`` form, which is recognized without an id. +These tests lock in that transformation so the regression cannot return silently. +""" + +import unittest + +from agent_server.history import normalize_history_items + + +class NormalizeHistoryItemsTest(unittest.TestCase): + def test_idless_assistant_output_text_collapsed_to_easy_input(self): + # Exactly what the chat UI replays on the 2nd prompt (no "id"). + messages = [ + {"role": "user", "content": "hello"}, + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "Hi! How can I help?"}], + }, + {"role": "user", "content": "test"}, + ] + self.assertEqual( + normalize_history_items(messages), + [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "Hi! How can I help?"}, + {"role": "user", "content": "test"}, + ], + ) + + def test_multiple_output_text_segments_joined_with_newline(self): + # Matches the SDK converter, which joins segments with "\n". + messages = [ + { + "type": "message", + "role": "assistant", + "content": [ + {"type": "output_text", "text": "part A"}, + {"type": "output_text", "text": "part B"}, + ], + } + ] + self.assertEqual( + normalize_history_items(messages), + [{"role": "assistant", "content": "part A\npart B"}], + ) + + def test_user_and_plain_assistant_items_are_untouched(self): + messages = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "already easy-input"}, + ] + self.assertEqual(normalize_history_items(messages), messages) + + def test_non_output_text_content_parts_are_dropped_from_text(self): + # Only output_text parts contribute to the collapsed string. + messages = [ + { + "type": "message", + "role": "assistant", + "content": [ + {"type": "output_text", "text": "keep"}, + {"type": "something_else", "foo": "bar"}, + ], + } + ] + self.assertEqual( + normalize_history_items(messages), + [{"role": "assistant", "content": "keep"}], + ) + + +if __name__ == "__main__": + unittest.main()