diff --git a/DOCS.md b/DOCS.md index ccb10ea..8a66de6 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1185,6 +1185,11 @@ defaults: model: openai-codex/gpt-5.6-sol thinking: high # minimal|low|medium|high (Pi engines) relay: auto # engine death mid-run: auto|resume|off + session_rotation_context: 180000 # live-context ceiling per agent session + # (tokens); past it the next phase starts + # a fresh session with a compact reseed + # instead of resuming the giant one. 0 = + # never rotate. tools: [read, bash, edit, write, grep, find, ls] # Pi tool allowlist protected_files: # deny-list enforced for EVERY agent - imp/modules/ @@ -1272,6 +1277,22 @@ Every switch is printed and traced — never silent. The config header repeats the golden billing rule: Claude INSIDE Pi bills per token as "extra usage" — always use `coding_agent: claude_code` to stay on the plan. +**Session rotation**: one engine session per agent per run means `build → +fix_1 → … → fix_ui` share one growing conversation — and a resumed session +re-reads its whole prefix on EVERY turn (cache reads are ~10% of the input +price, but on a long run they become ~94% of everything spent). Past +`defaults.session_rotation_context` (default 180000 tokens of live context; +`0` = never), the agent's NEXT phase starts a fresh session seeded with a +compact reseed block on the user prompt: what the run already changed, the +archived transcript path (Pi's session file is renamed +`pi_session..rotated.jsonl`, never deleted) and the previous envelope that +already rides the prompt template. The decision is taken only at phase start +(corrections inside a phase always continue their session), it stands down +when an engine-death marker targets the phase (the continuation preamble +already reseeds), and each rotation is printed and traced as a +`session_rotation` log event. Cursor reports no usage, so its sessions never +rotate. + **Permissions**: every agent phase snapshots the working tree (`git diff` + hashed untracked files); writes outside the agent's `writes` allowlist (or in `protected_files`) are rolled back. A fully-rolled-back breach retries the diff --git a/fia-templates/fia.config.yaml b/fia-templates/fia.config.yaml index 85823ce..b8fa351 100644 --- a/fia-templates/fia.config.yaml +++ b/fia-templates/fia.config.yaml @@ -87,6 +87,14 @@ defaults: # and on --resume; resume = fail fast now, arm the chain # only on --resume; off = never auto-switch (the death # is still recorded and traced). + # session_rotation_context: 180000 + # # live-context ceiling (tokens) per agent session. Past + # it, the next phase starts a FRESH session seeded with + # a compact recap instead of resuming the giant one — + # a resumed session re-reads its whole prefix on EVERY + # turn, which is where runaway runs burn most tokens. + # 0 = never rotate. Default lives in code + # (imp/modules/continuation.mjs). harness_engineering: [] tools: - read diff --git a/fia-templates/modules/agent-claude.mjs b/fia-templates/modules/agent-claude.mjs index 89e91c7..cb9beb9 100644 --- a/fia-templates/modules/agent-claude.mjs +++ b/fia-templates/modules/agent-claude.mjs @@ -59,6 +59,11 @@ export async function runClaude(request, { onEvent, onSpawn, onExit } = {}) { let output = 0; let cacheRead = 0; let cacheWrite = 0; + // Live context ≈ what the LAST turn processed (fresh input + the cached + // prefix it re-read) — NOT the invocation-accumulated sum, which grows + // with every turn and would make the session-rotation cap fire on any + // long phase regardless of actual context size. + let lastContext = 0; // Subscription billing: no dollar cost unless the CLI reports one (result event). let cost = 0; let sessionId = request.sessionId || ''; @@ -92,6 +97,7 @@ export async function runClaude(request, { onEvent, onSpawn, onExit } = {}) { output += usage.output; cacheRead += usage.cacheRead; cacheWrite += usage.cacheWrite; + lastContext = usage.input + usage.cacheRead + usage.cacheWrite; } } catch { text += line; @@ -114,7 +120,7 @@ export async function runClaude(request, { onEvent, onSpawn, onExit } = {}) { output_tokens: output, cache_read_tokens: cacheRead, cache_write_tokens: cacheWrite, - context_tokens: tokens, + context_tokens: lastContext, context_window: 0, }); }); diff --git a/fia-templates/modules/agents.mjs b/fia-templates/modules/agents.mjs index 9e51072..753cbb5 100644 --- a/fia-templates/modules/agents.mjs +++ b/fia-templates/modules/agents.mjs @@ -1,4 +1,4 @@ -import { readFileSync, statSync, writeFileSync, mkdirSync } from 'node:fs'; +import { existsSync, readFileSync, renameSync, statSync, writeFileSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { parse as parseYaml } from 'yaml'; import * as agentPi from './agent-pi.mjs'; @@ -8,6 +8,7 @@ import * as prompts from './prompts.mjs'; import * as permissions from './permissions.mjs'; import * as continuation from './continuation.mjs'; import { checkEngines, engineIssue } from './engines.mjs'; +import { runChangedPaths } from './git-helper.mjs'; import { makeStreamRecorder } from './stream-events.mjs'; import { extractJson, getOutputSchema } from './envelopes.mjs'; import { gateReport } from './gates.mjs'; @@ -402,10 +403,89 @@ function composeUserText(run, phase, agent, agentDir, variables, extras = {}) { }) + userText; } } + // The rotation reseed rides the USER prompt for the same caching reason. + // Mutually exclusive with the marker continuation by construction: a marker + // targeting this phase stands the rotation down (planSessionRotation). + if (extras.rotation) { + composed = + continuation.buildRotationPreamble({ + contextTokens: extras.rotation.contextTokens, + cap: extras.rotation.cap, + changedPaths: safeChangedPaths(run), + archivedTranscripts: extras.rotation.archivedTranscripts, + }) + composed; + } if (extras.permissionRetry) composed = permissionRetryPreamble(extras.permissionRetry) + composed; return composed; } +/** The run's changed paths, or [] when git is unavailable — never throws. */ +function safeChangedPaths(run) { + try { + return runChangedPaths(run.repoRoot, run.baseline); + } catch { + return []; + } +} + +/** + * Should this phase START A FRESH SESSION instead of resuming the agent's + * accumulated one? Rotation fires when the session's live context passed the + * cap (`defaults.session_rotation_context`): every turn of a resumed session + * re-reads the whole prefix, so past the cap a fresh session with a compact + * reseed is cheaper within a handful of turns. Skipped when an engine-death + * marker targets this phase — the continuation preamble already reseeds, and + * a Pi native resume needs the session file in place. Archives the Pi session + * file aside (never deleted: it stays as a read-only reference) and clears + * the agent-map entry so resumeSessionId naturally mints a new session. + */ +function planSessionRotation(run, phase, agent, agentDir) { + const cap = continuation.sessionRotationCapOf(run.cfg); + if (!(cap > 0)) return null; + const entry = run.agentMap[agent.name]; + // No session, or a model change: the next send is fresh anyway. + if (!entry || !entry.session_id || entry.model !== agent.model) return null; + const contextTokens = Number(entry.context_tokens) || 0; + if (contextTokens < cap) return null; + const marker = continuation.readEngineError(agentDir); + if (marker && marker.phase === phase.params.name) return null; + + const archivedTranscripts = [join(agentDir, 'raw_output.jsonl')]; + if (agent.coding_agent === 'pi') { + const sessionFile = join(agentDir, 'pi_session.jsonl'); + if (existsSync(sessionFile)) { + let n = 1; + while (existsSync(join(agentDir, `pi_session.${n}.rotated.jsonl`))) n += 1; + const archived = join(agentDir, `pi_session.${n}.rotated.jsonl`); + try { + renameSync(sessionFile, archived); + archivedTranscripts.push(archived); + } catch { + // The rename failing means the old session would still be resumed — + // rotating only the claude/cursor way (no --resume) is wrong for Pi, + // so stand down and try again next phase. + return null; + } + } + } + run.saveAgentMap(agent.name, { ...entry, session_id: '', context_tokens: 0 }); + run.console.note( + `${agent.name}: session rotated at ${contextTokens} context tokens (cap ${cap}) — fresh session with a compact reseed`, + ); + try { + run.tracer.event({ + fda_id: run.fdaId, + phase_id: phase.phase_id, + type: 'log', + name: 'session_rotation', + payload: { agent: agent.name, context_tokens: contextTokens, cap }, + }); + } catch { + /* tracing must never block the rotation */ + } + return { contextTokens, cap, archivedTranscripts }; +} + /** Persist the death (marker + engine_error event) — never masks the error. */ function recordEngineFailure(run, phase, agent, agentDir, error) { const marker = continuation.writeEngineError(agentDir, { @@ -488,6 +568,9 @@ export async function execute(run, phase, call) { // fallbacks only on --resume; 'off' never auto-switches. const relayMode = continuation.relayModeOf(run.cfg); const tried = new Set([engineKey(agent)]); + // Decided ONCE per phase, before the first send: a rotated session stays + // rotated for every relay leg (relay legs start fresh sessions anyway). + const rotation = planSessionRotation(run, phase, agent, agentDir); // One automatic retry after a fully-rolled-back allowlist breach. The // rollback IS the fix; the second attempt is told which paths to leave // alone. A second breach (or an unrecoverable one) surfaces to the engineer. @@ -496,7 +579,7 @@ export async function execute(run, phase, call) { for (;;) { // Composed and saved BEFORE the send so the audit copy under prompts/ // shows what was actually sent — continuation preamble included. - const userText = composeUserText(run, phase, agent, agentDir, variables, { permissionRetry }); + const userText = composeUserText(run, phase, agent, agentDir, variables, { permissionRetry, rotation }); prompts.savePromptDir(join(agentDir, 'prompts'), 'user.md', userText); try { const envelope = await attemptPhase(run, phase, call, agent, agentDir, systemText, userText); @@ -697,6 +780,9 @@ async function attemptPhase(run, phase, call, agent, agentDir, systemText, userT session_id: sessionId || result.session_id || '', model: agent.model, coding_agent: agent.coding_agent, + // Live context of the session after this phase — the rotation cap + // compares against this on the NEXT phase (planSessionRotation). + context_tokens: result.context_tokens || 0, }); run.tracer.agentSessionRow( run.fdaId, diff --git a/fia-templates/modules/continuation.mjs b/fia-templates/modules/continuation.mjs index 7aecd93..db302ef 100644 --- a/fia-templates/modules/continuation.mjs +++ b/fia-templates/modules/continuation.mjs @@ -286,6 +286,65 @@ export function relayModeOf(cfg) { return value === 'resume' || value === 'off' ? value : 'auto'; } +/** + * Live-context ceiling (tokens) above which an agent's engine session is + * rotated instead of resumed. Measured on a real project: sessions grew to + * 7-9M tokens of context, and since every turn re-reads the whole prefix, + * 94% of ALL tokens spent were cache reads of that prefix. Resuming a session + * at the cap costs ~cap tokens of cache read PER TURN; a fresh session pays + * one small cache write and starts re-reading from ~20k — break-even is a + * handful of turns, and build phases run hundreds. Tolerant like `stop:` + * (modules/stop.mjs): absent/invalid → the default; 0 = never rotate. + */ +export const SESSION_ROTATION_DEFAULT = 180000; + +export function sessionRotationCapOf(cfg) { + const raw = cfg?.defaults?.session_rotation_context; + if (raw === undefined || raw === null) return SESSION_ROTATION_DEFAULT; + const value = Number(raw); + if (!Number.isFinite(value) || value < 0) return SESSION_ROTATION_DEFAULT; + return Math.floor(value); +} + +/** Changed-path lines kept inline in the rotation preamble; the rest is counted. */ +const ROTATION_PATHS_CAP = 50; + +/** + * The reseed block prepended to the USER prompt of a rotated session (never + * the system prompt — that must stay byte-stable for caching). Deliberately + * compact: the trimmed previous envelope already rides the user template as + * {{previous_envelope}}, so this block only adds what a fresh session cannot + * know — that it is mid-run, what the run already changed, and where the + * archived transcript lives. + */ +export function buildRotationPreamble({ contextTokens, cap, changedPaths = [], archivedTranscripts = [] }) { + const paths = changedPaths.slice(0, ROTATION_PATHS_CAP).map((p) => `- ${p}`); + if (changedPaths.length > ROTATION_PATHS_CAP) { + paths.push(`- (+${changedPaths.length - ROTATION_PATHS_CAP} more — run \`git status\` for the full list)`); + } + const transcripts = archivedTranscripts.map((p) => `- ${p}`); + return [ + '## Session rotation (automatic)', + '', + `Your previous session for this run grew past the context cap (${contextTokens} tokens >= ${cap})`, + 'and was archived. You are continuing the SAME run in a fresh session — do NOT start from scratch.', + '', + ...(paths.length ? ['Files this run has already changed:', ...paths, ''] : []), + ...(transcripts.length ? ['Archived transcript(s) — read-only historical reference:', ...transcripts, ''] : []), + 'Rules:', + '1. The WORKSPACE is the authority on current state — read the actual files', + ' before trusting memory or any transcript claim.', + '2. Read an archived transcript selectively (scan the tail) and only when you', + ' are missing something; never re-read the whole file.', + "3. Your previous phase's Report envelope is included below — trust it for", + ' what was already delivered.', + '', + '---', + '', + '', + ].join('\n'); +} + /** * Does this marker justify switching engines? login/limit/missing arm on the * first death (waiting cannot fix an expired login mid-run, and a limit will diff --git a/pi-templates/.pi/skills/fia/cookbooks/run_fda.md b/pi-templates/.pi/skills/fia/cookbooks/run_fda.md index b6bfadd..3a109ab 100644 --- a/pi-templates/.pi/skills/fia/cookbooks/run_fda.md +++ b/pi-templates/.pi/skills/fia/cookbooks/run_fda.md @@ -48,6 +48,13 @@ news, not a defect: the optional `stop:` block of `imp/fia.config.yaml`; the code defaults apply when it is absent, so never tell the engineer they must add it. +A `session_rotation` log line in a run is normal, not a defect: past +`defaults.session_rotation_context` (default 180k tokens of live context) the +agent's next phase continues the SAME run in a fresh engine session with a +compact reseed — resuming a giant session re-reads its whole prefix on every +turn, which is where runaway runs burn most tokens. The archived transcript +stays on disk (`pi_session..rotated.jsonl` / `raw_output.jsonl`). + ## Automatic recovery (once) A first recoverable failure is retried in CODE, not by asking the engineer: diff --git a/test/fia-session-rotation.test.js b/test/fia-session-rotation.test.js new file mode 100644 index 0000000..32ecf72 --- /dev/null +++ b/test/fia-session-rotation.test.js @@ -0,0 +1,217 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { Tracer } from '../fia-templates/modules/tracer.mjs'; +import { Run } from '../fia-templates/modules/runner.mjs'; +import { engineAdapters, execute } from '../fia-templates/modules/agents.mjs'; +import { + SESSION_ROTATION_DEFAULT, + buildRotationPreamble, + sessionRotationCapOf, +} from '../fia-templates/modules/continuation.mjs'; + +// Rotation runs against IN-PROCESS engine fakes swapped into the exported +// engineAdapters dispatch table — no CLI is ever spawned (same harness as +// test/fia-relay.test.js). git is only used by the permission snapshots. + +function initGitRepo(root) { + execFileSync('git', ['init'], { cwd: root, stdio: 'ignore' }); + execFileSync('git', ['config', 'core.autocrlf', 'false'], { cwd: root, stdio: 'ignore' }); + execFileSync('git', ['config', 'user.email', 'fia@test.dev'], { cwd: root, stdio: 'ignore' }); + execFileSync('git', ['config', 'user.name', 'FIA Rotation'], { cwd: root, stdio: 'ignore' }); + writeFileSync(join(root, 'README.md'), '# rotation\n'); + writeFileSync(join(root, '.gitignore'), 'imp/\npe/\n'); + execFileSync('git', ['add', '.'], { cwd: root, stdio: 'ignore' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: root, stdio: 'ignore' }); +} + +function makeSetup({ codingAgent = 'claude_code', rotationCap } = {}) { + const root = mkdtempSync(join(tmpdir(), 'fia-rotation-')); + initGitRepo(root); + process.chdir(root); + const promptsDir = join(root, 'pe'); + mkdirSync(promptsDir, { recursive: true }); + writeFileSync(join(promptsDir, 'system.md'), 'You are the builder.'); + writeFileSync(join(promptsDir, 'user.md'), 'Task: {{prompt}}\nPrevious: {{previous_envelope}}'); + const cfg = { + defaults: { + data_dir: join(root, 'imp/data'), + ...(rotationCap === undefined ? {} : { session_rotation_context: rotationCap }), + }, + observability: { db: join(root, 'imp/data/fia.db') }, + agents: [ + { + name: 'builder', + coding_agent: codingAgent, + model: codingAgent === 'pi' ? 'openai-codex/gpt-5.6-sol' : 'sonnet', + writes: [], + tools: ['read'], + prompt_engineering: { system: join(promptsDir, 'system.md'), user: join(promptsDir, 'user.md') }, + }, + ], + }; + const tracer = new Tracer(cfg.observability.db, join(cfg.defaults.data_dir, 'sessions', 'run1', 'events.jsonl')); + tracer.sessionStart('run1', 'Tester', 'fda_test'); + const run = new Run(cfg, 'run1', tracer, 'Tester'); + const phaseOf = (name) => ({ + phase_id: `run1_${name}`, + fda_id: 'run1', + params: { name, owner: 'builder', kind: 'agent' }, + }); + const call = { prompt: 'build the thing', outputType: 'GenericOutput', gates: [] }; + return { root, cfg, run, phaseOf, call }; +} + +async function withAdapters(fakes, fn) { + const saved = { ...engineAdapters }; + Object.assign(engineAdapters, fakes); + try { + return await fn(); + } finally { + Object.assign(engineAdapters, saved); + } +} + +const okResult = (extra = {}) => ({ + text: JSON.stringify({ status: 'success', summary: 'done' }), + returncode: 0, + tokens: 100, + cost: 0, + input_tokens: 80, + output_tokens: 20, + cache_read_tokens: 0, + cache_write_tokens: 0, + session_id: 'sess-1', + context_tokens: 0, + context_window: 0, + ...extra, +}); + +const readEvents = (run) => + readFileSync(join(run.sessionDir, 'events.jsonl'), 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + +test('sessionRotationCapOf: absent/invalid values keep the default, 0 turns rotation off, overrides win', () => { + assert.equal(SESSION_ROTATION_DEFAULT, 180000); + assert.equal(sessionRotationCapOf(undefined), SESSION_ROTATION_DEFAULT); + assert.equal(sessionRotationCapOf({}), SESSION_ROTATION_DEFAULT); + assert.equal(sessionRotationCapOf({ defaults: {} }), SESSION_ROTATION_DEFAULT); + assert.equal(sessionRotationCapOf({ defaults: { session_rotation_context: 'lots' } }), SESSION_ROTATION_DEFAULT); + assert.equal(sessionRotationCapOf({ defaults: { session_rotation_context: -5 } }), SESSION_ROTATION_DEFAULT); + assert.equal(sessionRotationCapOf({ defaults: { session_rotation_context: 0 } }), 0); + assert.equal(sessionRotationCapOf({ defaults: { session_rotation_context: 250000.7 } }), 250000); +}); + +test('buildRotationPreamble: compact reseed with capped path list and read-only transcript rules', () => { + const manyPaths = Array.from({ length: 60 }, (_, i) => `src/file-${i}.ts`); + const text = buildRotationPreamble({ + contextTokens: 200000, + cap: 180000, + changedPaths: manyPaths, + archivedTranscripts: ['imp/data/sessions/run1/builder/pi_session.1.rotated.jsonl'], + }); + assert.match(text, /## Session rotation \(automatic\)/); + assert.match(text, /200000 tokens >= 180000/); + assert.match(text, /do NOT start from scratch/); + assert.match(text, /- src\/file-0\.ts/); + assert.match(text, /- src\/file-49\.ts/); + assert.ok(!text.includes('src/file-50.ts'), 'the path list is capped at 50 entries'); + assert.match(text, /\(\+10 more/); + assert.match(text, /pi_session\.1\.rotated\.jsonl/); + assert.match(text, /WORKSPACE is the authority/); + assert.match(text, /never re-read the whole file/); + // No paths and no transcripts: the sections disappear instead of rendering empty. + const bare = buildRotationPreamble({ contextTokens: 1, cap: 1 }); + assert.ok(!bare.includes('Files this run has already changed')); + assert.ok(!bare.includes('Archived transcript')); +}); + +test('rotation: past the cap the next phase starts a fresh session with the reseed preamble', async () => { + const { run, phaseOf, call } = makeSetup(); + const requests = []; + await withAdapters( + { + claude_code: async (request) => { + requests.push(request); + // First phase ends with the live context already past the default cap. + return okResult({ context_tokens: 200000, session_id: 'sess-1' }); + }, + }, + async () => { + await execute(run, phaseOf('build'), call); + assert.equal(run.agentMap.builder.session_id, 'sess-1'); + assert.equal(run.agentMap.builder.context_tokens, 200000); + + await execute(run, phaseOf('fix_1'), call); + }, + ); + assert.equal(requests.length, 2); + // First phase: no session to resume, no preamble. + assert.equal(requests[0].sessionId, null); + assert.ok(!requests[0].prompt.includes('## Session rotation')); + // Second phase: rotated — fresh session, reseed preamble, byte-stable system. + assert.equal(requests[1].sessionId, null, 'a rotated phase must not resume the giant session'); + assert.match(requests[1].prompt, /## Session rotation \(automatic\)/); + assert.match(requests[1].prompt, /200000 tokens >= 180000/); + assert.equal(requests[0].systemPrompt, requests[1].systemPrompt, 'rotation must never touch the system prompt'); + const rotationEvents = readEvents(run).filter((e) => e.type === 'log' && e.name === 'session_rotation'); + assert.equal(rotationEvents.length, 1); + assert.equal(rotationEvents[0].payload.context_tokens, 200000); + assert.equal(rotationEvents[0].payload.cap, 180000); +}); + +test('rotation: below the cap the session is resumed; 0 turns rotation off entirely', async () => { + for (const { cap, context } of [ + { cap: undefined, context: 50000 }, + { cap: 0, context: 900000 }, + ]) { + const { run, phaseOf, call } = makeSetup({ rotationCap: cap }); + const requests = []; + await withAdapters( + { + claude_code: async (request) => { + requests.push(request); + return okResult({ context_tokens: context, session_id: 'sess-1' }); + }, + }, + async () => { + await execute(run, phaseOf('build'), call); + await execute(run, phaseOf('fix_1'), call); + }, + ); + assert.equal(requests[1].sessionId, 'sess-1', `cap=${cap}: the session must be resumed`); + assert.ok(!requests[1].prompt.includes('## Session rotation')); + } +}); + +test('rotation: a Pi session file is archived aside, never deleted', async () => { + const { run, phaseOf, call } = makeSetup({ codingAgent: 'pi' }); + const agentDir = join(run.sessionDir, 'builder'); + const sessionFile = join(agentDir, 'pi_session.jsonl'); + const requests = []; + await withAdapters( + { + pi: async (request) => { + requests.push(request); + // The real adapter writes the session file; the fake simulates it. + mkdirSync(agentDir, { recursive: true }); + if (!existsSync(sessionFile)) writeFileSync(sessionFile, '{"turn":1}\n'); + return okResult({ context_tokens: 500000, session_id: sessionFile }); + }, + }, + async () => { + await execute(run, phaseOf('build'), call); + assert.ok(existsSync(sessionFile)); + await execute(run, phaseOf('fix_1'), call); + }, + ); + const archived = join(agentDir, 'pi_session.1.rotated.jsonl'); + assert.ok(existsSync(archived), 'the old Pi session must be archived, not deleted'); + assert.equal(readFileSync(archived, 'utf8'), '{"turn":1}\n'); + assert.match(requests[1].prompt, /pi_session\.1\.rotated\.jsonl/); +});