Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a5deb6e
feat: configurable agent-loop retry limits with pause-and-ask on tool…
shoryabansalgithub Aug 18, 2026
68a9b0d
refactor: thread repeated-call streak to recursion via explicit variable
shoryabansalgithub Aug 18, 2026
95f8927
no-mistakes(review): fix retry-limit doc scope, test config isolation…
shoryabansalgithub Aug 18, 2026
f95c2d2
no-mistakes(review): report cumulative repeat streak, refresh retry c…
shoryabansalgithub Aug 18, 2026
921a882
no-mistakes(document): document provider maxRetries and yolo repeated…
shoryabansalgithub Aug 18, 2026
9de2c2e
feat(plain): apply nanocoder.retries caps in the --plain runtime
shoryabansalgithub Aug 18, 2026
b3eb7ca
no-mistakes(review): extract shared getRetryLimits helper for both ru…
shoryabansalgithub Aug 18, 2026
2db2d6a
Merge pull request #1 from shoryabansalgithub/fm/nanocoder-897
shoryabansalgithub Aug 18, 2026
ed1768c
no-mistakes(document): sync run-mode, ACP scope, and CLAUDE.md docs f…
shoryabansalgithub Aug 18, 2026
b0940b0
Merge pull request #2 from shoryabansalgithub/fm/nanocoder-897
shoryabansalgithub Aug 18, 2026
863717a
feat(retries): count unknown-tool turns in the repeated-call streak a…
shoryabansalgithub Aug 18, 2026
08708a1
no-mistakes(review): deliver unknown-tool feedback and harden retry l…
shoryabansalgithub Aug 18, 2026
2742ee3
no-mistakes(review): preserve subagent partial output and dedupe tool…
shoryabansalgithub Aug 18, 2026
edea0ee
no-mistakes(test): stop double-printing plain retry-limit messages
shoryabansalgithub Aug 18, 2026
cc6f2f8
no-mistakes(document): sync docs for subagent and plain-run retry limits
shoryabansalgithub Aug 18, 2026
66aca0b
fix(vscode): load mention-utils.js in the chat-panel test harness
shoryabansalgithub Aug 19, 2026
9ad805e
fix(tool-calls): pair unconfirmed tools with cancellation results on …
shoryabansalgithub Aug 19, 2026
91486d0
no-mistakes(review): distinguish approval-unavailable results, share …
shoryabansalgithub Aug 19, 2026
ae3c90c
no-mistakes(review): pair skipped tools on confirm-loop abort break
shoryabansalgithub Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/configurable-retry-limits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nanocollective/nanocoder": minor
---

Add configurable agent-loop retry limits to prevent token drain (#897). A new `nanocoder.retries` section in `agents.config.json` exposes the previously hardcoded caps: `maxRepeatedToolCalls` (default 3), `maxEmptyTurns` (default 2), and `maxMalformedRetries` (default 2). When the repeated-tool-call limit is hit in an interactive session, Nanocoder now pauses and asks whether to continue (granting another window of attempts) or stop, instead of always hard-stopping; non-interactive runs keep the hard stop. The same limits now also protect the `--plain` runtime used by `nanocoder run` in CI and non-TTY environments, which previously had no repeated-call cap at all: each cap hard-stops with a clear error there. Note this also loosens `--plain` in two places: it used to return an error on the *first* empty response and on the *first* malformed tool call, and it now nudges or asks the model to self-correct up to `maxEmptyTurns` / `maxMalformedRetries` before stopping, so a silent or malformed-output model costs up to 3 model calls instead of 1. Set either limit to `0` to restore the old fail-fast behaviour. Calls to unknown tools count toward the repeated-call streak in both runtimes, so a model stuck on a nonexistent tool trips the same cap instead of looping until the turn ceiling. Delegated subagent runs, whose loop previously had no cap at all, now apply `maxRepeatedToolCalls` too and stop with an error naming the setting.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ File editing uses a content-based approach:
- `string_replace`: Primary edit tool — replaces exact content
- `write_file`: Whole file overwrites

Two execution paths exist: native tool calling (preferred, via AI SDK) and an XML fallback for models that don't support tools. `LLMChatResponse.toolsDisabled` signals which path produced the response; the conversation loop only runs `parseToolCalls()` (in `source/tool-calling/`) when `toolsDisabled` is true.
Two execution paths exist: native tool calling (preferred, via AI SDK) and an XML fallback for models that don't support tools. `LLMChatResponse.toolsDisabled` signals which path produced the response. The conversation loop runs `parseToolCalls()` (in `source/tool-calling/`) whenever the response has no native tool calls — always on the fallback path, and on the native path too, since models marketed as native-tool-capable sometimes regress to emitting tool-call text. Malformed text there feeds the self-correction retry loop capped by `nanocoder.retries.maxMalformedRetries`.

### Command System

Expand Down
30 changes: 30 additions & 0 deletions docs/configuration/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,36 @@ When the cap is reached, the loop does **not** error out and discard work. On th

One turn is a single LLM response plus its batch of tool executions. The default of 200 is high enough for long iterative jobs to finish while still bounding cost and wall-clock time for an unattended run that gets stuck.

### Retry Limits

Caps on how many times the conversation loop auto-retries a failing pattern without user intervention, so a stuck model cannot silently drain tokens. They apply in both runtimes: the interactive TUI loop and the `--plain` runtime used by `nanocoder run "..."` in CI and non-TTY environments (where they act within the [Headless](#headless) `maxTurns` ceiling). These are agent-loop limits — the per-provider `maxRetries` setting is unrelated and governs network request retries (see [Providers](providers/index.md)).

```json
{
"nanocoder": {
"retries": {
"maxRepeatedToolCalls": 3,
"maxEmptyTurns": 2,
"maxMalformedRetries": 2
}
}
}
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `maxRepeatedToolCalls` | number | `3` | Pause threshold for consecutive identical tool calls (minimum 2). The check fires when the same call (or set of calls) is emitted for the Nth consecutive turn, before that call runs - so the default of 3 executes the repeated call twice and pauses on the third emission. In an interactive session you are asked whether to continue - useful when the repetition is legitimate, such as polling a long-running job - or stop. `--plain` and other non-interactive runs stop with a clear error. Calls to unknown tools count toward the streak too, so a model stuck on a nonexistent tool hits the same cap. |
| `maxEmptyTurns` | number | `2` | Consecutive empty assistant turns that are auto-nudged before giving up (minimum 0). The interactive loop additionally compacts the context and retries once before stopping; the `--plain` runtime stops directly after the nudges. |
| `maxMalformedRetries` | number | `2` | Malformed self-correction retries allowed for text-parsed tool calls before the loop gives up (minimum 0). Applies to the XML fallback path in both runtimes. The interactive loop also parses tool-call text from native-tool models that emit it instead of native calls, so the cap covers that case there; the `--plain` runtime only parses text on the XML fallback path. |

Choosing "Continue" at the repeated-tool-call prompt runs the paused call and re-checks after `maxRepeatedToolCalls` further identical calls, so a genuinely stuck model is re-prompted rather than left looping.

Setting `maxEmptyTurns` or `maxMalformedRetries` to `0` disables the nudge entirely, so the first empty or malformed turn ends the run - the fail-fast behaviour `--plain` had before these limits existed, worth setting when a silent or malformed-output model should cost one model call rather than three. The interactive loop still runs its single compact-and-retry after an empty turn even at `0`.

> **Warning - CI polling patterns:** in `--plain` runs (`nanocoder run "..."` in CI and non-TTY environments) there is no prompt to answer, so `maxRepeatedToolCalls` is a hard stop. A workflow whose model legitimately repeats the identical command - polling a deploy, re-running the same check while waiting on an external state change - aborts with exit code `1` once the cap is hit, by default on the third consecutive identical call. Raise `nanocoder.retries.maxRepeatedToolCalls` in that project's `agents.config.json` before relying on such a polling pattern.

Unlike [Headless](#headless), these limits do not cover the ACP loop (`--acp`, used by editor clients), which is bounded by `maxTurns` alone. Delegated [subagent](../features/subagents.md) runs apply `maxRepeatedToolCalls` - a stuck subagent stops with an error naming the setting, since there is nobody to ask inside a delegated run - but not the other two limits: a subagent's loop ends on its own after an empty turn, and it does not use text-parsed tool calls.

### Paste Handling

Configure how pasted text is handled in the input. By default, single-line pastes of 800 characters or fewer are inserted directly, while longer or multi-line pastes are collapsed into a `[Paste #N: X chars]` placeholder.
Expand Down
1 change: 1 addition & 0 deletions docs/configuration/providers/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ Use dedicated AI SDK packages for native API support, enabled via the `sdkProvid
| `disableToolModels` | List of model names to disable tool calling for (optional) |
| `requestTimeout` | Overall request timeout in milliseconds (default: 120,000). Set to `-1` to disable (optional) |
| `socketTimeout` | Socket-level timeout in milliseconds, uses `requestTimeout` if not set. Set to `-1` to disable (optional) |
| `maxRetries` | How many times a failed network request is retried (default: 2). Unrelated to the agent-loop [Retry Limits](../index.md#retry-limits), which cap how often the model may repeat itself (optional) |
| `connectionPool` | Connection pool settings (optional, see [Timeouts & Connection Pooling](#timeouts--connection-pooling)) |

### Context Window Overrides
Expand Down
7 changes: 6 additions & 1 deletion docs/features/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ nanocoder --mode yolo run "update README and push"
```

If a tool requires approval that the active mode won't grant, nanocoder prints `Tool approval required for: ...` and exits with status code `1`.

Because there is nobody to answer a prompt in a `run`, the agent-loop [retry limits](../configuration/index.md#retry-limits) hard-stop instead of pausing: a model that repeats the same tool call, returns empty responses, or keeps emitting malformed tool calls past its configured cap ends the run with an error. Under the `--plain` runtime (used automatically in CI and non-TTY environments) the error names the limit that fired and the run exits with status code `1`.

> **Warning - CI polling patterns:** the repeated-call hard stop triggers on *legitimate* repetition too. If your workflow's model is expected to run the identical command repeatedly - polling a deploy, waiting on a slow job by re-running the same check - the run aborts once `maxRepeatedToolCalls` consecutive identical calls are emitted (default 3). Raise `nanocoder.retries.maxRepeatedToolCalls` in that project's `agents.config.json` before relying on such a pattern in CI.

### JSON Output

For CI pipelines, scripting, and tool chaining, pass `--json` (alias `--output-format json`) alongside `run` to get a single structured JSON object on `stdout` instead of streamed markdown:
Expand Down Expand Up @@ -152,4 +157,4 @@ The emitted object looks like:

Two more fields appear conditionally: `message` (the error text, when `kind` is `"error"`) and `toolNames` (the tools awaiting approval, when `kind` is `"tool-approval-required"`).

On error (e.g. an untrusted workspace directory, or the turn limit being hit without a final answer), `kind` is `"error"` and the object still includes whatever `toolCalls` were captured before the failure, so partial progress isn't silently dropped.
On error (e.g. an untrusted workspace directory, the turn limit being hit without a final answer, or an agent-loop [retry limit](../configuration/index.md#retry-limits) being hit), `kind` is `"error"` and the object still includes whatever `toolCalls` were captured before the failure, so partial progress isn't silently dropped.
5 changes: 3 additions & 2 deletions docs/features/development-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,12 @@ Automatically accepts and executes most tool calls without confirmation. Some hi

Automatically accepts and executes **every** tool call without exception — including bash commands and destructive git operations.

- No confirmation prompts at all — everything runs immediately
- No tool confirmation prompts at all — everything runs immediately
- Bash commands, hard resets, force deletes, stash drops — all auto-accepted
- The status bar turns red to make it clear you're in yolo mode
- One safeguard remains: if the model repeats the identical tool call too many times in a row, Nanocoder pauses and asks whether to continue, so a stuck loop cannot drain tokens unattended. See [Retry Limits](../configuration/index.md#retry-limits)

**When to use:** When you fully trust the AI and want zero interruptions. Use with caution — there are no safety nets other than basic tool validators.
**When to use:** When you fully trust the AI and want zero interruptions. Use with caution — there are no safety nets other than basic tool validators and the repeated-call pause above.

## Plan Mode

Expand Down
6 changes: 6 additions & 0 deletions docs/features/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,12 @@ mode (no foreground prompts, no `ask_user`, no `agent`). The
`confirm: true` opt-in below switches a specific subscription to plan
mode instead.

Triggered runs are subagent runs, so the
[`maxRepeatedToolCalls`](../configuration/index.md#retry-limits) cap
applies: a triggered skill whose model gets stuck repeating the same
tool call stops with an error instead of burning tokens unattended (see
[Loop Protection](./subagents.md#loop-protection)).

## Inspecting and creating skills

```
Expand Down
8 changes: 8 additions & 0 deletions docs/features/subagents.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,14 @@ A project-level agent with the same `name` as a built-in or user-level agent ove
- The `tools` key in the agent definition controls which tools the subagent can access. Use this to restrict subagents to only the tools they need.
- The `alwaysAllow` setting in `agents.config.json` applies to tools within subagents, so you can configure which tools run without prompts.

## Loop Protection

A subagent that re-issues the identical tool call(s) on consecutive turns is stopped by the same [`maxRepeatedToolCalls`](../configuration/index.md#retry-limits) cap the main agent uses (default 3). There is nobody to ask inside a delegated run, so it never pauses: the run fails with an error naming the setting. Calls to nonexistent tools count toward the streak too, so a subagent stuck on a tool it doesn't have hits the same cap.

Whatever the subagent produced before it got stuck is still handed to the main agent under a `Partial output produced before stopping:` heading, so useful work isn't discarded along with the failure.

The other two retry limits don't apply to subagents: their loop ends on its own when a turn comes back with no tool calls (so `maxEmptyTurns` is moot), and they don't use text-parsed tool calls (so `maxMalformedRetries` is too). There is also no turn ceiling: apart from the repeated-call cap, a subagent runs until it stops calling tools or the main agent's run is cancelled.

## Development Modes and Tune Profiles

### Plan Mode
Expand Down
29 changes: 10 additions & 19 deletions source/acp/acp-conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import {requestUserChoice} from '@/acp/acp-question';
import type {AcpSession} from '@/acp/acp-session';
import {type AcpToolCallMeta, buildToolCallMeta} from '@/acp/acp-tool-call';
import {DEFAULT_HEADLESS_MAX_TURNS, getAppConfig} from '@/config/index';
import {
buildAbandonedTurnMessages,
partitionUnknownToolCalls,
} from '@/hooks/chat-handler/utils/tool-filters';
import {processToolUse} from '@/message-handler';
import {
getAllSubagentProgress,
Expand Down Expand Up @@ -213,36 +217,23 @@ export async function runAcpConversation(
];
const cleanedContent = xmlParse.cleanedContent;

const validToolCalls: ToolCall[] = [];
const errorResults: ToolResult[] = [];
for (const toolCall of allToolCalls) {
if (
toolCall.function.name === '__xml_validation_error__' ||
!toolManager.hasTool(toolCall.function.name)
) {
errorResults.push({
tool_call_id: toolCall.id,
role: 'tool',
name: toolCall.function.name,
content: `Unknown tool: ${toolCall.function.name}`,
});
continue;
}
validToolCalls.push(toolCall);
}
const partition = partitionUnknownToolCalls(allToolCalls, toolManager);
const {validToolCalls, errorResults} = partition;
const {emittedToolCalls, resultsForAbandonedTurn} =
buildAbandonedTurnMessages(partition);

messages = [
...messages,
{
role: 'assistant',
content: cleanedContent,
tool_calls: validToolCalls.length > 0 ? validToolCalls : undefined,
tool_calls: emittedToolCalls.length > 0 ? emittedToolCalls : undefined,
reasoning: streamedReasoning || undefined,
},
];

if (errorResults.length > 0) {
messages = [...messages, ...errorResults];
messages = [...messages, ...resultsForAbandonedTurn];
continue;
}

Expand Down
156 changes: 156 additions & 0 deletions source/config/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,162 @@ test.serial('headless maxTurns ignores invalid env var', async t => {
);
});

// Tests for agent-loop retry limits (nanocoder.retries)
async function withRetriesConfig(
subdir: string,
configBody: unknown,
assertion: (retries: {
maxRepeatedToolCalls: number;
maxEmptyTurns: number;
maxMalformedRetries: number;
}) => void,
): Promise<void> {
const originalCwd = process.cwd();
const originalConfigDir = process.env.NANOCODER_CONFIG_DIR;
const testSubdir = join(headlessTestDir, subdir);
mkdirSync(testSubdir, {recursive: true});

try {
writeFileSync(
join(testSubdir, 'agents.config.json'),
JSON.stringify(configBody),
'utf-8',
);
process.chdir(testSubdir);
process.env.NANOCODER_CONFIG_DIR = join(testSubdir, 'nonexistent-global');

const {reloadAppConfig: reload, getAppConfig} = await import('./index.js');
reload();
const retries = getAppConfig().retries;
if (!retries) {
throw new Error('Resolved config should always carry retry limits');
}
assertion(retries);
} finally {
process.chdir(originalCwd);
if (originalConfigDir !== undefined) {
process.env.NANOCODER_CONFIG_DIR = originalConfigDir;
} else {
delete process.env.NANOCODER_CONFIG_DIR;
}
}
}

test.serial('retry limits default to the historical caps when not configured', async t => {
await withRetriesConfig('retries-default', {nanocoder: {}}, retries => {
t.is(retries.maxRepeatedToolCalls, 3);
t.is(retries.maxEmptyTurns, 2);
t.is(retries.maxMalformedRetries, 2);
});
});

test.serial('retry limits load custom values from config', async t => {
await withRetriesConfig(
'retries-config',
{
nanocoder: {
retries: {
maxRepeatedToolCalls: 10,
maxEmptyTurns: 5,
maxMalformedRetries: 4,
},
},
},
retries => {
t.is(retries.maxRepeatedToolCalls, 10);
t.is(retries.maxEmptyTurns, 5);
t.is(retries.maxMalformedRetries, 4);
},
);
});

test.serial('retry limits apply defaults for fields not configured', async t => {
await withRetriesConfig(
'retries-partial',
{nanocoder: {retries: {maxRepeatedToolCalls: 7}}},
retries => {
t.is(retries.maxRepeatedToolCalls, 7);
t.is(retries.maxEmptyTurns, 2);
t.is(retries.maxMalformedRetries, 2);
},
);
});

test.serial('retry limits clamp maxRepeatedToolCalls to at least 2', async t => {
await withRetriesConfig(
'retries-clamp-repeated',
{nanocoder: {retries: {maxRepeatedToolCalls: 1}}},
retries => {
// A fresh tool call already counts as 1 repeat, so anything below 2
// would pause on every single tool call.
t.is(retries.maxRepeatedToolCalls, 2);
},
);
});

test.serial('retry limits clamp negative values to their minimums', async t => {
await withRetriesConfig(
'retries-clamp-negative',
{
nanocoder: {
retries: {
maxRepeatedToolCalls: -5,
maxEmptyTurns: -1,
maxMalformedRetries: -1,
},
},
},
retries => {
t.is(retries.maxRepeatedToolCalls, 2);
t.is(retries.maxEmptyTurns, 0);
t.is(retries.maxMalformedRetries, 0);
},
);
});

test.serial('retry limits ignore non-numeric values', async t => {
await withRetriesConfig(
'retries-invalid-types',
{
nanocoder: {
retries: {
maxRepeatedToolCalls: 'lots',
maxEmptyTurns: null,
maxMalformedRetries: {nope: true},
},
},
},
retries => {
t.is(retries.maxRepeatedToolCalls, 3);
t.is(retries.maxEmptyTurns, 2);
t.is(retries.maxMalformedRetries, 2);
},
);
});

test.serial('getRetryLimits falls back per field, not per object', async t => {
// A retries object missing a key would otherwise hand callers `undefined`,
// and every `count >= limit` guard reading it evaluates false, silently
// disabling the cap.
const {
getAppConfig,
getRetryLimits,
reloadAppConfig: reload,
} = await import('./index.js');
const config = getAppConfig();
const original = config.retries;
config.retries = {maxEmptyTurns: 5} as unknown as typeof original;
try {
const limits = getRetryLimits();
t.is(limits.maxEmptyTurns, 5);
t.is(limits.maxRepeatedToolCalls, 3);
t.is(limits.maxMalformedRetries, 2);
} finally {
config.retries = original;
reload();
}
});

// Tests for modeProviders
async function withModeProvidersConfig(
testName: string,
Expand Down
Loading
Loading