From 6dbc13c083404db2b4f749de9448bf0bcd1aae3d Mon Sep 17 00:00:00 2001 From: ErenAta16 Date: Sun, 12 Jul 2026 16:58:53 +0300 Subject: [PATCH 1/2] fix(converter): don't let trailing braces corrupt valid JSON extraction _JSON_PATTERN = re.compile(r"({.*})", re.DOTALL) greedily matched from the first '{' to the LAST '}' anywhere in the string. If the LLM's output contained a complete, valid JSON object followed by any commentary that itself referenced curly braces (e.g. mentioning the schema, a code snippet, or a set literal), the match swallowed that trailing text too and json.loads failed on the resulting garbage, even though the actual JSON object was perfectly well formed. Replaced the regex-based extraction with json.JSONDecoder.raw_decode starting at the first '{': it parses exactly one JSON value and stops there regardless of what follows, so trailing braces elsewhere in the string can no longer corrupt an otherwise-valid match. This preserves the existing fall-through behavior for brace-delimited non-JSON content (e.g. GraphQL schemas, fixed by #5461/#5460) since raw_decode still raises JSONDecodeError on those. Applied to both handle_partial_json and async_handle_partial_json, which shared the same pattern. Also drops the now-unused re import. Verified against the existing test suite (51/52 passing; the one failure is a pre-existing, unrelated missing-litellm-dependency issue in my sandbox, reproduced identically on unpatched main). --- lib/crewai/src/crewai/utilities/converter.py | 57 ++++++++++---------- lib/crewai/tests/utilities/test_converter.py | 18 +++++++ 2 files changed, 46 insertions(+), 29 deletions(-) diff --git a/lib/crewai/src/crewai/utilities/converter.py b/lib/crewai/src/crewai/utilities/converter.py index d31b76f48a..c8ce156209 100644 --- a/lib/crewai/src/crewai/utilities/converter.py +++ b/lib/crewai/src/crewai/utilities/converter.py @@ -2,8 +2,7 @@ import asyncio import json -import re -from typing import TYPE_CHECKING, Any, Final, TypedDict +from typing import TYPE_CHECKING, Any, TypedDict from crewai_core.printer import PRINTER from pydantic import BaseModel, ValidationError @@ -21,10 +20,32 @@ from crewai.llm import LLM from crewai.llms.base_llm import BaseLLM -_JSON_PATTERN: Final[re.Pattern[str]] = re.compile(r"({.*})", re.DOTALL) _I18N = I18N_DEFAULT +def _extract_first_json_object(result: str) -> Any: + """Parse the first complete JSON value starting at the first ``{`` in + ``result``, ignoring anything that follows it. + + A plain ``{.*}`` regex over the whole string is greedy: if trailing text + beyond the real JSON object also contains ``{``/``}`` characters (e.g. + LLM commentary that references curly-brace syntax), the regex swallows + that text too and the result fails to parse even though the JSON object + itself was perfectly well formed. ``json.JSONDecoder.raw_decode`` parses + exactly one JSON value starting at the given index and stops there, + regardless of what follows it, so trailing braces elsewhere in the + string can't corrupt the match. + """ + start = result.find("{") + if start == -1: + return None + try: + parsed, _end = json.JSONDecoder(strict=False).raw_decode(result, start) + except json.JSONDecodeError: + return None + return parsed + + class ConverterError(Exception): """Error raised when Converter fails to parse the input.""" @@ -296,19 +317,8 @@ def handle_partial_json( Returns: The converted result as a dict, BaseModel, or original string. """ - match = _JSON_PATTERN.search(result) - if match: - try: - parsed = json.loads(match.group(), strict=False) - except json.JSONDecodeError: - return convert_with_instructions( - result=result, - model=model, - is_json_output=is_json_output, - agent=agent, - converter_cls=converter_cls, - ) - + parsed = _extract_first_json_object(result) + if parsed is not None: try: exported_result = model.model_validate(parsed) if is_json_output: @@ -449,19 +459,8 @@ async def async_handle_partial_json( converter_cls: type[Converter] | None = None, ) -> dict[str, Any] | BaseModel | str: """Async equivalent of ``handle_partial_json`` — defers LLM fallback to ``acall``.""" - match = _JSON_PATTERN.search(result) - if match: - try: - parsed = json.loads(match.group(), strict=False) - except json.JSONDecodeError: - return await async_convert_with_instructions( - result=result, - model=model, - is_json_output=is_json_output, - agent=agent, - converter_cls=converter_cls, - ) - + parsed = _extract_first_json_object(result) + if parsed is not None: try: exported_result = model.model_validate(parsed) if is_json_output: diff --git a/lib/crewai/tests/utilities/test_converter.py b/lib/crewai/tests/utilities/test_converter.py index ed6429dacd..a8d26295fe 100644 --- a/lib/crewai/tests/utilities/test_converter.py +++ b/lib/crewai/tests/utilities/test_converter.py @@ -225,6 +225,24 @@ def test_handle_partial_json_falls_through_for_non_json_curly_blocks( mock_convert.assert_called_once() +def test_handle_partial_json_ignores_braces_in_trailing_commentary() -> None: + """Valid JSON followed by commentary that itself contains curly braces + must still be parsed - the trailing text shouldn't be swallowed into the + match just because it also has ``{``/``}`` characters in it. + """ + result = ( + "Here is the extracted data:\n" + '{"name": "Alice", "age": 30}\n\n' + 'Note: this follows the requested schema (see {"type": "object"} ' + "in the task description)." + ) + output = handle_partial_json(result, SimpleModel, False, None) + assert isinstance(output, SimpleModel) + assert output.name == "Alice" + assert output.age == 30 + + + @patch("crewai.utilities.converter.create_converter") @patch("crewai.utilities.converter.get_conversion_instructions") def test_convert_with_instructions_success( From b15a1ed5ffa49d7239a0b0ebfe60da45f593cff6 Mon Sep 17 00:00:00 2001 From: ErenAta16 Date: Sun, 12 Jul 2026 17:17:13 +0300 Subject: [PATCH 2/2] test(converter): add async coverage for trailing-brace-commentary case Addresses CodeRabbit nitpick on #6521: async_handle_partial_json shares _extract_first_json_object with the sync path, so it should get the same regression coverage for the trailing-commentary bug. --- lib/crewai/tests/utilities/test_converter.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/lib/crewai/tests/utilities/test_converter.py b/lib/crewai/tests/utilities/test_converter.py index a8d26295fe..6fdc7a244a 100644 --- a/lib/crewai/tests/utilities/test_converter.py +++ b/lib/crewai/tests/utilities/test_converter.py @@ -7,6 +7,7 @@ from crewai.utilities.converter import ( Converter, ConverterError, + async_handle_partial_json, convert_to_model, convert_with_instructions, create_converter, @@ -242,6 +243,23 @@ def test_handle_partial_json_ignores_braces_in_trailing_commentary() -> None: assert output.age == 30 +@pytest.mark.asyncio +async def test_async_handle_partial_json_ignores_braces_in_trailing_commentary() -> None: + """Async equivalent of the sync test above - async_handle_partial_json + shares the same _extract_first_json_object helper, so it needs the same + coverage for the trailing-brace-commentary case.""" + result = ( + "Here is the extracted data:\n" + '{"name": "Alice", "age": 30}\n\n' + 'Note: this follows the requested schema (see {"type": "object"} ' + "in the task description)." + ) + output = await async_handle_partial_json(result, SimpleModel, False, None) + assert isinstance(output, SimpleModel) + assert output.name == "Alice" + assert output.age == 30 + + @patch("crewai.utilities.converter.create_converter") @patch("crewai.utilities.converter.get_conversion_instructions")