diff --git a/cecli/helpers/responses.py b/cecli/helpers/responses.py index e455d08db03..ba1155a7ad9 100644 --- a/cecli/helpers/responses.py +++ b/cecli/helpers/responses.py @@ -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: @@ -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 @@ -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) diff --git a/cecli/tools/utils/base_tool.py b/cecli/tools/utils/base_tool.py index b749c148ad6..3cdebd882df 100644 --- a/cecli/tools/utils/base_tool.py +++ b/cecli/tools/utils/base_tool.py @@ -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 = ( diff --git a/cecli/tools/validations/validations.py b/cecli/tools/validations/validations.py index e6dc8e5cd90..f23306fc419 100644 --- a/cecli/tools/validations/validations.py +++ b/cecli/tools/validations/validations.py @@ -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]}