Skip to content
Closed
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
38 changes: 38 additions & 0 deletions cecli/helpers/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,44 @@ def _repair_local_model_json_text(text: str) -> str:
return repaired


def try_parse_lenient_task_array(text) -> list[dict] | None:
"""
Parse UpdateTodoList ``tasks`` when the model emits unescaped inner JSON quotes.

Example::
[{"done": true, "task": "Explore crates"}, {"done": false, "task": "Review curl"}]
"""
if isinstance(text, dict):
if "tasks" in text:
text = text["tasks"]
elif "done" in text and "task" in text:
return [text]
else:
return None
if not isinstance(text, str):
return None
raw = text.strip()
if not raw:
return None
if raw.startswith('"') and raw.endswith('"') and "[{" in raw:
raw = raw[1:-1]
if "}{" in raw:
merged = merge_glued_json_objects(utils.split_concatenated_json(raw))
if merged and "tasks" in merged:
raw = str(merged["tasks"])
if not raw.lstrip().startswith("["):
return None
pattern = re.compile(
r'\{\s*"done"\s*:\s*(true|false)\s*,\s*"task"\s*:\s*"((?:\\.|[^"\\])*)"\s*\}',
re.IGNORECASE,
)
rows = [
{"done": m.group(1).lower() == "true", "task": m.group(2).replace('\\"', '"')}
for m in pattern.finditer(raw)
]
return rows if rows else None


def _parse_bracket_arguments(payload_str: str) -> dict:
"""Parse multiple arguments from a bracket-style payload.

Expand Down
18 changes: 14 additions & 4 deletions cecli/tools/utils/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,10 +365,20 @@ def normalize_json_array(value, *, param_name: str = "items", allow_empty: bool
raise ToolError(f"{param_name} array cannot be empty")
parsed = responses.try_parse_json_value(text)
if parsed is None:
try:
parsed = json.loads(text)
except json.JSONDecodeError as err:
raise ToolError(f"Invalid {param_name} parameter JSON: {err}") from err
lenient = responses.try_parse_lenient_task_array(text)
if lenient is not None:
parsed = lenient
else:
try:
parsed = json.loads(text)
except json.JSONDecodeError as err:
raise ToolError(f"Invalid {param_name} parameter JSON: {err}") from err
if isinstance(parsed, dict) and "tasks" in parsed and param_name == "tasks":
nested = responses.try_parse_lenient_task_array(parsed)
if nested is not None:
parsed = nested
elif isinstance(parsed["tasks"], str):
parsed = responses.try_parse_json_value(parsed["tasks"]) or parsed["tasks"]
value = parsed

if isinstance(value, dict):
Expand Down
12 changes: 12 additions & 0 deletions tests/tools/test_tool_arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,18 @@ def test_normalize_json_array_empty_list_with_allow_empty():
assert normalize_json_array([], param_name="items", allow_empty=True) == []


def test_normalize_json_array_lenient_unescaped_tasks():
"""UpdateTodoList tasks with unescaped inner JSON quotes (local qwen)."""
raw = (
'[{"done": true, "task": "Explore project structure"}, '
'{"done": false, "task": "Review curl-reference-code"}]'
)
result = normalize_json_array(raw, param_name="tasks")
assert len(result) == 2
assert result[0]["done"] is True
assert "Explore" in result[0]["task"]


def test_extract_tools_from_content_json_with_arguments_key():
"""Standard tool calls with 'arguments' key should be extracted."""
content = '{"name": "ls", "arguments": {"path": "."}}'
Expand Down
Loading