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()