Skip to content

Commit 7bd5b5c

Browse files
ronshapiroclaude
andcommitted
Handle rate limits and scope tool permissions in headless calls
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent db2feb5 commit 7bd5b5c

4 files changed

Lines changed: 178 additions & 19 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import {asCliError, HeadlessClaudeError, primaryModel} from "./headless_claude";
2+
3+
describe("primaryModel", () => {
4+
test("returns undefined when there's no modelUsage", () => {
5+
expect(primaryModel(undefined)).toBeUndefined();
6+
});
7+
8+
test("returns undefined for an empty modelUsage", () => {
9+
expect(primaryModel({})).toBeUndefined();
10+
});
11+
12+
test("returns the only model when there's just one", () => {
13+
expect(primaryModel({"claude-sonnet-5": {costUSD: 0.01}})).toBe("claude-sonnet-5");
14+
});
15+
16+
test("picks the highest-cost model when several models were used in one session", () => {
17+
// A cheap sub-step on one model alongside the model that did the real generation work — the
18+
// real work should win, not whichever key happened to be inserted first.
19+
expect(primaryModel({
20+
"claude-haiku-4-5-20251001": {costUSD: 0.001142},
21+
"claude-sonnet-5": {costUSD: 0.14135159999999997},
22+
})).toBe("claude-sonnet-5");
23+
});
24+
25+
test("treats a missing costUSD as zero", () => {
26+
expect(primaryModel({
27+
"claude-haiku-4-5-20251001": {},
28+
"claude-sonnet-5": {costUSD: 0.01},
29+
})).toBe("claude-sonnet-5");
30+
});
31+
});
32+
33+
describe("asCliError", () => {
34+
test("returns undefined when the error has no stdout", () => {
35+
expect(asCliError(new Error("boom"))).toBeUndefined();
36+
});
37+
38+
test("returns undefined when stdout isn't valid JSON", () => {
39+
expect(asCliError({stdout: "not json"})).toBeUndefined();
40+
});
41+
42+
test("recovers a HeadlessClaudeError from a rate-limited response on stdout", () => {
43+
const stdout = JSON.stringify({
44+
is_error: true,
45+
result: "You've hit your session limit · resets 6:10pm",
46+
api_error_status: 429,
47+
});
48+
const recovered = asCliError({stdout});
49+
expect(recovered).toBeInstanceOf(HeadlessClaudeError);
50+
expect(recovered!.isRateLimited).toBe(true);
51+
expect(recovered!.message).toBe("You've hit your session limit · resets 6:10pm");
52+
});
53+
54+
test("a non-429 CLI error is not treated as rate-limited", () => {
55+
const stdout = JSON.stringify({is_error: true, result: "boom", api_error_status: 500});
56+
expect(asCliError({stdout})!.isRateLimited).toBe(false);
57+
});
58+
});

rsi_orchestrator/headless_claude.ts

Lines changed: 74 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,75 @@ const execFileAsync = promisify(execFile);
66
export interface HeadlessClaudeOptions {
77
cwd?: string;
88
timeoutMs?: number;
9+
// Defaults to a safe read-only set — this task family only needs to read repo files, not run
10+
// arbitrary commands or write anything. Without an explicit allow-list, headless calls have no
11+
// way to approve tool use, so anything beyond the default-allowed tools gets silently denied
12+
// and Claude wastes turns retrying workarounds instead of just reading the file.
13+
allowedTools?: string[];
914
}
1015

16+
const DEFAULT_ALLOWED_TOOLS = ["Read", "Grep", "Glob"];
17+
1118
export interface HeadlessClaudeResult {
1219
text: string;
13-
// The canonical model ID that actually ran (e.g. "claude-sonnet-5"), read back from
14-
// `--output-format json`'s modelUsage — undefined if the CLI didn't report one. Recording this
15-
// per generated artifact is what Phase 4's model-routing learning reads later.
20+
// The canonical model ID that actually did the substantive work (highest-cost entry in
21+
// modelUsage — a session can involve more than one model, e.g. a cheap model for a small
22+
// sub-step alongside the model that did the real generation). Recording this per generated
23+
// artifact is what Phase 4's model-routing learning reads later.
1624
model: string | undefined;
1725
costUsd: number | undefined;
1826
}
1927

2028
export type HeadlessClaudeRunner =
2129
(prompt: string, options?: HeadlessClaudeOptions) => Promise<HeadlessClaudeResult>;
2230

31+
/** Thrown when the CLI itself reports an error (as opposed to a malformed-response parse error).
32+
* `isRateLimited` distinguishes a usage/session-limit hit (429) — expected under subscription
33+
* billing, and the caller should stop the run rather than keep retrying every remaining
34+
* candidate against the same wall — from a genuine unexpected failure. */
35+
export class HeadlessClaudeError extends Error {
36+
public readonly isRateLimited: boolean;
37+
38+
constructor(
39+
message: string,
40+
public readonly apiErrorStatus: number | undefined,
41+
) {
42+
super(message);
43+
this.name = "HeadlessClaudeError";
44+
this.isRateLimited = apiErrorStatus === 429;
45+
}
46+
}
47+
2348
interface ClaudeCliJsonOutput {
2449
result: string;
2550
is_error: boolean; // eslint-disable-line camelcase
2651
total_cost_usd?: number; // eslint-disable-line camelcase
27-
modelUsage?: Record<string, unknown>;
52+
api_error_status?: number; // eslint-disable-line camelcase
53+
modelUsage?: Record<string, {costUSD?: number}>;
54+
}
55+
56+
export function primaryModel(modelUsage: ClaudeCliJsonOutput["modelUsage"]): string | undefined {
57+
if (!modelUsage) return undefined;
58+
const entries = Object.entries(modelUsage);
59+
if (entries.length === 0) return undefined;
60+
return entries.reduce((a, b) => ((b[1].costUSD ?? 0) > (a[1].costUSD ?? 0) ? b : a))[0];
61+
}
62+
63+
/**
64+
* The CLI often still writes valid JSON to stdout even when the process exits non-zero (e.g. a
65+
* rate limit) — execFile treats that as a rejected promise carrying an error whose `.stdout`
66+
* holds that JSON. Recover the structured error from it rather than surfacing a raw exec
67+
* failure with no usable information.
68+
*/
69+
export function asCliError(error: unknown): HeadlessClaudeError | undefined {
70+
const stdout = (error as {stdout?: string} | undefined)?.stdout;
71+
if (!stdout) return undefined;
72+
try {
73+
const parsed = JSON.parse(stdout) as ClaudeCliJsonOutput;
74+
return new HeadlessClaudeError(parsed.result, parsed.api_error_status);
75+
} catch {
76+
return undefined;
77+
}
2878
}
2979

3080
/**
@@ -37,22 +87,31 @@ interface ClaudeCliJsonOutput {
3787
* tests instead of depending on this one directly.
3888
*/
3989
export const runHeadlessClaude: HeadlessClaudeRunner = async (prompt, options = {}) => {
40-
const {stdout} = await execFileAsync(
41-
"claude",
42-
["-p", prompt, "--output-format", "json"],
43-
{
44-
cwd: options.cwd,
45-
timeout: options.timeoutMs,
46-
maxBuffer: 1024 * 1024 * 32,
47-
},
48-
);
90+
let stdout: string;
91+
try {
92+
({stdout} = await execFileAsync(
93+
"claude",
94+
[
95+
"-p", prompt,
96+
"--output-format", "json",
97+
"--allowedTools", (options.allowedTools ?? DEFAULT_ALLOWED_TOOLS).join(","),
98+
],
99+
{
100+
cwd: options.cwd,
101+
timeout: options.timeoutMs,
102+
maxBuffer: 1024 * 1024 * 32,
103+
},
104+
));
105+
} catch (e) {
106+
throw asCliError(e) ?? e;
107+
}
49108
const parsed = JSON.parse(stdout) as ClaudeCliJsonOutput;
50109
if (parsed.is_error) {
51-
throw new Error(`Headless Claude call failed: ${parsed.result}`);
110+
throw new HeadlessClaudeError(parsed.result, parsed.api_error_status);
52111
}
53112
return {
54113
text: parsed.result,
55-
model: parsed.modelUsage ? Object.keys(parsed.modelUsage)[0] : undefined,
114+
model: primaryModel(parsed.modelUsage),
56115
costUsd: parsed.total_cost_usd,
57116
};
58117
};

rsi_orchestrator/rashi_tosafot_translation.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {Edit} from "../precomputed/ai_edits";
2+
import {HeadlessClaudeError} from "./headless_claude";
23
import {
34
CritiqueVerdict,
45
GeneratedEdit,
@@ -124,6 +125,32 @@ describe("translateRashiTosafotComments", () => {
124125
expect(recordGeneration).not.toHaveBeenCalled();
125126
});
126127

128+
test("stops the whole run when generate hits a rate limit, without writing", async () => {
129+
const writeEdit = jest.fn();
130+
const generate = jest.fn()
131+
.mockRejectedValueOnce(new HeadlessClaudeError("You've hit your session limit", 429));
132+
await translateRashiTosafotComments(fakeGenerationDeps({
133+
listCandidates: () => [candidate({ref: "a"}), candidate({ref: "b"})],
134+
generate,
135+
writeEdit,
136+
}));
137+
expect(generate).toHaveBeenCalledTimes(1); // never reached candidate "b"
138+
expect(writeEdit).not.toHaveBeenCalled();
139+
});
140+
141+
test("skips a candidate on a non-rate-limit error and continues to the next", async () => {
142+
const written: string[] = [];
143+
await translateRashiTosafotComments(fakeGenerationDeps({
144+
listCandidates: () => [candidate({ref: "a"}), candidate({ref: "b"})],
145+
generate: async (c) => {
146+
if (c.ref === "a") throw new Error("transient CLI failure");
147+
return generated();
148+
},
149+
writeEdit: (c) => written.push(c.ref),
150+
}));
151+
expect(written).toEqual(["b"]);
152+
});
153+
127154
test("processes multiple candidates independently", async () => {
128155
const written: string[] = [];
129156
await translateRashiTosafotComments(fakeGenerationDeps({

rsi_orchestrator/rashi_tosafot_translation.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {Edit} from "../precomputed/ai_edits";
77
import {readGenerationRecord, upsertGenerationRecord} from "../precomputed/rsi_state/generation_record";
88
import {checkTextStaleness, DEFAULT_STALENESS_THRESHOLDS} from "../precomputed/rsi_state/staleness";
99
import {toFlatArray} from "../sefariaTextType";
10-
import {runHeadlessClaude} from "./headless_claude";
10+
import {HeadlessClaudeError, runHeadlessClaude} from "./headless_claude";
1111

1212
/**
1313
* Translates and punctuates Rashi/Tosafot comments — the first task type on the new agentic
@@ -73,6 +73,8 @@ export async function generateWithSelfCritique(
7373
export interface TranslationDeps {
7474
listCandidates: () => TranslationCandidate[];
7575
isFresh: (candidate: TranslationCandidate) => boolean;
76+
// May reject — translateRashiTosafotComments stops the whole run on a HeadlessClaudeError with
77+
// isRateLimited, and skips just this candidate on any other error.
7678
generate: (candidate: TranslationCandidate) => Promise<GeneratedEdit | undefined>;
7779
writeEdit: (candidate: TranslationCandidate, edit: Edit) => void;
7880
recordGeneration: (candidate: TranslationCandidate, generated: GeneratedEdit) => void;
@@ -81,9 +83,22 @@ export interface TranslationDeps {
8183
export async function translateRashiTosafotComments(deps: TranslationDeps): Promise<void> {
8284
for (const candidate of deps.listCandidates()) {
8385
if (deps.isFresh(candidate)) continue;
84-
// Deliberately sequential — see the same rationale in triage_suggestions.ts.
85-
// eslint-disable-next-line no-await-in-loop
86-
const generated = await deps.generate(candidate);
86+
let generated;
87+
try {
88+
// Deliberately sequential — see the same rationale in triage_suggestions.ts.
89+
// eslint-disable-next-line no-await-in-loop
90+
generated = await deps.generate(candidate);
91+
} catch (e) {
92+
if (e instanceof HeadlessClaudeError && e.isRateLimited) {
93+
// Every remaining candidate would fail against the same wall — stop the run rather than
94+
// burn through it logging the identical failure. Subscription usage limits are the
95+
// expected budget signal under self-hosted billing; see RecursiveSelfImprovingAgentPlan.md.
96+
console.error(`Stopping: hit the usage limit (${e.message})`);
97+
return;
98+
}
99+
console.error(`Skipping ${candidate.ref}: ${e}`);
100+
continue;
101+
}
87102
if (!generated) continue;
88103
deps.writeEdit(candidate, generated.edit);
89104
deps.recordGeneration(candidate, generated);

0 commit comments

Comments
 (0)