From ccf55cd7169f1e6d6589a438a2ec8af247410779 Mon Sep 17 00:00:00 2001 From: aarshx05 <115548002+aarshx05@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:56:52 +0530 Subject: [PATCH 1/3] fix(chatbot): handle structured/list message content from newer LLM providers Signed-off-by: aarshx05 <115548002+aarshx05@users.noreply.github.com> --- services/chatbot/src/chatbot/agent_utils.py | 25 +++++++++++ services/chatbot/src/chatbot/chat_service.py | 11 +++-- .../src/chatbot/tests/test_agent_utils.py | 42 +++++++++++++++++++ 3 files changed, 74 insertions(+), 4 deletions(-) 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) From bb26081161154cb1563f4c466f664cbcef911869 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 15:32:24 +0000 Subject: [PATCH 2/3] ci: wait for crAPI services to be healthy before running Newman tests The Newman integration tests were failing because docker compose up -d returns immediately while services are still booting. Added --wait flag so compose blocks until all health checks pass, plus a secondary curl loop to verify the gateway is actually accepting connections on port 30080 before Newman runs. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_011FfMVcQ5F2gnZidb2gtuLU --- .github/workflows/pr-build.yml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index ecc733b89..6f8fd76f5 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -167,7 +167,22 @@ jobs: - name: Run crAPI using built images run: | cd deploy/docker - VERSION=${{ env.TAG_NAME }} docker compose -f docker-compose.yml --compatibility up -d + VERSION=${{ env.TAG_NAME }} docker compose -f docker-compose.yml --compatibility up -d --wait --wait-timeout 300 + + - name: Wait for crAPI gateway to be ready + run: | + echo "Waiting for crAPI gateway on port 30080..." + for i in $(seq 1 60); do + if curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:30080/health 2>/dev/null | grep -qE '(200|301|302|404)'; then + echo "crAPI gateway is ready!" + exit 0 + fi + echo "Attempt $i/60: gateway not ready yet, waiting 5s..." + sleep 5 + done + echo "crAPI gateway failed to become ready within 300s" + docker compose -f deploy/docker/docker-compose.yml ps + exit 1 - name: Install Node uses: actions/setup-node@v3 From 39621b87513fda40a18625d70ad8434811eb74cf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 15:34:38 +0000 Subject: [PATCH 3/3] Revert "ci: wait for crAPI services to be healthy before running Newman tests" This reverts commit bb26081161154cb1563f4c466f664cbcef911869. --- .github/workflows/pr-build.yml | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 6f8fd76f5..ecc733b89 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -167,22 +167,7 @@ jobs: - name: Run crAPI using built images run: | cd deploy/docker - VERSION=${{ env.TAG_NAME }} docker compose -f docker-compose.yml --compatibility up -d --wait --wait-timeout 300 - - - name: Wait for crAPI gateway to be ready - run: | - echo "Waiting for crAPI gateway on port 30080..." - for i in $(seq 1 60); do - if curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:30080/health 2>/dev/null | grep -qE '(200|301|302|404)'; then - echo "crAPI gateway is ready!" - exit 0 - fi - echo "Attempt $i/60: gateway not ready yet, waiting 5s..." - sleep 5 - done - echo "crAPI gateway failed to become ready within 300s" - docker compose -f deploy/docker/docker-compose.yml ps - exit 1 + VERSION=${{ env.TAG_NAME }} docker compose -f docker-compose.yml --compatibility up -d - name: Install Node uses: actions/setup-node@v3