Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 28 additions & 29 deletions lib/crewai/src/crewai/utilities/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
36 changes: 36 additions & 0 deletions lib/crewai/tests/utilities/test_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from crewai.utilities.converter import (
Converter,
ConverterError,
async_handle_partial_json,
convert_to_model,
convert_with_instructions,
create_converter,
Expand Down Expand Up @@ -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(
Expand Down