diff --git a/services/chatbot/src/chatbot/agent_utils.py b/services/chatbot/src/chatbot/agent_utils.py index 16cd57192..976def6f4 100644 --- a/services/chatbot/src/chatbot/agent_utils.py +++ b/services/chatbot/src/chatbot/agent_utils.py @@ -15,6 +15,31 @@ CHARS_PER_TOKEN = 4 +def message_content_to_text(content) -> str: + """Normalize an LLM message's ``content`` to plain text. + + The OpenAI provider and older models return ``content`` as a ``str``, but + newer models (e.g. recent Anthropic Claude models) return it as a list of + structured content blocks (text / thinking / tool-use / signed text). + Downstream the value is stored in the Mongo chat history and passed to + ``Document(page_content=...)``, which requires a ``str`` -- a list makes it + raise ``pydantic.ValidationError`` and the whole turn is lost (chat appears + to fail and nothing is persisted). Join the text blocks and drop the rest + so output from any provider is handled. + """ + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for block in content: + if isinstance(block, str): + parts.append(block) + elif isinstance(block, dict) and block.get("type") == "text": + parts.append(block.get("text", "")) + return "".join(parts) + return "" if content is None else str(content) + + def collect_long_strings(obj): field_info = [] diff --git a/services/chatbot/src/chatbot/chat_service.py b/services/chatbot/src/chatbot/chat_service.py index 8f5de1f7d..646f43417 100644 --- a/services/chatbot/src/chatbot/chat_service.py +++ b/services/chatbot/src/chatbot/chat_service.py @@ -3,7 +3,7 @@ from langgraph.graph.message import Messages -from .agent_utils import trim_messages_to_token_limit +from .agent_utils import message_content_to_text, trim_messages_to_token_limit from .config import Config from .extensions import db from .langgraph_agent import execute_langgraph_agent @@ -64,9 +64,12 @@ async def process_user_message(session_id, user_message, api_key, model_name, us logger.debug("Response messages count: %d", len(response.get("messages", []))) reply: Messages = response.get("messages", [{}])[-1] + # Newer models can return `content` as a list of structured blocks rather + # than a plain string; normalize it before storing / serializing it. + reply_text = message_content_to_text(reply.content) response_message_id = uuid4().int & (1 << 63) - 1 history.append( - {"id": response_message_id, "role": "assistant", "content": reply.content} + {"id": response_message_id, "role": "assistant", "content": reply_text} ) logger.debug( "Added assistant response to history - message_id: %s", response_message_id @@ -75,7 +78,7 @@ async def process_user_message(session_id, user_message, api_key, model_name, us add_to_chroma_collection( api_key, session_id, - [{"user": user_message}, {"assistant": reply.content}], + [{"user": user_message}, {"assistant": reply_text}], Config.LLM_PROVIDER, model_name, ) @@ -89,4 +92,4 @@ async def process_user_message(session_id, user_message, api_key, model_name, us response_message_id, len(history), ) - return reply.content, response_message_id + return reply_text, response_message_id diff --git a/services/chatbot/src/chatbot/tests/test_agent_utils.py b/services/chatbot/src/chatbot/tests/test_agent_utils.py index 790234116..32cfb2143 100644 --- a/services/chatbot/src/chatbot/tests/test_agent_utils.py +++ b/services/chatbot/src/chatbot/tests/test_agent_utils.py @@ -50,6 +50,7 @@ CHARS_PER_TOKEN, _estimate_tokens, _message_content, + message_content_to_text, trim_messages_to_token_limit, ) @@ -190,3 +191,44 @@ def test_does_not_mutate_original(self): original_len = len(msgs) trim_messages_to_token_limit(msgs) assert len(msgs) == original_len + + +# --------------------------------------------------------------------------- +# message_content_to_text +# --------------------------------------------------------------------------- + +class TestMessageContentToText: + def test_plain_string_passthrough(self): + assert message_content_to_text("hello") == "hello" + + def test_list_of_text_blocks(self): + # Newer Anthropic models return signed text blocks as a list. + content = [{"type": "text", "text": "OK", "signature": "CAQS..."}] + assert message_content_to_text(content) == "OK" + + def test_skips_non_text_blocks(self): + # thinking / tool_use blocks must be dropped, text kept and joined. + content = [ + {"type": "thinking", "thinking": "...", "signature": "x"}, + {"type": "tool_use", "name": "sql", "input": {}}, + {"type": "text", "text": "Answer"}, + ] + assert message_content_to_text(content) == "Answer" + + def test_joins_multiple_text_blocks(self): + content = [{"type": "text", "text": "a"}, {"type": "text", "text": "b"}] + assert message_content_to_text(content) == "ab" + + def test_list_with_bare_strings(self): + assert message_content_to_text(["a", "b"]) == "ab" + + def test_none_returns_empty_string(self): + assert message_content_to_text(None) == "" + + def test_other_types_coerced_to_str(self): + assert message_content_to_text(123) == "123" + + def test_result_is_always_a_string(self): + # The contract the callers rely on (Document(page_content=...) needs str). + for value in ["x", [{"type": "text", "text": "y"}], None, 1, []]: + assert isinstance(message_content_to_text(value), str)