diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fcbd6f1b..4c15f9ced 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] ### Fixed +- **v2 message handling**: `merge_consecutive_messages` no longer crashes with `AttributeError` when two consecutive same-role messages both have `content=None` (e.g. consecutive tool-call-only assistant turns in a replayed conversation history), affecting the OpenAI, Mistral, and Writer JSON-mode handlers. - **v2 message handling**: Preserve caller-owned message lists and nested content across request preparation and retries for OpenAI-compatible, Cohere, Mistral, OpenRouter, Writer, and xAI handlers. ([#2417](https://github.com/567-labs/instructor/issues/2417), [#2428](https://github.com/567-labs/instructor/issues/2428)) - **v2 JSON extraction**: Prefer the final complete top-level JSON value in text responses and retain every JSON object when multiple objects arrive in one streaming chunk. - **v2 schemas**: Treat fields with Pydantic `default_factory` values as optional in generated OpenAI tool schemas. diff --git a/instructor/v2/core/messages.py b/instructor/v2/core/messages.py index 9a24c81ea..c7aac4d5d 100644 --- a/instructor/v2/core/messages.py +++ b/instructor/v2/core/messages.py @@ -83,6 +83,8 @@ def merge_consecutive_messages(messages: list[dict[str, Any]]) -> list[dict[str, for message in messages: role = message.get("role", "user") new_content = message.get("content", "") + if new_content is None: + new_content = "" if not flat_string and isinstance(new_content, str): new_content = [{"type": "text", "text": new_content}] diff --git a/tests/processing/test_message_processing.py b/tests/processing/test_message_processing.py index cdc990aa9..6d3fe24b5 100644 --- a/tests/processing/test_message_processing.py +++ b/tests/processing/test_message_processing.py @@ -88,6 +88,27 @@ def test_multiple_consecutive(self): assert result[2]["role"] == "user" assert "I need help" in result[2]["content"] + def test_consecutive_none_content(self): + """Consecutive same-role messages with content=None (e.g. tool-call-only + assistant turns) must not crash the merge.""" + messages = [ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": None, + "tool_calls": [{"id": "call_1"}], + }, + { + "role": "assistant", + "content": None, + "tool_calls": [{"id": "call_2"}], + }, + ] + result = merge_consecutive_messages(messages) + assert len(result) == 2 + assert result[0]["role"] == "user" + assert result[1]["role"] == "assistant" + class TestGetMessageContent: """Test the get_message_content function."""