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..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, @@ -225,6 +226,41 @@ 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 + + +@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") def test_convert_with_instructions_success(