Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ One structural fact to know before editing:

- **Approval is split in two.** `runtime/approval/classifier.ts` decides how risky a shell command is; `runtime/approval/policy.ts` decides whether that risk needs asking. Adding an approval mode is one entry in a table.

A turn: `cli.ts` → `AgentController` (owns client, model, cancellation) → `buildRepositoryContext` in `config/config.ts` (package metadata, README, agent instruction files, structure — each capped, the whole capped again) → `agentLoop` in `runtime/loop.ts` (stream, collect tool calls, execute, feed results back; 20 iterations by default) → tools resolved via `toolRegistry` in `tools/index.ts`.
A turn: `cli.ts` → `AgentController` (owns client, model, cancellation) → `buildRepositoryContext` in `config/config.ts` (package metadata, README, agent instruction files, structure — each capped, the whole capped again) → `agentLoop` in `runtime/loop.ts` (stream, collect tool calls, execute, feed results back; 40 iterations per stretch, then it asks via `onBudgetExhausted` — absent handler means nobody to ask, and exhaustion throws as before) → tools resolved via `toolRegistry` in `tools/index.ts`.

Providers implement `ProviderClient` in `providers/client.ts`, whose `stream()` yields `StreamEvent`s (`text`, `tool_call`, `done`). Google, OpenAI and Anthropic are all enabled in `providers/providerRegistry.ts`. The Gemini client lives in `providers/client.ts`, the Anthropic one in `providers/anthropicClient.ts`, the OpenAI one in `providers/openaiClient.ts`; `createProviderClient` picks between them.

Expand Down
48 changes: 43 additions & 5 deletions commands/agent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,27 @@ async function runInteractive(modelOverride?: string) {
store.setStatus(status);
},

// The loop reports this every iteration and the TUI used to drop it, so
// nothing on screen said how much of the window the session was spending.
// Each provider client normalises `promptTokens` to the whole prompt —
// Anthropic's sums its three counts — so the number means the same thing
// whichever one is answering.
onUsage(iteration) {
store.setUsage(iteration.usage?.promptTokens);
},

// Implemented here and deliberately not in `runHeadless`: the loop treats
// an absent handler as "nobody is there to ask" and raises the budget error
// instead, which is what the headless exit codes are built on.
async onBudgetExhausted({ steps }) {
const shouldContinue = await store.setPendingContinuation({
id: crypto.randomUUID(),
steps,
});

return shouldContinue ? "continue" : "stop";
},

onToolStart(tool) {
store.finishAssistantMessage();
store.startTool(tool);
Expand Down Expand Up @@ -363,9 +384,26 @@ async function runInteractive(modelOverride?: string) {
}
};

// The app paints a full-height frame, so it belongs on the alternate screen:
// in the normal buffer it overwrites the scrollback of whatever the user was
// doing and leaves its last frame stranded there on exit.
//
// Restoring is not optional, and `handleExit` is not enough on its own — an
// uncaught throw leaves the terminal in the alternate buffer with mouse
// reporting still on, which reads as a hung shell. `restoreTerminal` is
// idempotent so the exit hook and the normal path can both call it.
let terminalRestored = false;
const restoreTerminal = () => {
if (terminalRestored || !process.stdin.isTTY) return;
terminalRestored = true;
process.stdin.off("data", onData);
process.stdout.write("\x1b[?1006l\x1b[?1000l\x1b[?1049l");
};

if (process.stdin.isTTY) {
process.stdin.on("data", onData);
process.stdout.write("\x1b[?1000h\x1b[?1006h");
process.stdout.write("\x1b[?1049h\x1b[?1000h\x1b[?1006h");
process.once("exit", restoreTerminal);
}

const { unmount } = render(
Expand All @@ -379,18 +417,18 @@ async function runInteractive(modelOverride?: string) {
if (exiting) return;
exiting = true;

if (process.stdin.isTTY) {
process.stdin.off("data", onData);
process.stdout.write("\x1b[?1006l\x1b[?1000l");
}
if (scrollFrame) clearTimeout(scrollFrame);

store.clearPendingEdit();
store.clearPendingCommand();
store.cancelPendingQuestion();
store.clearPendingContinuation();
controller.cancel();
await controller.dispose();
unmount();
// After unmount, so Ink's final frame lands in the alternate buffer rather
// than in the scrollback the user is about to get back.
restoreTerminal();
process.exit(0);
}

Expand Down
1 change: 1 addition & 0 deletions commands/agentController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ export class AgentController {
store.clearPendingEdit();
store.clearPendingCommand();
store.cancelPendingQuestion();
store.clearPendingContinuation();
this.abortController?.abort();
}

Expand Down
13 changes: 13 additions & 0 deletions config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,8 +241,21 @@ export interface ToolFailure extends ToolCall {
error: string;
}

/** What to do when a turn reaches its step ceiling. */
export type BudgetDecision = "continue" | "stop";

export interface AgentCallbacks {
onStatus?(status: string): void;
/**
* The turn has used its whole budget and is not finished. Answering
* `continue` grants another budget and carries the same turn on.
*
* Optional, and the absence is meaningful rather than a default: it means
* nobody is there to ask, so the loop raises `IterationBudgetExhaustedError`
* as it always has. Headless runs deliberately do not implement it, which is
* what keeps their exit-code contract.
*/
onBudgetExhausted?(info: { steps: number }): Promise<BudgetDecision>;
/** Reported once per completed iteration, before the next one starts. */
onUsage?(usage: IterationUsage): void;
/** Reported exactly once when the turn ends, however it ends. */
Expand Down
5 changes: 3 additions & 2 deletions docs/guides/working-in-a-repository.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,9 @@ edit than describing the outcome.
**Ask before you change.** A question costs one turn and no writes, and it
tells you whether the agent has understood the project before you let it edit.

**Keep the scope to one thing.** A turn has a budget of 20 iterations. Two
unrelated changes in one prompt tends to produce a partial result for both.
**Keep the scope to one thing.** A turn works in stretches of 40 steps and asks
before taking another. Two unrelated changes in one prompt tends to mean
answering that question with neither of them finished.

## Git

Expand Down
6 changes: 3 additions & 3 deletions docs/introduction/why.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ the review.

## What it is bad at

**Large refactors across many files.** A turn is capped at 20 iterations. Broad
sweeping changes will run out of budget partway through, and you will get a
partial result rather than a clean stop.
**Large refactors across many files.** A turn works in stretches of 40 steps and
stops to ask before taking another, so a broad sweeping change means answering
that question repeatedly rather than handing the work over once.

**Long autonomous runs.** There is no plan-then-execute mode and no background
work. If you want to hand over a task and come back in an hour, this is the
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ the run.
| Variable | Default | Effect |
| --- | --- | --- |
| `WOOPCODE_PROVIDER` | `google` | Pairs with `WOOPCODE_API_KEY` |
| `WOOPCODE_MAX_ITERATIONS` | `20` | Steps the agent may take in one turn. The interactive default is deliberately low, because a human is waiting and a runaway loop spends their quota; an automated caller working one hard task wants far more |
| `WOOPCODE_MAX_ITERATIONS` | `40` | Steps a turn may take before it stops to ask whether to keep going. Interactively the ceiling is a checkpoint, so it is set to catch a stuck loop rather than to ration requests — the provider rations those itself, and answering the checkpoint grants another `40`. A headless run has nobody to ask, so this is the whole budget and exhausting it exits `2` |
| `WOOPCODE_MAX_ATTEMPTS` | `3` | Tries per provider request before the error surfaces |
| `WOOPCODE_TOOL_HISTORY_BUDGET` | unset (off) | Characters of tool history to keep before older results are compacted. Off by default — see the measurements in `runtime/compaction.ts` |
| `WOOPCODE_THINKING_BUDGET` | `-1` | Reasoning depth; see below |
Expand Down
5 changes: 4 additions & 1 deletion packages/tests/runtime/agentController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ mock.module("../../../providers/client", () => ({
createProviderClient,
}));

// Mock the UI store
// Mock the UI store. Every store method the controller calls needs an entry —
// this stub replaces the module for the whole run, so a missing one fails here
// and in any other file that touches the controller.
const mockStore = {
addUserMessage: mock(() => {}),
startTurn: mock(() => {}),
Expand All @@ -94,6 +96,7 @@ const mockStore = {
clearPendingEdit: mock(() => {}),
clearPendingCommand: mock(() => {}),
cancelPendingQuestion: mock(() => {}),
clearPendingContinuation: mock(() => {}),
};

mock.module("../../../tui/src", () => ({
Expand Down
6 changes: 3 additions & 3 deletions packages/tests/runtime/agentLoop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,10 +437,10 @@ describe("agentLoop - Iteration Limits", () => {
const promise = agentLoop(dynamicClient, messages, "", callbackSpy);

await expect(promise).rejects.toThrow(
"Agent exceeded the maximum number of iterations (20)",
"Agent exceeded the maximum number of iterations (40)",
);
expect(mockTool.executionCount).toBe(20);

expect(mockTool.executionCount).toBe(40);
});

test("completes successfully within iteration limit", async () => {
Expand Down
114 changes: 99 additions & 15 deletions packages/tests/runtime/iterationBudget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import {
agentLoop,
IterationBudgetExhaustedError,
} from "../../../runtime/loop";
import type { ProviderClient, StreamEvent } from "../../../config/types";
import type {
AgentCallbacks,
ProviderClient,
StreamEvent,
} from "../../../config/types";
import { MockTool, MockToolRegistry } from "../shared/mocks";
import { createRuntimeTest } from "../shared/testHelpers";

Expand Down Expand Up @@ -86,21 +90,21 @@ describe("iteration budget", () => {
delete process.env.WOOPCODE_MAX_ITERATIONS;

const error = await runToExhaustion();
expect(error?.message).toContain("(20)");
expect(error?.message).toContain("(40)");
});

test("a non-numeric budget falls back to the default", async () => {
process.env.WOOPCODE_MAX_ITERATIONS = "many";

const error = await runToExhaustion();
expect(error?.message).toContain("(20)");
expect(error?.message).toContain("(40)");
});

test("a non-positive budget falls back to the default", async () => {
process.env.WOOPCODE_MAX_ITERATIONS = "0";

const error = await runToExhaustion();
expect(error?.message).toContain("(20)");
expect(error?.message).toContain("(40)");
});
});

Expand All @@ -111,6 +115,14 @@ describe("iteration budget", () => {
* it was still starting new work at the wall, because the only notice went to
* stderr through onStatus. The model cannot act on something it was never sent.
*/
/** The nudge pushed into the conversation as the ceiling comes into view. */
const budgetNotices = (messages: Array<{ role: string; content?: string }>) =>
messages.filter(
(message) =>
message.role === "user" &&
(message.content ?? "").includes("before this turn is stopped"),
);

describe("running out of budget", () => {
/** Runs to exhaustion, keeping the transcript and the statuses. */
async function runKeepingMessages() {
Expand All @@ -125,26 +137,21 @@ describe("running out of budget", () => {
return { messages, callbacks };
}

const budgetNotices = (messages: Array<{ role: string; content?: string }>) =>
messages.filter(
(message) =>
message.role === "user" &&
(message.content ?? "").includes("before this turn is stopped"),
);

test("the model is told, not just the terminal", async () => {
test("the model is told, and only the model", async () => {
process.env.WOOPCODE_MAX_ITERATIONS = "8";

const { messages, callbacks } = await runKeepingMessages();

expect(budgetNotices(messages)).toHaveLength(1);
// The status still fires: the TUI shows it, and removing it would trade one
// audience for the other.
// The status used to fire too, back when reaching the ceiling ended the
// turn as a failure and this row was the user's only warning. The ceiling
// asks them directly now, so a transcript row saying the turn is nearly
// over is a worse version of the question they are about to be asked.
const statuses = callbacks
.getCallsByName("onStatus")
.map((call: { args: any[] }) => String(call.args[0]));
expect(statuses.some((text) => text.includes("iterations remaining"))).toBe(
true,
false,
);
});

Expand All @@ -167,3 +174,80 @@ describe("running out of budget", () => {
expect(budgetNotices(messages)).toHaveLength(0);
});
});

/**
* Reaching the ceiling asks rather than fails.
*
* The distinction that matters here is between "the user said stop" and "there
* was nobody to ask". They look the same from inside the loop and mean opposite
* things: one is a turn a human ended, the other is a headless run whose exit
* code a harness reads. An absent callback is the second, and must keep
* throwing however the first behaves.
*/
describe("the budget checkpoint", () => {
/** Runs to exhaustion with a handler, capturing what the loop did. */
async function runWithHandler(
onBudgetExhausted: AgentCallbacks["onBudgetExhausted"],
) {
const { callbacks, messages } = createRuntimeTest();
callbacks.onError = () => {};
callbacks.onBudgetExhausted = onBudgetExhausted;

let threw: unknown;
try {
await agentLoop(neverFinishingProvider(), messages, "", callbacks);
} catch (error) {
threw = error;
}
return { threw, callbacks, messages };
}

test("with nobody to ask, exhaustion is still an error", async () => {
process.env.WOOPCODE_MAX_ITERATIONS = "2";

// No handler at all — the headless case, whose exit code depends on this.
const error = await runToExhaustion();
expect(error).toBeInstanceOf(IterationBudgetExhaustedError);
});

test("continuing carries the same turn past the original ceiling", async () => {
process.env.WOOPCODE_MAX_ITERATIONS = "8";

let asked = 0;
const { messages } = await runWithHandler(async () => {
asked += 1;
// Continue once, then stop, so the test ends rather than looping forever.
return asked === 1 ? "continue" : "stop";
});

expect(asked).toBe(2);
// Once for the first eight steps, once for the eight the checkpoint added.
// The warning tracking the extension is how the model learns the second
// stretch is also finite.
expect(budgetNotices(messages)).toHaveLength(2);
});

test("the handler is told how many steps have been taken", async () => {
process.env.WOOPCODE_MAX_ITERATIONS = "3";

const seen: number[] = [];
await runWithHandler(async ({ steps }) => {
seen.push(steps);
return seen.length === 1 ? "continue" : "stop";
});

expect(seen).toEqual([3, 6]);
});

test("stopping ends the turn as a cancellation, not a failure", async () => {
process.env.WOOPCODE_MAX_ITERATIONS = "2";

const { threw, callbacks } = await runWithHandler(async () => "stop");

// Nothing thrown is the point: the controller marks a turn `error` from a
// raised exception, and this turn did not fail — it was halted.
expect(threw).toBeUndefined();
expect(callbacks.getCallsByName("onCancel")).toHaveLength(1);
expect(callbacks.getCallsByName("onError")).toHaveLength(0);
});
});
Loading