Skip to content
Merged
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
19 changes: 17 additions & 2 deletions cecli/helpers/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -366,11 +366,18 @@ def unprefix_tool_call(tool_call):
return server_name, result


def parse_tool_arguments(args_string: str) -> dict:
def parse_tool_arguments(args_string: str) -> dict | list:
"""Parse tool-call arguments, merging glued ``{…}{} {…}`` object fragments.

Also unwraps a single ``arguments``/``parameters``/``params`` wrapper key
that some models emit when mirroring the OpenAI wire format.

Returns a dict in almost all cases. A bare top-level JSON array (e.g. a
model emitting ``[...]`` directly as the arguments instead of wrapping it
under the tool's single required array parameter, as EditFile's `edits`
or ReadFile's `read`) is returned as-is rather than collapsed to ``{}``,
so ``BaseTool.process_response`` can still wrap it using the tool's
schema instead of silently losing the data.
"""
text = (args_string or "").strip()
if not text:
Expand All @@ -379,6 +386,8 @@ def parse_tool_arguments(args_string: str) -> dict:
parsed = json.loads(text)
if isinstance(parsed, dict):
return coerce_tool_structure(parsed)
if isinstance(parsed, list):
return parsed
except json.JSONDecodeError:
pass

Expand All @@ -393,12 +402,18 @@ def parse_tool_arguments(args_string: str) -> dict:
lone = try_parse_json_value(chunks[0])
if isinstance(lone, dict):
return coerce_tool_structure(lone)
if isinstance(lone, list):
return lone
try:
json_string = json_repair.repair_json(chunks[0], ensure_ascii=False)
single = json.loads(json_string)
except json.JSONDecodeError as err:
return {"@error": f"Malformed JSON arguments: {err}"}
return coerce_tool_structure(single) if isinstance(single, dict) else {}
if isinstance(single, dict):
return coerce_tool_structure(single)
if isinstance(single, list):
return single
return {}

merged = merge_glued_json_objects(chunks)

Expand Down
38 changes: 21 additions & 17 deletions cecli/tools/utils/base_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,25 +63,29 @@ def process_response(cls, coder, params, _convert=True):
required_params = function_schema["parameters"]["required"]
properties = function_schema["parameters"].get("properties", {})

# Auto-correction: If a required parameter is missing but it's an array,
# and the current params look like a single item of that array, wrap it.
if len(required_params) == 1:
missing_param = required_params[0]
if missing_param not in params and params:
param_schema = properties.get(missing_param, {})
if param_schema.get("type") == "array":
params = {missing_param: [params]}
# Auto-correction: fix common shape mistakes (a bare array or
# a single item of that array sent directly as the whole
# arguments, instead of wrapped under the expected key) BEFORE
# checking for missing required parameters. Otherwise a
# recoverable shape (e.g. a bare `[...]` array) gets rejected
# before it has a chance to be normalized.
params = ToolValidations._basic_validations(params, cls.SCHEMA)

# Auto-correction: If a required parameter is present but is a dict instead of an array
for param_name in required_params:
if param_name in params:
param_schema = properties.get(param_name, {})
if param_schema.get("type") == "array" and isinstance(
params[param_name], dict
):
params[param_name] = [params[param_name]]

missing_params = [param for param in required_params if param not in params]
if isinstance(params, dict):
for param_name in required_params:
if param_name in params:
param_schema = properties.get(param_name, {})
if param_schema.get("type") == "array" and isinstance(
params[param_name], dict
):
params[param_name] = [params[param_name]]

missing_params = [
param
for param in required_params
if not isinstance(params, dict) or param not in params
]
if missing_params:
tool_name = function_schema.get("name", "Unknown Tool")
error_msg = (
Expand Down
24 changes: 18 additions & 6 deletions cecli/tools/validations/validations.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,16 +198,28 @@ def _basic_validations(cls, params: object, schema: dict | None = None) -> dict:

parameters = function_schema["parameters"]
properties = parameters.get("properties", {})

# Only auto-correct when there is exactly one property and it is an array
if len(properties) == 1:
required = parameters.get("required", [])

# Determine the single array-typed parameter to auto-correct into,
# if any. Prefer the schema's `required` list (covers tools that also
# declare optional properties alongside their one required array,
# e.g. EditFile's optional `change_id`); fall back to "exactly one
# property total" when the schema doesn't declare `required` at all.
single_param_name = None
if len(required) == 1:
single_param_name = required[0]
elif not required and len(properties) == 1:
single_param_name = next(iter(properties.keys()))
param_schema = properties[single_param_name]

if single_param_name is not None:
param_schema = properties.get(single_param_name, {})
if param_schema.get("type") == "array":
# Case 1: LLM emitted the array directly (bare list)
# Case 1: LLM emitted the array directly (bare list) → wrap
# it as-is under the expected key, don't nest it again.
if isinstance(params, list):
return {single_param_name: params}
# Case 2: LLM emitted a dict missing the expected key → wrap it
# Case 2: LLM emitted a single item of the array directly
# as a dict, missing the expected wrapper key → wrap it.
if isinstance(params, dict) and single_param_name not in params:
return {single_param_name: [params]}

Expand Down
Loading