Skip to content
Merged
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
645fa16
Enforce a pre-spawn model budget for planning invocations (#163)
mchwang Oct 21, 2026
e2a11c6
Quote Claude's errors when a run stops without a result (#163)
mchwang Oct 21, 2026
e58d019
Name the turn limit in planning errors; label budget assumptions (#163)
mchwang Oct 21, 2026
f3998cb
Check the turn-limit envelope against a real Claude Code 2.1.281 run …
mchwang Oct 21, 2026
4c27611
Rewrap parseClaudeOutput's comment
mchwang Oct 21, 2026
98a6d1f
Rewrap parseClaudeOutput's comment
mchwang Oct 21, 2026
91a942f
Use the observed turn-limit envelope verbatim; note the limit-plus-on…
mchwang Oct 21, 2026
77c276c
Word the model-budget docs to their evidence (#163)
mchwang Oct 21, 2026
b01806f
Tie the planning input budget to the author's limits; note text-based…
mchwang Oct 21, 2026
03d9da0
Rewrap the plan-author builder note
mchwang Oct 21, 2026
4cde897
Check a prepared draft at the prompt limit against the model budget (…
mchwang Oct 21, 2026
e661388
Split the over-budget size into prompt and schema (#163)
mchwang Oct 21, 2026
2498340
Name every part of the request in the over-budget advice (#163)
mchwang Oct 21, 2026
e966141
Say which part of the backstop claim a test covers (#163)
mchwang Oct 21, 2026
4bb2fc1
Rewrap the budget note
mchwang Oct 21, 2026
0f16acc
Match the turn-limit text anywhere; note the errors text reaches ever…
mchwang Oct 21, 2026
cd5a8fb
Make the other-phases note its own bullet
mchwang Oct 21, 2026
58df0ce
Say that other output with the turn-limit text gets the same advice (…
mchwang Oct 21, 2026
69eb218
Re-record planning fixtures with the --max-turns budget (#163)
mchwang Oct 21, 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
22 changes: 13 additions & 9 deletions agents/adapters/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,26 +47,30 @@ function topLevelMembers(json: string): Map<string, string> {

/**
* Read Claude's `--output-format json` envelope. A result whose `subtype` is not `success`, or whose `is_error` is
* true, is a provider failure; it can omit `result`. With `--json-schema` (planning), a successful answer needs
* `subtype: "success"` and is the schema-validated `structured_output` object. It is returned as the exact JSON text
* of that member in the CLI's envelope, never re-encoded here, so the plan parser sees any duplicate key or inexact
* integer the envelope holds (#166). Whether the API or CLI resolves those before writing the envelope is not verified.
* With a schema, an envelope that repeats a top-level key is malformed, whatever it reports. `result` then holds only
* Claude's prose. Plain-text envelopes without a `subtype` are still accepted.
* true, is a provider failure; it can omit `result`, and when `result` is missing or empty its `errors` strings are
* the text. With `--json-schema` (planning), a successful answer needs `subtype: "success"` and is the
* schema-validated `structured_output` object. It is returned as the exact JSON text of that member in the CLI's
* envelope, never re-encoded here, so the plan parser sees any duplicate key or inexact integer the envelope holds
* (#166). Whether the API or CLI resolves those before writing the envelope is not verified. With a schema, an
* envelope that repeats a top-level key is malformed, whatever it reports. `result` then holds only Claude's prose.
* Plain-text envelopes without a `subtype` are still accepted.
*/
export function parseClaudeOutput(raw: Buffer, structured = false): { text: string; providerFailed: boolean } {
const source = new TextDecoder('utf-8', { fatal: true }).decode(raw);
const envelope = JSON.parse(source) as
{ subtype?: unknown; result?: unknown; is_error?: unknown; structured_output?: unknown };
{ subtype?: unknown; result?: unknown; is_error?: unknown; structured_output?: unknown; errors?: unknown };
// Read every member before classifying: JSON.parse keeps only the last of a repeated key, such as `is_error`.
const members = structured && envelope !== null && typeof envelope === 'object' && !Array.isArray(envelope)
? topLevelMembers(source) : null;
if (typeof envelope.is_error !== 'boolean' || (envelope.result !== undefined && typeof envelope.result !== 'string')
|| (envelope.subtype !== undefined && typeof envelope.subtype !== 'string'))
throw new Error('Claude returned a malformed output envelope.');
const prose = envelope.result ?? '';
if (envelope.is_error || (envelope.subtype !== undefined && envelope.subtype !== 'success'))
return Object.freeze({ text: prose, providerFailed: true });
if (envelope.is_error || (envelope.subtype !== undefined && envelope.subtype !== 'success')) {
// A run the CLI stops itself, such as at `--max-turns` (#163), gives no `result`, only its `errors` strings.
const errors = Array.isArray(envelope.errors) ? envelope.errors.filter(error => typeof error === 'string') : [];
return Object.freeze({ text: envelope.result || errors.join('; '), providerFailed: true });
}
if (!structured) {
if (envelope.result === undefined) throw new Error('Claude returned a malformed output envelope.');
return Object.freeze({ text: prose, providerFailed: false });
Expand Down
30 changes: 28 additions & 2 deletions agents/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,21 @@ export function dispatchApprovedCommand<T>(policy: PhasePolicy, argv: readonly s
return execute(Object.freeze([...argv]));
}

/**
* Planning's model budget (#163), checked when the command is built, before the adapter creates a container. The input
* is counted in UTF-8 bytes of the prompt and schema: Claude's tokenizer is not available offline, and a count of bytes
* bounds the tokens if every token covers at least one byte (assumed, not verified against Claude's tokenizer). 64 KiB
* is the 32 KiB prompt plus the 32 KiB of schema text that core/planning-author.ts allows, so this is a backstop for
* callers that skip it; it does not count Claude Code's own system prompt and tools, or the answer. The run is bounded
* by turns: if each turn sends at most one context window (assumed; the CLI's own calls, such as compaction, are not
* counted), `--max-turns` bounds the tokens a run can use (the CLI may make one call more than the limit). Claude stops
* at the limit with an `error_max_turns` result and exit status 1 (seen in a run of Claude Code 2.1.281 on macOS,
* outside the container), which the adapter reports as a provider failure. See docs/implementation/agent-isolation.md,
* "Planning model budget".
*/
export const MAX_PLANNING_INPUT_BYTES = 64 * 1024;
export const PLANNING_MAX_TURNS = 50;

/**
* Planning answers must match the request's schema, so the planning command carries it. Claude's `--json-schema` takes
* the schema text, not a path; the profile checks that text against the mounted file. Questions answer in plain text
Expand All @@ -86,11 +101,12 @@ export function createClaudeCommand(policy: PhasePolicy, prompt: string, schema?
if (assertPhasePolicy(policy).vendor !== 'claude') throw new Error('Claude command requires a Claude invocation policy.');
if ((policy.phase === 'planning') !== (schema !== undefined))
throw new Error('Only the Claude planning command carries an answer schema, and it must.');
const schemaArguments = schema === undefined ? [] : ['--json-schema', assertPlanningSchema(schema)];
const planningArguments = schema === undefined ? [] : ['--json-schema',
assertPlanningInput(prompt, assertPlanningSchema(schema)), '--max-turns', String(PLANNING_MAX_TURNS)];
Comment thread
mchwang marked this conversation as resolved.
Resolved
const writable = policy.worktree === 'read-write';
const allowed = writable ? 'Read,Glob,Grep,Edit,Write' : 'Read,Glob,Grep';
// `--` ends option parsing, so a prompt beginning with `-` stays prompt data.
return command(policy, ['claude', '--print', '--output-format', 'json', ...schemaArguments, '--restricted',
return command(policy, ['claude', '--print', '--output-format', 'json', ...planningArguments, '--restricted',
'--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', '--disable-slash-commands', '--no-chrome',
'--permission-prompts', 'none', '--permission-mode', writable ? 'acceptEdits' : 'plan', '--tools', allowed,
'--allowedTools', allowed, '--disallowedTools', 'Bash,WebFetch,WebSearch,NotebookEdit', '--add-dir',
Expand All @@ -117,6 +133,16 @@ export function createRunnerCommand(policy: PhasePolicy, commands: string): Agen
return command(policy, ['node', '/usr/local/bin/codeboost-command-check', '/run/codeboost-input/schema.json'], commands);
}

/** Refuse a planning request whose prompt and schema together exceed the model budget. Returns the schema unchanged. */
export function assertPlanningInput(prompt: string, schema: string): string {
const promptBytes = Buffer.byteLength(prompt, 'utf8'), schemaBytes = Buffer.byteLength(schema, 'utf8');
if (promptBytes + schemaBytes > MAX_PLANNING_INPUT_BYTES)
throw new Error(`Planning request is ${promptBytes + schemaBytes} bytes (prompt ${promptBytes}, schema ${schemaBytes}), `
+ `over the ${MAX_PLANNING_INPUT_BYTES}-byte model budget. Narrow the request (the issue and comments, the selected `
+ 'paths, the lessons, the feedback or the previous plan), then retry.');
return schema;
}

/**
* A planning schema must describe a JSON object (Claude returns `structured_output` as an object) and fit one command
* argument. Returns the schema unchanged.
Expand Down
31 changes: 31 additions & 0 deletions docs/implementation/agent-isolation.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Each row is a T9 requirement for the Docker suite. The suite fails if any row fa
| A read-only phase cannot write `/work` | `agent-container`: phase worktree for all five phases |
| Planning and questions cannot run a process | `agent-policy`: tool sets exclude the command tool, and command dispatch refuses these phases |
| Codex runs in no phase, because it cannot read code without its shell (see [Codex is refused in every phase](#codex-is-refused-in-every-phase)) | `agent-policy`: the Codex command refuses all five phases; `agent-adapter`: the Codex start call refuses them and allocates nothing; `runner-execution`: an execute task with Codex is refused before task storage exists |
| A planning request over its model budget is refused before the start, and the planning command passes `--max-turns 50` (see [planning model budget](#planning-model-budget) for what is and is not shown about the stop) | `agent-policy`: a prompt and schema of exactly 64 KiB build a command with `--max-turns 50`, and one byte more is refused; `agent-adapter`: the start call refuses an over-budget request and allocates nothing, and an `error_max_turns` envelope is a provider failure whose text is its `errors`; `planning-provider`: the planning error quotes the turn limit |
| A planning answer is checked against the mounted schema | `agent-policy`: only the Claude planning command passes `--json-schema`; `agent-container`: the profile refuses a command whose schema differs from the mounted file; `agent-adapter`: the answer is read from `structured_output` |
| Task and scratch byte and inode limits hold | `agent-container`: task capacity; scratch capacity for Codex and Claude (`/tmp`, `HOME`, `CODEX_HOME`, output directory) |
| Hard links and alias writes from `.git/config` and objects fail, and metadata stays unchanged | `agent-container`: metadata alias probe in planning, review and execute, with a digest of `.git` before and after |
Expand Down Expand Up @@ -101,6 +102,36 @@ Use only these entry points to run an agent:
one link, at most 64 KiB of UTF-8, and a JSON object with `"type": "object"`; otherwise the start call throws.
Leave out a draft 2020-12 `$schema` line: Claude's `--json-schema` rejects it (see [plan
format](../plan-format.md)).
- <a id="planning-model-budget"></a>**Planning model budget (#163).** `createClaudeCommand` checks it when it
builds the planning command, so the adapter's start call throws before it creates a container or network. The
runner's host clone and task storage already exist by then, and the runner removes them. The budget has two parts:
- *Input, before the start.* The prompt and the schema together must be at most `MAX_PLANNING_INPUT_BYTES` (64 KiB)
of UTF-8: the 32 KiB prompt plus 32 KiB of schema text. A larger request is refused with an error that gives its
size, split into prompt and schema, and asks the person to narrow the request (the issue and comments, the paths,
the lessons, the feedback or the previous plan). Bytes stand in for tokens because Claude's tokenizer is not
available offline. A count of bytes is an upper bound on the tokens if every token covers at least one byte; this
is assumed, not verified against Claude's tokenizer. `prepareDraft` and `prepareSuggestions` already limit the
prompt and the schema text to 32 KiB each, so this check refuses nothing they produce. It is a backstop for a
caller that skips them. A test checks a draft at the prompt limit with today's schema; no test fails if the
32 KiB schema bound in `core/planning-author.ts` is removed. It does not count Claude Code's own system prompt
and tool definitions, or the tokens of the answer.
- *The run.* The command passes `--max-turns 50` (`PLANNING_MAX_TURNS`). If each turn sends at most one context
window, a turn limit bounds the tokens a run can use with no tokenizer. That is assumed: calls the CLI makes for
itself, such as compaction, may not count as turns, and the bound is loose (about 50 context windows; the CLI
reported `num_turns: 2` when it stopped at a limit of 1, so it may make one call more than the limit). Claude
Code 2.1.281 accepts the flag in `--print` mode (its help hides it) and stops at the limit with an
`error_max_turns` result and exit status 1. This was checked with `--max-turns 1` in a run of the 2.1.281 macOS
arm64 build outside the container; the Linux build in the image was read from its bundled source only. The
adapter reports that result as a provider failure whose text is the result's `errors` strings ("Reached maximum
number of turns (1)" in that run). That holds in every Claude phase, so a failed question run quotes them too.
The planning error then names the 50-turn limit, asks the person to narrow the request, and quotes that text. The
planning error finds the stop by that text, so other output that contains it, such as Claude's own `result`, gets
the same advice; the output is quoted either way. A token or dollar limit was not chosen: `--max-budget-usd`
counts the dollar cost of API calls, which is not what an OAuth subscription token pays, and the CLI has no flag
that limits the tokens of a whole run. 50 turns is about 12 s for each turn in the ten-minute budget; a planning
run reads files and answers, so most turns are reads. Lower it if runs are seen to loop; raise it only with a
recorded run that needs more.
- *Other phases.* They do not pass `--max-turns` yet; their runs are bounded by time and output bytes only.
- **Codex.** `startCodexInvocation` throws for every phase, and allocates nothing. Use Claude. See
[Codex is refused in every phase](#codex-is-refused-in-every-phase).
5. To keep a stopped writable attempt's partial output, call `exportTaskDiff(storage, { base, imageId })` before
Expand Down
2 changes: 1 addition & 1 deletion docs/implementation/plan-library-alignment.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Issue #6 tracked the v1 plan library contract and the runner obligations that ca
| Output shown in the UI bounded: captured output comes only from the supervisor, which bounds stdout and stderr; when a planning run exits with an error, the provider's message quotes its output with whitespace collapsed, cut to at most 300 UTF-16 code units (`runner/planning-provider.ts`) | Done before this change; 300-unit quote tested in this change | `test/agent-supervisor.test.ts` output-limit cases (real Docker); `test/planning-provider.test.ts` ("quotes at most 300 code units of a failed run's output, with whitespace collapsed"). The Docker daemon's own log is #165 |
| Output limits at the exact byte | **This change** (tests; the supervisor already enforced the limits) | Supervisor enforcement: `test/agent-supervisor.test.ts` (real Docker). Decoder behaviour only, not a limit: the Claude decoder handles an envelope of exactly the stdout ceiling, and the 1 MiB parser limit applies to the decoded answer at the exact byte: `test/agent-adapter.test.ts`. At #6 the decoder re-serialized the answer, so the limit applied to the re-encoded text. *Later:* #166 returns the exact text of `structured_output` in the CLI's envelope, so the limit applies to those bytes |
| Prompt byte budget before spawn | Done before this change | `MAX_PROMPT_BYTES` in `core/planning-author.ts`; `test/planning-author.test.ts` ("accepts exactly the prompt byte limit and rejects the next byte without truncation") |
| Model budget before spawn | Moved to #163 | — |
| Model budget before spawn | Moved to #163. *Later:* done in #163 | `MAX_PLANNING_INPUT_BYTES` and `PLANNING_MAX_TURNS` in `agents/policy.ts`; `test/agent-policy.test.ts` ("accepts a planning request at exactly its model budget and refuses one byte more"); [agent isolation](agent-isolation.md#planning-model-budget) |
| Planning profile probed at startup | Moved to #164 | — |
| Docker daemon log limits | Moved to #165 | — |
| Claude structured output under the v1 parsing contract | Moved to #166 (found during this change). *Later:* fixed in #166 | `agents/adapters/claude.ts`; `test/agent-adapter.test.ts` ("lets draft validation refuse %s in a structured answer") |
Expand Down
14 changes: 8 additions & 6 deletions prompts/plan-author.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,14 @@
Reject NUL input and any oversized request before spawning; do not silently
truncate any source. Report which input exceeded the budget and ask the person
to narrow the issue/comments, selected path set, or lesson set before retrying.
Also enforce the selected pinned model profile's token budget (including
schema/system overhead and reserved answer tokens) before launch; missing
budget/tokenizer support fails closed. The launch probe must test these caps
on the supported container OS/CLI; lower them if needed, never raise them
automatically. Tests include one huge field, many small fields exceeding the
aggregate, post-escaping expansion, and exact boundary accepted/rejected inputs.
Also enforce the model budget before launch. With no tokenizer available
offline, lane D counts the prompt and schema in UTF-8 bytes (64 KiB together)
and bounds the run with Claude's --max-turns (#163; see agent-isolation.md,
"Planning model budget"; no probe tests them yet, #164). The launch probe
must test these caps on the supported container OS/CLI; lower them if
needed, never raise them automatically. Tests include one huge field, many
small fields exceeding the aggregate, post-escaping expansion, and exact
boundary accepted/rejected inputs.
In every serialized data string, escape <, >, and & as JSON Unicode escapes. Never insert
raw source text or recursively render placeholders inside serialized values.
These wrappers do not prevent semantic prompt injection: container permissions,
Expand Down
9 changes: 8 additions & 1 deletion runner/planning-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
import { basename, dirname, join } from 'node:path';
import type { InvocationInput, InvocationResult, StopReason } from '../agents/contract.ts';
import type { TaskStorageLimits } from '../agents/container/storage.ts';
import { PLANNING_MAX_TURNS } from '../agents/policy.ts';
import type { AuthorProvider, AuthorRequest } from '../core/planning-author.ts';
import { allocations, exceedsStorage, RetainedStorage, runReadOnlyAgent, sameContext, type ContainerDependencies, type LeftoverPolicy, type Provider,
type ReadOnlyFeature, type ReadOnlyRun, type RepositorySize } from './question-container.ts';
Expand Down Expand Up @@ -94,7 +95,13 @@ export function planningOutput(result: InvocationResult, invocation: InvocationI
throw new Error(`Claude stopped unexpectedly${result.signal ? ` (${result.signal})` : ''}.`);
if (result.exitCode !== 0) {
const detail = result.stdout.replace(/\s+/g, ' ').trim().slice(0, 300);
throw new Error(`Claude could not write the plan. Check its sign-in and usage limits.${detail ? ` Claude said: ${detail}` : ''}`);
// Claude Code's own text when `--max-turns` stops the run (#163). The decoder keeps no subtype, so this matches the
// text: a stop without it gets the general advice, and other output that contains it (Claude's own `result` text
// can) gets the turn-limit advice. Only the advice can be wrong; the text is still quoted.
const advice = detail.includes('Reached maximum number of turns')
? `Planning stopped at its ${PLANNING_MAX_TURNS}-turn limit before Claude answered. Narrow the request, then retry.`
: 'Check its sign-in and usage limits.';
throw new Error(`Claude could not write the plan. ${advice}${detail ? ` Claude said: ${detail}` : ''}`);
}
return result.stdout;
}
Expand Down
Loading
Loading