Summary
When a terminal command fails, the exit code never reaches the model. If the command also produced no stdout, the model receives an empty string as the tool result — indistinguishable from "succeeded with no output". It cannot tell that anything went wrong, so it retries trivial variations of the same command indefinitely and the agent loop becomes stuck.
Observed with a local model (Qwen 3.8 9B via llama.cpp), but the defect is model-independent: the failure information is discarded before any model sees it.
Root cause
core/tools/implementations/runTerminalCommand.ts:310 puts the exit code in a status field:
const status = `Command failed with exit code ${code}`;
resolve([{
name: "Terminal",
description: "Terminal command output",
content: terminalOutput, // <-- raw stdout only
status: status, // <-- UI-only, never sent to the model
}]);
status is consumed by the GUI for the status pill. The message actually sent to the model is assembled in core/util/messageContent.ts:35:
return contextItems.map((item) => item.content).join("\n\n");
Only content. status is dropped. Same on the success path at line 300.
Reproduction
Any command that fails with no stdout. grep is the common case, since it exits 1 when it finds no matches — a normal, conclusive result:
grep "class" /tmp/dump.cs | grep -i "sfx"
Persisted tool state shows the mismatch:
{"status": "done",
"output": [{"name": "Terminal",
"content": "",
"status": "Command failed with exit code 1"}]}
The model receives "".
Real-world impact
From a live session, 20+ consecutive turns cycling near-identical greps, each returning empty, none analyzed:
grep "class" dump.cs | grep -i "sfx"
grep "class" dump.cs | grep -i "sfx" | head -10
grep "class" dump.cs | grep -i "sfx"
grep -i "class" dump.cs | grep -i "sfx"
grep "class" dump.cs | grep -i "sfx" | head -20
...
The session only ended when cancelled manually. On local models this burns GPU time and context; on hosted models it burns tokens.
Proposed fix
Fold the status into content when it carries information the model needs — non-zero exit, or empty output. Sketch:
const failed = code && code !== 0;
const parts: string[] = [];
if (terminalOutput) parts.push(terminalOutput);
if (failed) parts.push(`[exit code ${code}]`);
else if (!terminalOutput) parts.push("[command produced no output]");
content: parts.join("\n"),
status, // keep for the UI
Keep status as-is so the GUI pill is unaffected.
Design questions to settle first
This is why the task is larger than the diff suggests:
- Does an empty-but-successful result need a marker too? Distinguishing "no output" from "no result" matters for
grep/find, where exit 1 is a legitimate answer rather than an error.
- Should
grep-style exit 1 be reported as failure at all, or normalized to "no matches"? Reporting it as an error may push models toward unnecessary retries in the other direction.
- Does the same gap exist in other tools that populate
status? Needs an audit — this issue only establishes it for runTerminalCommand.
- Background /
waitForCompletion: false path (line 454, 481) has the same split and needs the same treatment.
- Token cost of appending markers to every tool result, especially on long agent runs.
Scope
core/tools/implementations/runTerminalCommand.ts — both foreground and background paths
- audit of other tool implementations setting
status
core/tools/implementations/runTerminalCommand.vitest.ts — existing tests assert on status (lines 142, 170, 340, 400, 460); add coverage asserting failure info reaches content
Summary
When a terminal command fails, the exit code never reaches the model. If the command also produced no stdout, the model receives an empty string as the tool result — indistinguishable from "succeeded with no output". It cannot tell that anything went wrong, so it retries trivial variations of the same command indefinitely and the agent loop becomes stuck.
Observed with a local model (Qwen 3.8 9B via llama.cpp), but the defect is model-independent: the failure information is discarded before any model sees it.
Root cause
core/tools/implementations/runTerminalCommand.ts:310puts the exit code in astatusfield:statusis consumed by the GUI for the status pill. The message actually sent to the model is assembled incore/util/messageContent.ts:35:Only
content.statusis dropped. Same on the success path at line 300.Reproduction
Any command that fails with no stdout.
grepis the common case, since it exits 1 when it finds no matches — a normal, conclusive result:Persisted tool state shows the mismatch:
{"status": "done", "output": [{"name": "Terminal", "content": "", "status": "Command failed with exit code 1"}]}The model receives
"".Real-world impact
From a live session, 20+ consecutive turns cycling near-identical greps, each returning empty, none analyzed:
The session only ended when cancelled manually. On local models this burns GPU time and context; on hosted models it burns tokens.
Proposed fix
Fold the status into
contentwhen it carries information the model needs — non-zero exit, or empty output. Sketch:Keep
statusas-is so the GUI pill is unaffected.Design questions to settle first
This is why the task is larger than the diff suggests:
grep/find, where exit 1 is a legitimate answer rather than an error.grep-style exit 1 be reported as failure at all, or normalized to "no matches"? Reporting it as an error may push models toward unnecessary retries in the other direction.status? Needs an audit — this issue only establishes it forrunTerminalCommand.waitForCompletion: falsepath (line 454, 481) has the same split and needs the same treatment.Scope
core/tools/implementations/runTerminalCommand.ts— both foreground and background pathsstatuscore/tools/implementations/runTerminalCommand.vitest.ts— existing tests assert onstatus(lines 142, 170, 340, 400, 460); add coverage asserting failure info reachescontent