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:
- The arguments are validated exactly as sent. Valid input is executed untouched, so a
write_filewhosecontentis 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. - Only when validation fails, each failing field is tried against four repairs, in this order, and the first that applies wins:
| Repair | Model sent | Becomes |
|---|---|---|
null-for-optional | {"timeout_ms": null} on an optional field | the 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.- 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.jsonltoolInputStats 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.
History compaction spills oversized tool results instead of truncating to 80 chars
For a worker with historyCompression: true on its WorkerProfile (see src/core/workerProfiles.ts), once accumulated message history exceeds historyCompressionBudget (default 8000 tokens,...
Edit pacing — a loop implementer writes before its round budget runs out
A key-only loop implementer (loop-deepseek-build and the other loop--build workers) used to spend every tool round reading and never call a write tool. When the budget ran out, the loop asked for...