Skip to content

Terminal tool: command failures are invisible to the model, causing infinite retry loops #12

Description

@ScrewTSW

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:

  1. 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.
  2. 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.
  3. Does the same gap exist in other tools that populate status? Needs an audit — this issue only establishes it for runTerminalCommand.
  4. Background / waitForCompletion: false path (line 454, 481) has the same split and needs the same treatment.
  5. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions