Skip to content

Fix/broken tool call arguments - #700

Merged
zikajk merged 2 commits into
masterfrom
fix/broken-tool-call-arguments
Nov 11, 2026
Merged

zikajk merged 2 commits into
masterfrom
fix/broken-tool-call-arguments

Conversation

@zikajk

@zikajk zikajk commented Nov 11, 2026

Copy link
Copy Markdown
Member

A model sometimes sends a tool call with broken JSON arguments. ECA then either ended the turn silently or reported a fake connection error, which made a subagent's failed compaction look like a finished result.

This branch returns the parse error to the model as the call's result on every provider, so it can send the call again. It also makes the server send clients the real reason a tool call was rejected.

  • I added a entry in changelog under unreleased section.
  • This is not an AI slop. (Please take a human time to write a comprehensive context about the problem, alternatives and rationale)

A tool call whose arguments are not valid JSON was handled differently
by each API, and never well. openai-chat dropped the call and sent a
normal finish, so the turn ended as a clean stop: clients kept showing
the call as being prepared, and a subagent returned an old assistant
text as its result. This happened when a model closed a `compact_chat`
call with `</summary>` and no closing quote. Anthropic, OpenAI
Responses and Bedrock did not guard the parse: the Jackson exception is
an IOException, so it was reported as "Connection error", classified
as overloaded and auto-continued with a generic nudge, and the valid
calls of the same response did not run. While compacting, that recovery
is blocked, so the compaction failed on every API.

Parse the arguments in one place, `llm-util/parse-tool-call-arguments`,
which never throws and marks an invalid call with `:arguments-error`
(the parser message and the end of the text). The chat rejects such a
call through the existing rejection path, without approval or hooks,
with the error as its result, and the tool loop goes on as after a hook
rejection. The LLM can send the call again, so a broken `compact_chat`
recovers and the task resumes. Valid calls in the same response run,
and a rejection by the user or the config still decides how the turn
goes on.

openai-chat stopped continuing on such calls in 5c18fe0, as resending
the same request looped. Now the history gains the error, and after 3
responses in a row with invalid arguments the turn ends with a prompt
error, so a subagent reports a failure instead of a result.

The result gives no advice to shorten or split the call: the model sees
how its text ended, and of a split `compact_chat` call only the last
part would count.
The protocol lists the `reason` of `toolCallRejected` as `userChoiceDeny`,
`userConfigDeny`, `hookRejected`, `userPromptStop`, `userStop`, `user`
or `interrupted`. The server sent `user` for every call denied by the
user, the config or a hook, and for every stopped running call: since
58fe983 the action reads `(:code (:reason event-data) :user)`, but
`:send-reject` and `:stop-attempted` passed a bare code, not a decision
reason map. The codes that did get through went out in kebab-case, e.g.
`user-prompt-stop`, as only map keys are converted on the way out.

Pass a decision reason map from those two as well, and send its code in
the protocol's camelCase. This also sends two codes the protocol did not
list, so document them: `hookStopped`, for a preToolCall hook stopping
the turn, and `invalidArguments`, for the calls with invalid JSON
arguments rejected since the previous commit.

No client compares the value: of the clients in the organization only
eca-omarchy reads it, to show it as text.
@zikajk
zikajk merged commit 2e6f7e3 into master Nov 11, 2026
8 of 9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant