bug(runtime): every pre-dispatch tool refusal kills the turn — the synthetic result lands as an orphan_response the ledger rejects
#2,234 opened on 2026/08/05
Repository metrics
- Stars
- (1 個のスター)
- PR merge metrics
- (PR metrics pending)
説明
What happened
A model sent agent_swarm one malformed item (items[2] carried neither
subagent_id nor a legacy profile). That is a recoverable mistake, and the
runtime is built to treat it as one: formatToolArgsViolationText exists so the
refusal names the fields the call does take, and
packages/runtime/src/__tests__/tool-args-violation.test.ts asserts the refusal
comes back as a tool result the model can read and correct.
Instead the whole turn died. From the user's side the transcript simply stops
under a red Agent Swarm card:
Tool "agent_swarm" arguments failed validation: [
{
"code": "custom",
"message": "Provide exactly one of subagent_id or legacy profile.",
"path": [ "items", 2 ]
}
] agent_swarm takes `items`, `prompt_template`, `profile`, `subagent_id`, `resume_run_ids`, `max_concurrency`.
No retry, no further steps, composer idle. The model was never given the chance to fix its third item.
What the durable state shows
The refused call left nothing in the ledger, and the run never reached a terminal state:
runtime_eventsfor the turn stops atevent_seq 102— the assistant text that preceded the call. There is nofunction_calland nofunction_responseforagent_swarmanywhere in the session.tool_journal_events/tool_operationshave no row for it either; the last entries are the precedingReadoperations, cleanlyoutcome_committed.core_agent_runsstill holds"status": "running"for that run (it becomesfailureClass: "app_restarted"on the next launch), carrying:
"traceWriteError": "append runtime event: Tool ledger transition rejected: orphan_response at 8de09ddf-1b18-4b0c-964f-8d69be078d69"
So the append of the synthetic error result was rejected by the ledger, the rejection threw out of the append, and the turn unwound with the run row stuck mid-flight.
Root cause
packages/runtime/src/tool-runtime.ts:858-864 already names this exact failure
mode — but it only closes it for one of the refusal paths:
// Exclusive-step rejection is preflight: it must remain on the generic
// call/response lane instead of claiming the T1 dispatch protocol. If the
// call carried an operationId here, AgentRun would (correctly) skip its
// generic projection assuming commitToolPrepared already persisted it;
// the synthetic response would then become an orphan.
const operationId =
this.input.runtimeCommitSink && invocationId && !admissionFailure
? buildToolOperationId({ invocationId, providerToolCallId: toolUseId })
: undefined;
admissionFailure is the only pre-dispatch refusal excluded. Every other one
returns before prepareDurableToolAttempt (:1107), which is what calls
commitToolPrepared and actually persists the call on the T1 lane:
| refusal | site |
|---|---|
| arguments failed schema validation | :950 |
| loop-gate / repeated failing call | :1005, :1026, :1049 |
| permission denial, sandbox boundary | :1066, :1077 |
| subagent tool limit | :1102 |
For all of those the tool_start event has already been pushed with an
operationId (id: ${operationId}_call), so AgentRun skips its generic
projection of the call and waits for a commitToolPrepared that never comes.
writeSyntheticToolResult (:728) then finds no durable attempt for the call:
const durableAttempt = this.durableToolAttempts.get(durableAttemptKey(turnId, toolUseId));
const durableOutcome = await durableAttempt?.commitOutcome(content, true);
…
queue.push({
type: 'tool_result',
id: durableOutcome?.id ?? this.input.newId(), // ← UUID, not `${operationId}_response`
…
...(durableOutcome ? { operationId: durableOutcome.operationId } : {}), // ← omitted
— and writes the result onto the generic lane with a fresh UUID and no
operationId. tool-ledger-scanner.ts:351-372 matches responses to calls by
(invocationId, toolCallId), finds no call, and raises orphan_response. The
append throws, and the turn dies.
The UUID in the recorded traceWriteError (rather than a
toolop_<hash>_response id) is the fingerprint of that fallback path.
Scope
Observed via the argument-validation path. The other rows in the table above
share the same shape — pushed tool_start with an operationId, returned before
prepareDurableToolAttempt, synthetic result on the generic lane — so they
should be lethal in the same way; that part is read off the code, not yet
reproduced. If so, the class is "every recoverable pre-dispatch refusal is fatal
whenever runtimeCommitSink is active", i.e. in the real Desktop runtime.
Two secondary observations, separable from the fix:
agent_swarm's refusal text saysProvide exactly one of subagent_id or legacy profile.without listing the validprofileenum values or pointing atagent_list. The field list appended byformatToolArgsViolationTextis the top-level one, which is not where the violation was (path: ["items", 2]). Once the turn survives the refusal, this is what decides whether the model actually repairs the call.- A single rejected event append killing the turn silently — and leaving the
run row at
status: "running"with no terminal event — is its own weakness, independent of what caused the rejection.
How to reproduce
- Desktop, a real session (durable
runtimeCommitSinkactive). - Get any tool call whose arguments the tool's own zod schema rejects. A
cross-field
superRefinerule is the reliable way to reach ToolRuntime's validation, e.g. anagent_swarmitem with neithersubagent_idnorprofile. - The refusal renders under the tool's card and the turn ends there. Afterwards
the session's
runtime_eventshold no call/response pair for it, and the run row incore_agent_runscarriestraceWriteError: … orphan_response …while still readingstatus: "running".
Environment
- Maka commit:
cef8c44d8(locally packaged unsigned build, app version 0.1.5) - OS: macOS 26.6 (arm64)
- Surface: Desktop
- Node.js: v22.17.0
- Model:
deepseek-v4-proover an openai-compatible connection