cyberine
UsageRun recipes

Malformed tool calls — validate-then-repair on the loop tool-input path (kmk7x)

Open models on OpenAI-compatible endpoints (DeepSeek, MiniMax, GLM, Qwen) emit a small set of recurring tool-call shape errors. Before this change a loop worker parsed the arguments with JSON.parse, silently fell back to {} when that failed, and never checked the tool's schema at all. Now every tool call a LoopAgentAdapter worker makes is checked by src/core/toolInputRepair.ts against the tool's own inputSchema, before the PreToolUse hooks, the permission gate and dispatch. It is automatic; there is nothing to enable.

The order is validate-then-repair, never preprocess-then-validate:

  1. The arguments are validated exactly as sent. Valid input is executed untouched, so a write_file whose content is a JSON-shaped string ("[1,2,3]", "{\"a\":1}") is written byte-exact: it is a string, the schema says string, and no repair ever looks at it.
  2. Only when validation fails, each failing field is tried against four repairs, in this order, and the first that applies wins:
RepairModel sentBecomes
null-for-optional{"timeout_ms": null} on an optional fieldthe field is omitted
json-array-parse"[\"a\",\"b\"]" where an array is expected["a","b"]
empty-placeholder{} (or an empty string) where an array is expected[]
bare-string-wrap"read_file" where an array of strings is expected["read_file"]

empty-placeholder never fabricates [] for a field whose schema declares minItems >= 1 (multi_edit's edits, for example), or for a field named in a small hard-coded list of tools whose call REPLACES the tool's entire persisted state rather than appending to it — currently todo_write's todos and multi_edit's edits (STATE_REPLACING_ARRAY_FIELDS in toolInputRepair.ts). An empty-object or empty-string placeholder for the whole list is a model mistake, not an intentional "clear everything", and [] there would either violate minItems or silently wipe state the model never meant to touch. On either check, the field is left unrepaired, so re-validation catches it (see the minItems enforcement below) and the model gets a refusal naming the field instead of the tool silently running against an empty list:

Error: tool "todo_write" was not run because its arguments do not match its input schema.
- "todos": received an empty object {}, but it must be a JSON array of objects. This field replaces the tool's entire state on every call; do not send an empty object or empty string as a placeholder — provide the real, complete list.
Expected arguments: todos (a JSON array of objects, required).
Fix the fields listed above and call todo_write again.
  1. The repaired arguments are validated again. If they pass, the tool runs. If any failing field had no applicable repair, or the result still fails, the tool is NOT run and the model gets a prose retry message naming each field, what it received and what the schema expects:
Error: tool "write_file" was not run because its arguments do not match its input schema.
- "path" is missing. It is required and must be a string.
- "content" is missing. It is required and must be a string.
Fields this tool does not accept: "file_path".
Expected arguments: path (a string, required); content (a string, required).
Fix the fields listed above and call write_file again.

The order is load-bearing: json-array-parse runs before bare-string-wrap, so '["a","b"]' becomes ["a","b"] and never ['["a","b"]'], and a string that starts with [ but does not parse as an array ('["a","b"') is refused rather than wrapped. Arguments that are not valid JSON at all (a call cut off mid-string, for example) are no longer run with {}: the model gets the parser's error, the first 120 characters it sent and the expected fields. An empty arguments string is read as {} (the no-arguments encoding some gateways send) and then validated like any other call.

One fix is not a schema repair: a DeepSeek-flash habit of writing a path as a degenerate markdown auto-link, /Users/x/proj/[notes.md](http://notes.md), is unwrapped to /Users/x/proj/notes.md. Only the case where the link text equals the URL without its http:// or https:// is touched; a real link such as docs/[x](https://example.com/y) passes through unchanged. It applies to string fields named path, base, file, file_path, filePath, out_path, dir or directory in the tool's schema, and it runs before the permission gate, so a deny rule sees the real path rather than the wrapped one. Field contents such as write_file's content are never unwrapped.

Read the counters after a run:

cyberine run --bg "<task>"
jq '.steps[].workerRuns[] | {agentId, toolInputStats}' .local/runs/<runId>/run.json
grep tool-input .local/runs/<runId>/events.jsonl

toolInputStats holds one row per model, tool and outcome, for example {"model":"deepseek-v4-flash","tool":"todo_write","outcome":"repaired","kind":"json-array-parse","count":2}. A repaired call counts once per repair kind it needed, including markdown-autolink-path, and an unrun call counts once as "outcome":"invalid". events.jsonl carries one worker:tool-input-repaired or worker:tool-input-invalid line per call, with a detail such as tool_input_repaired:run_bash model=deepseek-v4-flash repairs=null-for-optional@timeout_ms or tool_input_invalid:write_file model=deepseek-v4-flash reason=schema-mismatch fields=content. A foreground cyberine run that dispatches without the workflow engine writes no run record; it prints the same detail line on stderr instead.

Known Limitations and Deferred Work (src/core/toolInputRepair.ts). Only LoopAgentAdapter is wired. AnthropicAgentAdapter, BedrockAdapter and VertexAdapter receive already-parsed tool input and still pass it to the tool unchecked; prepareParsedToolInput is the entry point for wiring them. Calls a run_pipeline step makes internally bypass the layer. The validator is a JSON Schema subset: it blocks on type, required, enum and, as of kq4r1, minItems on arrays (recursing through properties and items) and deliberately does not block on minimum/maximum, minLength/maxLength, maxItems, pattern or additionalProperties, because the tools already clamp or report those themselves and blocking them would bounce calls that run today. minItems is the one exception: an array repaired or received short of its declared floor (multi_edit's edits, for example) is refused rather than run, because a short list there is silent data loss, not a clamp a tool can safely apply after the fact. anyOf, oneOf, allOf and $ref are not interpreted, so a field declared only through them is never blocked or repaired. Unknown fields on a valid call pass through untouched; they are named only in the retry message of a call that already failed. A double-encoded arguments object (the whole object sent as a JSON string) is refused, not repaired. The relational default the vendor catalogue describes for read_file offset/limit has no counterpart here: read_file takes start_line and search, spill_retrieve defaults offset and length independently without an error, and lsp_query's line/character pair names a source position that no default could guess, so no relational default was added.