From d05a4bbac7f43b457102ecde3fab4a47383c5a54 Mon Sep 17 00:00:00 2001 From: Jessica Mulein Date: Tue, 2 Jun 2026 16:32:57 -0700 Subject: [PATCH] fix(tools): lenient UpdateTodoList tasks JSON in normalize_json_array Local models emit glued tool args and unescaped inner quotes on tasks arrays. Add try_parse_lenient_task_array and wire it through normalize_json_array so UpdateTodoList survives qwen-style { "tasks": "[{...}]" }{ "path": "..." }. Co-authored-by: Cursor --- cecli/helpers/responses.py | 38 ++++++++++++++++++++++++++++++ cecli/tools/utils/helpers.py | 18 ++++++++++---- tests/tools/test_tool_arguments.py | 12 ++++++++++ 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/cecli/helpers/responses.py b/cecli/helpers/responses.py index 87d10a2daff..be0a43147e0 100644 --- a/cecli/helpers/responses.py +++ b/cecli/helpers/responses.py @@ -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. diff --git a/cecli/tools/utils/helpers.py b/cecli/tools/utils/helpers.py index f05e2eda8f9..dd647d0aa7f 100644 --- a/cecli/tools/utils/helpers.py +++ b/cecli/tools/utils/helpers.py @@ -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): diff --git a/tests/tools/test_tool_arguments.py b/tests/tools/test_tool_arguments.py index c06ff1d5d83..b3277f27c58 100644 --- a/tests/tools/test_tool_arguments.py +++ b/tests/tools/test_tool_arguments.py @@ -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": "."}}'