Skip to content

Commit 5c3b41a

Browse files
fix(tools): join char-split JSON arrays from local LLMs
Some models emit tool array args as one string per character (e.g. UpdateTodoList tasks). Rejoin and parse before normalize_task_items / ReadRange show ops. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 41d8086 commit 5c3b41a

3 files changed

Lines changed: 100 additions & 1 deletion

File tree

cecli/tools/utils/helpers.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,12 +346,46 @@ def format_tool_result(
346346
return result_for_llm
347347

348348

349+
def _try_join_char_split_json_array(items: list) -> list | None:
350+
"""
351+
Some local models emit a JSON array as one string per character in tool args.
352+
353+
Example: tasks=["[", "{", "\\"", "t", "a", "s", "k", "\\"", ...] instead of
354+
tasks='[{"task": "...", "done": false}]'.
355+
"""
356+
if len(items) < 8:
357+
return None
358+
if not all(isinstance(x, str) for x in items):
359+
return None
360+
joined = "".join(items).strip()
361+
if not joined.startswith(("[", "{")):
362+
return None
363+
try:
364+
parsed = json.loads(joined)
365+
except json.JSONDecodeError:
366+
return None
367+
if isinstance(parsed, dict):
368+
return [parsed]
369+
if isinstance(parsed, list):
370+
return parsed
371+
return None
372+
373+
349374
def normalize_json_array(value, *, param_name: str = "items", allow_empty: bool = False) -> list:
350375
"""
351376
Coerce tool args that should be arrays but sometimes arrive as JSON strings.
352377
353-
Local models occasionally double-encode array parameters as JSON text.
378+
Local models occasionally double-encode array parameters as JSON text, or emit
379+
arrays as per-character string lists (see ``_try_join_char_split_json_array``).
354380
"""
381+
if isinstance(value, list):
382+
coerced = _try_join_char_split_json_array(value)
383+
if coerced is not None:
384+
value = coerced
385+
elif len(value) == 1 and isinstance(value[0], str):
386+
# Single element wrapping the whole JSON array/object as a string.
387+
value = value[0]
388+
355389
if isinstance(value, str):
356390
text = value.strip()
357391
if not text:

tests/tools/test_get_lines.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,13 @@ def test_format_output_accepts_show_as_json_string(coder_with_file):
184184
assert "example.txt" in output_text
185185
assert "alpha" in output_text
186186
coder.io.tool_error.assert_not_called()
187+
188+
189+
def test_normalize_show_ops_joins_char_split_json_list():
190+
show_json = json.dumps(
191+
[{"file_path": "docs/ROADMAP.md", "start_text": "@000", "end_text": "\\n"}]
192+
)
193+
ops = normalize_show_ops(list(show_json))
194+
assert len(ops) == 1
195+
assert ops[0]["file_path"] == "docs/ROADMAP.md"
196+
assert ops[0]["start_text"] == "@000"

tests/tools/test_update_todo_list.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,35 @@ def test_normalize_task_items_accepts_list_of_json_strings():
5050
assert items[1]["task"] == "Second"
5151

5252

53+
def test_normalize_json_array_joins_char_split_json_list():
54+
"""Ollama sometimes sends tasks as a list of single-character strings."""
55+
chars = list(
56+
'[{"task": "Explore the codebase", "done": false, "current": true},'
57+
'{"task": "Draft roadmap", "done": false}]'
58+
)
59+
items = normalize_json_array(chars, param_name="tasks")
60+
assert len(items) == 2
61+
assert items[0]["task"] == "Explore the codebase"
62+
assert items[1]["task"] == "Draft roadmap"
63+
64+
65+
def test_normalize_json_array_unwraps_single_element_json_string_list():
66+
wrapped = [
67+
'[{"task": "Only task", "done": false}]',
68+
]
69+
items = normalize_json_array(wrapped, param_name="tasks")
70+
assert len(items) == 1
71+
assert items[0]["task"] == "Only task"
72+
73+
74+
def test_normalize_task_items_from_char_split_list():
75+
chars = list(json.dumps([{"task": "Ship tests", "done": True}]))
76+
items = normalize_task_items(chars)
77+
assert len(items) == 1
78+
assert items[0]["task"] == "Ship tests"
79+
assert items[0]["done"] is True
80+
81+
5382
def test_normalize_task_items_does_not_split_characters():
5483
tasks_json = json.dumps([{"task": "Only one task", "done": False}])
5584
items = normalize_task_items(tasks_json)
@@ -103,6 +132,32 @@ def test_format_output_accepts_tasks_as_json_string():
103132
coder.io.tool_error.assert_not_called()
104133

105134

135+
def test_format_output_accepts_char_split_tasks_list():
136+
"""Reproduces BrightVision bug: tasks array is one JSON character per element."""
137+
coder = DummyCoder()
138+
tasks_json = (
139+
'[{"task": "Explore the codebase", "done": false, "current": true},'
140+
'{"task": "Draft roadmap items", "done": false}]'
141+
)
142+
args = json.dumps({"tasks": list(tasks_json)})
143+
tool_response = SimpleNamespace(function=SimpleNamespace(name="UpdateTodoList", arguments=args))
144+
145+
update_todo_list.Tool.format_output(
146+
coder,
147+
mcp_server=SimpleNamespace(name="test"),
148+
tool_response=tool_response,
149+
)
150+
151+
output_text = "\n".join(call.args[0] for call in coder.io.tool_output.call_args_list)
152+
assert "Explore the codebase" in output_text
153+
assert "Draft roadmap items" in output_text
154+
assert output_text.count("○ ") == 1
155+
assert "→ Explore the codebase" in output_text
156+
assert "○ Draft roadmap items" in output_text
157+
assert all(len(line.strip()) > 3 for line in output_text.splitlines() if line.startswith("○ "))
158+
coder.io.tool_error.assert_not_called()
159+
160+
106161
def test_format_output_reports_invalid_tool_arguments_json():
107162
coder = DummyCoder()
108163
tool_response = SimpleNamespace(

0 commit comments

Comments
 (0)