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
21 changes: 21 additions & 0 deletions DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down Expand Up @@ -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.<n>.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
Expand Down
8 changes: 8 additions & 0 deletions fia-templates/fia.config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion fia-templates/modules/agent-claude.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 || '';
Expand Down Expand Up @@ -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;
Expand All @@ -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,
});
});
Expand Down
90 changes: 88 additions & 2 deletions fia-templates/modules/agents.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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.
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
59 changes: 59 additions & 0 deletions fia-templates/modules/continuation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions pi-templates/.pi/skills/fia/cookbooks/run_fda.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<n>.rotated.jsonl` / `raw_output.jsonl`).

## Automatic recovery (once)

A first recoverable failure is retried in CODE, not by asking the engineer:
Expand Down
Loading
Loading