- Added `background` option to `bash` tool to allow long-lived commands to run without blocking the turn. - Introduced `bash_status` tool to check the status and output of background commands. - Added `bash_stop` tool to explicitly stop running background commands. - Updated command parsing to handle `/bash` commands for listing, stopping individual, and stopping all background commands. - Enhanced session management to reap stale background commands on agent shutdown. - Updated UI to reflect background command status and allow user interruption via ctrl-c. - Added tests for background command functionality and command parsing. - Documented new features and usage in background-bash.md.
1269 lines
53 KiB
TypeScript
1269 lines
53 KiB
TypeScript
import {
|
|
isStepCount,
|
|
generateText,
|
|
streamText,
|
|
APICallError,
|
|
type Instructions as SdkInstructions,
|
|
type LanguageModel,
|
|
type ModelMessage,
|
|
type ToolApprovalResponse,
|
|
type ToolSet,
|
|
} from 'ai';
|
|
import { DEFAULT_VARIANT, sdkReasoning, renderAgent, type AgentVariant } from './agents';
|
|
import { createAskTool, type AskFn } from './ask';
|
|
import type { Instructions } from './instructions';
|
|
import type { Memory } from './memory';
|
|
import { Notebook, type NotebookState } from './notebook';
|
|
import { Permissions, type PermissionConfig } from './permission';
|
|
import type { PluginHost } from './plugins';
|
|
import { costOf, formatUsd } from './pricing';
|
|
import { systemPrompt } from './prompt';
|
|
import { detachProviderItems, droppedSpan, estimateTokens as pruneEstimateTokens, pruneToFit } from './prune';
|
|
import { walk } from './ignore';
|
|
import { createSkillTool, renderSkills, type Skill } from './skills';
|
|
import { suggestSkillsFromTranscript, writeAutoSkill } from './skill-learner';
|
|
import { disabledToolNames, onBashOutput, tools as builtinTools, type ToolSetName } from './tools';
|
|
import { onBeforeWrite, SnapshotStack, type FileState } from './snapshot';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
import { existsSync, readFileSync, statSync, readdirSync } from 'node:fs';
|
|
|
|
export type ApprovalRequest = {
|
|
approvalId: string;
|
|
toolName: string;
|
|
input: unknown;
|
|
/** The rule that decided this needs asking, when one did. */
|
|
matchedPattern?: string;
|
|
/** What `always` would whitelist, e.g. `git *` rather than every bash call. */
|
|
suggestedPattern: string;
|
|
/** Set when the call is being asked about because it repeated, not because of a rule. */
|
|
repeated?: boolean;
|
|
/** Set when a `worker` subagent is asking, not the main agent. */
|
|
subagent?: boolean;
|
|
};
|
|
|
|
/** Files a turn changed on disk, for /changes. Absolute paths, classified. */
|
|
export type ChangeSummary = {
|
|
added: string[];
|
|
modified: string[];
|
|
deleted: string[];
|
|
};
|
|
|
|
/** 'once' runs this call only; 'always' whitelists the suggested pattern for the session. */
|
|
export type ApprovalDecision = 'once' | 'always' | 'deny';
|
|
|
|
export type AgentEvent =
|
|
| { type: 'text'; text: string }
|
|
| { type: 'reasoning'; text: string }
|
|
| { type: 'tool-start'; id: string; name: string }
|
|
| { type: 'tool-call'; id: string; name: string; input: unknown }
|
|
| { type: 'tool-output'; id: string; chunk: string }
|
|
| { type: 'tool-result'; id: string; name: string; output: unknown }
|
|
| { type: 'tool-error'; id: string; name: string; error: unknown }
|
|
| { type: 'tool-denied'; name: string }
|
|
| { type: 'compacted'; before: number; after: number }
|
|
| { type: 'notice'; text: string }
|
|
| { type: 'error'; error: unknown }
|
|
| { type: 'done'; inputTokens?: number; outputTokens?: number };
|
|
|
|
export type SessionOptions = {
|
|
model: LanguageModel;
|
|
/** Model id, for pricing the session's spend against the ceiling. */
|
|
modelId?: string;
|
|
/** Subagent model id, when it differs; its spend prices against this. */
|
|
subagentModelId?: string;
|
|
askApproval: (req: ApprovalRequest) => Promise<ApprovalDecision>;
|
|
yolo?: boolean;
|
|
cwd?: string;
|
|
maxSteps?: number;
|
|
/** USD ceiling: warn at 80%, refuse the next turn at 100%. */
|
|
maxSpendUsd?: number;
|
|
/** USD ceiling per turn: abort a step if this turn's spend delta crosses it. */
|
|
maxSpendPerTurn?: number;
|
|
/** MCP and subagent tools merged on top of the built-ins. */
|
|
extraTools?: ToolSet;
|
|
/** Tool sets offered this session; omit for all of them. `core` is always on. */
|
|
toolSets?: readonly ToolSetName[];
|
|
/** Rules deciding which calls run, ask, or are refused. Omit for the defaults. */
|
|
permissions?: PermissionConfig;
|
|
/** Tool names that never prompt, e.g. the read-only subagent tool. */
|
|
autoApprove?: readonly string[];
|
|
/** Prune the history once the estimated token count crosses this. */
|
|
compactThreshold?: number;
|
|
/** Retries per model call for transient failures. */
|
|
maxRetries?: number;
|
|
/** AGENTS.md-style files appended to the system prompt. */
|
|
instructions?: Instructions;
|
|
/** Task list restored from a resumed session. */
|
|
notebook?: NotebookState;
|
|
/** Thinking level, tool restrictions, and behaviour appendix. */
|
|
agent?: AgentVariant;
|
|
skills?: Skill[];
|
|
memory?: Memory;
|
|
plugins?: PluginHost;
|
|
/** Where an `ask` tool call goes. Omit in headless runs. */
|
|
ask?: AskFn;
|
|
messages?: ModelMessage[];
|
|
onChange?: (messages: ModelMessage[]) => void;
|
|
/** Live stdout/stderr from bash, for a UI that wants progress. */
|
|
onToolOutput?: (id: string, chunk: string) => void;
|
|
onNotebookChange?: (state: NotebookState) => void;
|
|
/** Cheap model for background learning; falls back to main model. */
|
|
learnerModel?: LanguageModel;
|
|
/** Emit learner notices to the UI. */
|
|
onNotice?: (text: string) => void;
|
|
/** Split the system prompt and mark the stable head cacheable (Anthropic cache_control). */
|
|
cacheSystemPrefix?: boolean;
|
|
/** Ignore-aware file list injected into the system prompt at boot; gitignore-respected. */
|
|
workspaceFiles?: readonly string[];
|
|
/** Project-driven workflow: TODO/ROADMAP tracking + verify-before-done nudges. */
|
|
workflow?: {
|
|
/** Master switch. Default true. */
|
|
enabled?: boolean;
|
|
/** Where the project keeps developer docs. Default 'docs'. */
|
|
docsDir?: string;
|
|
};
|
|
/** Disable background auto-learn (tests). */
|
|
disableAutoLearn?: boolean;
|
|
};
|
|
|
|
const estimateTokens = pruneEstimateTokens;
|
|
|
|
/** Estimated tokens at which the wire history is pruned. */
|
|
const DEFAULT_COMPACT_THRESHOLD = 120_000;
|
|
|
|
/** Identical calls in one turn before an allowed tool is asked about anyway. */
|
|
const REPEAT_LIMIT = 3;
|
|
|
|
const callKey = (toolName: string, input: unknown) => `${toolName}:${JSON.stringify(input ?? null)}`;
|
|
|
|
/**
|
|
* The provider rejected an `item_reference` because it no longer holds that item:
|
|
* 404 "Item with id 'msg_...' not found". Retrying the same history repeats it, so
|
|
* this is the one failure that is worth answering by rewriting the history.
|
|
*/
|
|
const isStaleItemError = (error: unknown): boolean =>
|
|
APICallError.isInstance(error) && /item with id '[^']*' not found/i.test(error.message);
|
|
|
|
const STALE_ITEM_NOTICE =
|
|
'The provider no longer had part of this session stored. Re-sent the history inline and carried on.';
|
|
|
|
async function summarizeDiscarded(span: ModelMessage[], model: LanguageModel): Promise<string | undefined> {
|
|
if (span.length === 0) return undefined;
|
|
const excerpt = span
|
|
.map((m) => {
|
|
const c = typeof m.content === 'string' ? m.content : JSON.stringify(m.content).slice(0, 2000);
|
|
return `${m.role}: ${c}`;
|
|
})
|
|
.join('\n')
|
|
.slice(0, 6000);
|
|
if (!excerpt.trim()) return undefined;
|
|
try {
|
|
const { text } = await generateText({
|
|
model,
|
|
system: 'Summarize the dropped part of a long coding session so nothing important is lost. Keep: user goals, files touched with paths, decisions and why, tool results that matter, and what remains. One short handover note, 3-6 lines, no preamble.',
|
|
prompt: excerpt,
|
|
maxRetries: 1,
|
|
});
|
|
const t = text.trim();
|
|
return t.length > 0 ? t : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
type ApprovalContext = Pick<ApprovalRequest, 'matchedPattern' | 'suggestedPattern' | 'repeated'>;
|
|
|
|
async function maybeLearn(
|
|
messages: import('ai').ModelMessage[],
|
|
mainModel: import('ai').LanguageModel,
|
|
learnerModel: import('ai').LanguageModel | undefined,
|
|
memory: import('./memory').Memory | undefined,
|
|
spend: () => { overWarn: boolean },
|
|
onNotice?: (t: string) => void,
|
|
): Promise<void> {
|
|
if (spend().overWarn) return;
|
|
const model = learnerModel ?? mainModel;
|
|
if ((model as unknown as { provider?: string }).provider === 'unconfigured') return;
|
|
// project memory: spesifik repo, keep paths — use cheaper learner model when available
|
|
if (memory) {
|
|
try {
|
|
const cands = await memory.suggestFromTranscript(messages as { role: string; content: unknown }[], model);
|
|
// at most 1 per turn to avoid spam; best-effort
|
|
if (cands.length > 0) {
|
|
const c = cands[0]!;
|
|
const added = await memory.add(c.kind, c.text);
|
|
if (added && onNotice) onNotice(`auto-memory: remembered (${c.kind}) ${c.text.slice(0, 80)}`);
|
|
}
|
|
} catch (e) { if (onNotice) onNotice(`auto-memory skipped: ${(e as Error).message?.slice(0, 120)}`); }
|
|
}
|
|
// general skill: universal pattern — write to disk; caller (Session) will hot-reload via loadSkills on next turn if needed
|
|
try {
|
|
const skills = await suggestSkillsFromTranscript(messages as { role: string; content: unknown }[], model);
|
|
for (const c of skills) {
|
|
const p = await writeAutoSkill(c);
|
|
if (p && onNotice) onNotice(`auto-skill: ${c.name} → ${p}`);
|
|
}
|
|
} catch (e) { if (onNotice) onNotice(`auto-skill skipped: ${(e as Error).message?.slice(0, 120)}`); }
|
|
}
|
|
|
|
export class Session {
|
|
readonly messages: ModelMessage[];
|
|
tools: ToolSet;
|
|
readonly notebook: Notebook;
|
|
inputTokens = 0;
|
|
outputTokens = 0;
|
|
/** Subagent token use, priced against the subagent's own model id in /cost. */
|
|
subagentInputTokens = 0;
|
|
subagentOutputTokens = 0;
|
|
private model: LanguageModel;
|
|
private variant: AgentVariant;
|
|
private permissions: Permissions;
|
|
private currentSkills: Skill[];
|
|
private pluginHost: PluginHost | undefined;
|
|
private pendingSkills: Skill[] | undefined;
|
|
private pendingHost: PluginHost | undefined;
|
|
/** Calls seen this turn, for the repeat guard. Cleared per turn, not per step. */
|
|
private readonly seen = new Map<string, number>();
|
|
/** One stale-item repair per turn, so a repeating 404 cannot loop the run. */
|
|
private staleItemsRepaired = false;
|
|
/** The 80% spend warning is shown once, not on every turn past the line. */
|
|
private warnedSpend = false;
|
|
private controller: AbortController | undefined;
|
|
private readonly snapshots = new SnapshotStack();
|
|
private turnBeforeLen = 0;
|
|
private turnBeforeFiles = new Map<string, FileState>();
|
|
private learnTurns = 0;
|
|
private lastLearnLen = 0;
|
|
/** Versions of the volatile prompt parts; a change busts the system-prompt cache. */
|
|
private readonly versions = { notebook: 0, memory: 0, skills: 0, plugins: 0, tools: 0, workspace: 0, workflow: 0 };
|
|
private promptCache: { key: string; text: string } | undefined;
|
|
private readonly cacheStats = { hits: 0, misses: 0 };
|
|
/** Current ignore-aware file list; refreshed at turn boundaries after writes. */
|
|
private workspaceFiles: readonly string[] | undefined;
|
|
private lastWalkSeq = 0;
|
|
/** USD at the start of the current turn, for the per-turn cap. */
|
|
private turnStartUsd: number | undefined;
|
|
/** One notice per turn when the per-turn cap trips, so a capped turn is not silent. */
|
|
private turnCappedNotice: string | undefined;
|
|
/** Did the current turn call todo_write? Gates the workflow nudge. */
|
|
private todoWrittenThisTurn = false;
|
|
/** How many times this session has nudged about the task list; capped at 3. */
|
|
private workflowNudgeCount = 0;
|
|
/** Line count of TODO.md at last check, for /workflow. */
|
|
private workflowTodoLines = 0;
|
|
private workflowRoadmapLines = 0;
|
|
private workflowDocsFiles = 0;
|
|
private workflowHasTodo = false;
|
|
private workflowHasRoadmap = false;
|
|
private workflowHasDocs = false;
|
|
|
|
constructor(private readonly opts: SessionOptions) {
|
|
this.messages = opts.messages ?? [];
|
|
// The notebook's onChange is wrapped so a todo_write mid-turn bumps the
|
|
// prompt-cache version: the task list is part of the system prompt, so a
|
|
// stale cached prompt would keep serving an outdated plan.
|
|
this.notebook = new Notebook((state) => {
|
|
this.opts.onNotebookChange?.(state);
|
|
this.bump('notebook');
|
|
});
|
|
this.notebook.restore(opts.notebook);
|
|
this.model = opts.model;
|
|
this.variant = opts.agent ?? DEFAULT_VARIANT;
|
|
this.currentSkills = opts.skills ?? [];
|
|
this.pluginHost = opts.plugins;
|
|
this.workspaceFiles = opts.workspaceFiles;
|
|
this.lastWalkSeq = this.fileChangeSeq;
|
|
// remember/recall/forget change what memory.render() prints next turn, so
|
|
// they must invalidate the cached prompt.
|
|
this.opts.memory?.setOnChange?.(() => this.bump('memory'));
|
|
const built = this.buildSessionTools();
|
|
this.tools = built.tools;
|
|
this.permissions = built.permissions;
|
|
}
|
|
|
|
private buildSessionTools(): { tools: ToolSet; permissions: Permissions } {
|
|
// skill tool reads live currentSkills so hot-reload is visible next turn
|
|
const skillTool: ToolSet = this.currentSkills.length > 0 ? { skill: this.createLiveSkillTool() } : {};
|
|
const sessionTools: ToolSet = {
|
|
...this.notebook.tools(),
|
|
...(this.opts.memory ? this.opts.memory.tools() : {}),
|
|
...skillTool,
|
|
...(this.opts.ask ? { ask: createAskTool(this.opts.ask) } : {}),
|
|
};
|
|
const tools: ToolSet = { ...builtinTools, ...sessionTools, ...(this.pluginHost?.tools ?? {}), ...(this.opts.extraTools ?? {}) };
|
|
const permissions = new Permissions({
|
|
...(this.opts.permissions ? { config: this.opts.permissions } : {}),
|
|
...(this.opts.yolo ? { yolo: true } : {}),
|
|
autoApprove: [
|
|
...(this.opts.autoApprove ?? []),
|
|
...(this.pluginHost?.autoApprove ?? []),
|
|
...Object.keys(sessionTools),
|
|
],
|
|
});
|
|
return { tools, permissions };
|
|
}
|
|
|
|
private createLiveSkillTool() {
|
|
const getSkills = () => this.currentSkills;
|
|
// keep description static at create time to satisfy tool() typing, but lookup is live
|
|
const names = getSkills().map(s=>s.name).join(', ') || 'none';
|
|
// use imported tool/z directly so types stay clean
|
|
const { tool: mkTool } = require('ai') as unknown as { tool: typeof import('ai').tool };
|
|
const zod = require('zod') as unknown as typeof import('zod');
|
|
return mkTool({
|
|
description: 'Load a skill: detailed instructions for one kind of task. Call it as soon as a skill description matches what you are about to do, then follow what it says. Available: ' + names + '.',
|
|
inputSchema: zod.z.object({ name: zod.z.string().describe('Skill name from the list in your instructions') }),
|
|
execute: async ({ name }: { name: string }) => {
|
|
const skills = getSkills();
|
|
const skill = skills.find(s => s.name === name.trim().toLowerCase());
|
|
if (!skill) throw new Error(`No skill named "${name}". Available: ${skills.map(s=>s.name).join(', ') || 'none'}`);
|
|
return `Skill "${skill.name}" (${skill.origin}). Follow these instructions for this task.\n\n${skill.body}`;
|
|
},
|
|
});
|
|
}
|
|
|
|
private rebuild(): void {
|
|
const built = this.buildSessionTools();
|
|
this.tools = built.tools;
|
|
this.permissions = built.permissions;
|
|
this.bump('tools');
|
|
}
|
|
|
|
/** Hot-reload: skills/plugins take effect next turn; in-flight turn is untouched. */
|
|
updateSkills(skills: Skill[]): void {
|
|
this.bump('skills');
|
|
if (this.controller) { this.pendingSkills = skills; return; }
|
|
this.currentSkills = skills;
|
|
this.rebuild();
|
|
}
|
|
updatePlugins(host: PluginHost): void {
|
|
this.bump('plugins');
|
|
if (this.controller) { this.pendingHost = host; return; }
|
|
this.pluginHost = host;
|
|
this.rebuild();
|
|
}
|
|
private drainPendingHotReload(): void {
|
|
let changed = false;
|
|
if (this.pendingSkills !== undefined) { this.currentSkills = this.pendingSkills; this.pendingSkills = undefined; changed = true; }
|
|
if (this.pendingHost !== undefined) { this.pluginHost = this.pendingHost; this.pendingHost = undefined; changed = true; }
|
|
if (changed) this.rebuild();
|
|
}
|
|
|
|
/**
|
|
* The approval channel a `worker` subagent uses for its gated calls.
|
|
*
|
|
* Same rules, same prompt, same grants as a direct call: a subagent that could
|
|
* approve its own writes would be a way to launder a tool call past the user.
|
|
* Handed to `createTaskTool` from cli.tsx, which is where the two are wired.
|
|
*/
|
|
approveForSubagent(): (req: { toolName: string; input: unknown }) => Promise<boolean> {
|
|
return async ({ toolName, input }) => {
|
|
const blocked = await (this.pluginHost ?? this.opts.plugins)?.guard({
|
|
toolName,
|
|
input,
|
|
cwd: this.opts.cwd ?? process.cwd(),
|
|
});
|
|
if (blocked) return false;
|
|
|
|
const { decision, pattern } = this.permissions.check(toolName, input);
|
|
if (decision === 'deny') return false;
|
|
if (decision === 'allow') return true;
|
|
|
|
const answer = await this.opts.askApproval({
|
|
approvalId: `sub:${toolName}`,
|
|
toolName,
|
|
input,
|
|
...(pattern ? { matchedPattern: pattern } : {}),
|
|
suggestedPattern: this.permissions.suggest(toolName, input),
|
|
subagent: true,
|
|
});
|
|
if (answer === 'always') this.permissions.grant(toolName, this.permissions.suggest(toolName, input));
|
|
return answer !== 'deny';
|
|
};
|
|
}
|
|
|
|
setModel(model: LanguageModel): void {
|
|
this.model = model;
|
|
this.bump('tools');
|
|
}
|
|
|
|
setAgent(variant: AgentVariant): void {
|
|
this.variant = variant;
|
|
this.bump('skills');
|
|
}
|
|
|
|
agent(): AgentVariant {
|
|
return this.variant;
|
|
}
|
|
|
|
/**
|
|
* Tool names offered this turn. A read-only variant hides the mutating tools;
|
|
* a disabled tool set is withheld from the wire and from the prompt, since a
|
|
* prompt that names an absent tool teaches calls that cannot succeed.
|
|
*/
|
|
activeTools(): string[] {
|
|
const withheld = new Set(disabledToolNames(this.opts.toolSets));
|
|
// __mcpServerNames is bookkeeping, not a tool
|
|
const all = Object.keys(this.tools).filter((name) => name !== '__mcpServerNames' && !withheld.has(name));
|
|
if (!this.variant.allowTools) return all;
|
|
return all.filter((name) => this.variant.allowTools!.includes(name));
|
|
}
|
|
|
|
private bump(part: keyof Session['versions']): void {
|
|
this.versions[part] += 1;
|
|
this.promptCache = undefined;
|
|
}
|
|
|
|
/** Cache hit rate for the system prompt, surfaced in /cost. */
|
|
promptCacheStats(): { hits: number; misses: number } {
|
|
return { ...this.cacheStats };
|
|
}
|
|
|
|
/**
|
|
* The git root of the workspace, walking up like instructions.ts does.
|
|
* Returns undefined outside a repo (bare dirs get no project workflow).
|
|
* Sync: called on the hot path (systemFor), must not block, so it uses
|
|
* Node's existsSync over Bun.file(...).exists().
|
|
*/
|
|
private gitRoot(): string | undefined {
|
|
if (this.gitRootResolved) return this.cachedGitRoot ?? undefined;
|
|
this.gitRootResolved = true;
|
|
try {
|
|
let dir = resolve(this.opts.cwd ?? process.cwd());
|
|
while (true) {
|
|
if (existsSync(join(dir, '.git', 'HEAD'))) {
|
|
this.cachedGitRoot = dir;
|
|
return dir;
|
|
}
|
|
const parent = dirname(dir);
|
|
if (parent === dir) {
|
|
this.cachedGitRoot = null;
|
|
return undefined;
|
|
}
|
|
dir = parent;
|
|
}
|
|
} catch {
|
|
this.cachedGitRoot = null;
|
|
return undefined;
|
|
}
|
|
}
|
|
private cachedGitRoot: string | null | undefined;
|
|
private gitRootResolved = false;
|
|
|
|
/**
|
|
* Rendered only when the project tracks its own progress (TODO.md/ROADMAP.md
|
|
* or a docs dir), so a bare repo gets no noise. The policy is guidance, not
|
|
* a gate: the agent stays in control, but it knows this project expects
|
|
* task tracking, docs-driven dev, tests, and verification.
|
|
*/
|
|
private workflowPolicy(): string {
|
|
if (this.opts.workflow?.enabled === false) return '';
|
|
const root = this.gitRoot();
|
|
if (!root) return '';
|
|
if (!this.workflowChecked) {
|
|
try {
|
|
this.workflowChecked = true;
|
|
const todoPath = join(root, 'TODO.md');
|
|
const roadmapPath = join(root, 'ROADMAP.md');
|
|
this.workflowHasTodo = existsSync(todoPath);
|
|
this.workflowHasRoadmap = existsSync(roadmapPath);
|
|
if (this.workflowHasTodo) {
|
|
try {
|
|
this.workflowTodoLines = readFileSync(todoPath, 'utf8').split('\n').length;
|
|
} catch {
|
|
this.workflowTodoLines = 0;
|
|
}
|
|
}
|
|
if (this.workflowHasRoadmap) {
|
|
try {
|
|
this.workflowRoadmapLines = readFileSync(roadmapPath, 'utf8').split('\n').length;
|
|
} catch {
|
|
this.workflowRoadmapLines = 0;
|
|
}
|
|
}
|
|
const docsDir = join(root, this.opts.workflow?.docsDir ?? 'docs');
|
|
try {
|
|
if (existsSync(docsDir) && statSync(docsDir).isDirectory()) {
|
|
this.workflowHasDocs = true;
|
|
let count = 0;
|
|
try {
|
|
const walkDir = (d: string): void => {
|
|
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
if (count > 200) return;
|
|
const p = join(d, e.name);
|
|
if (e.isDirectory()) walkDir(p);
|
|
else count += 1;
|
|
}
|
|
};
|
|
walkDir(docsDir);
|
|
} catch {}
|
|
this.workflowDocsFiles = count;
|
|
}
|
|
} catch {}
|
|
} catch {}
|
|
}
|
|
if (!this.workflowHasTodo && !this.workflowHasRoadmap && !this.workflowHasDocs) return '';
|
|
const lines = [
|
|
'This repo tracks its own progress. When you start real work here:',
|
|
'- read TODO.md (task list) before starting and keep it current as you go: mark what you did',
|
|
'- keep ROADMAP.md current when you ship a milestone',
|
|
'- for anything non-trivial, load the `plan` skill (spec-first) and write a short plan before code',
|
|
'- add tests alongside code; this project expects complete unit tests, not just happy paths',
|
|
'- verify with the project\'s check commands (tests/typecheck/build) before declaring done',
|
|
];
|
|
return lines.join('\n');
|
|
}
|
|
|
|
private workflowChecked = false;
|
|
private checkWorkflowState(_root: string): void {
|
|
// kept for backwards compat — logic now in workflowPolicy()
|
|
}
|
|
|
|
/**
|
|
* A gentle ladder of reminders after turns that wrote files without touching
|
|
* the task list. Cap at three — a nag that repeats without limit trains the
|
|
* model to ignore it, so the third is explicitly the last.
|
|
*/
|
|
private workflowNudge(): string | undefined {
|
|
if (this.opts.workflow?.enabled === false) return undefined;
|
|
if (this.workflowNudgeCount >= 3) return undefined;
|
|
const root = this.gitRoot();
|
|
if (!root) return undefined;
|
|
if (!this.workflowChecked) this.workflowPolicy();
|
|
if (!this.workflowHasTodo && !this.workflowHasRoadmap) return undefined;
|
|
if (this.todoWrittenThisTurn) return undefined;
|
|
// fileChangeSeq bump lives in onBeforeWrite (async), but lastWalkSeq is
|
|
// refreshed after the turn; compare at turn end: if no onBeforeWrite
|
|
// fired, this turn changed nothing — no nudge.
|
|
if (this.fileChangeSeq <= this.lastWalkSeq && this.fileChangeSeq === 0) return undefined;
|
|
// Only when onBeforeWrite actually fired (a write succeeded) and no todo_write
|
|
const hasWrites = this.turnBeforeFiles?.size > 0;
|
|
if (!hasWrites) return undefined;
|
|
this.workflowNudgeCount += 1;
|
|
const messages = [
|
|
'reminder: you modified files without updating the project task list (TODO.md). Keep it current: mark what you did.',
|
|
'still no update to TODO.md. The project expects its task list kept current as you work.',
|
|
'last reminder: update TODO.md when you have a moment. This is the final nudge for this session.',
|
|
] as const;
|
|
return messages[this.workflowNudgeCount - 1];
|
|
}
|
|
|
|
/**
|
|
* A deep, independent copy of the session's messages up to a turn boundary —
|
|
* the messages strictly before the most recent user prompt. The current
|
|
* session is untouched: /fork builds a fresh session elsewhere from the copy,
|
|
* so trying a different approach costs nothing and the original survives.
|
|
*/
|
|
fork(atIndex?: number): ModelMessage[] {
|
|
const idx = atIndex ?? this.turnBeforeLen;
|
|
const slice = this.messages.slice(0, Math.max(0, Math.min(idx, this.messages.length)));
|
|
return JSON.parse(JSON.stringify(slice)) as ModelMessage[];
|
|
}
|
|
|
|
/** Re-walks the workspace file list when files changed this turn, bounded at 5000. */
|
|
async refreshWorkspaceFiles(force = false): Promise<void> {
|
|
if (!force && this.fileChangeSeq <= this.lastWalkSeq) return;
|
|
this.lastWalkSeq = this.fileChangeSeq;
|
|
const found: string[] = [];
|
|
try {
|
|
for await (const rel of walk({ limit: 5000 })) found.push(rel);
|
|
} catch {
|
|
return; // an unreadable workspace keeps the last list; a walk must not break a turn
|
|
}
|
|
this.workspaceFiles = found;
|
|
this.bump('workspace');
|
|
}
|
|
|
|
/** The current ignore-aware workspace list, for tests and the /changes surface. */
|
|
workspaceList(): readonly string[] {
|
|
return this.workspaceFiles ?? [];
|
|
}
|
|
|
|
reset(): void {
|
|
this.messages.length = 0;
|
|
this.inputTokens = 0;
|
|
this.outputTokens = 0;
|
|
this.subagentInputTokens = 0;
|
|
this.subagentOutputTokens = 0;
|
|
this.warnedSpend = false;
|
|
this.notebook.clear();
|
|
this.snapshots.clear();
|
|
this.opts.onChange?.(this.messages);
|
|
}
|
|
|
|
/**
|
|
* Project workflow state for /workflow: what the repo tracks, whether the
|
|
* policy is rendered, and how much of the workflow the session exercised.
|
|
*/
|
|
workflowStatus(): {
|
|
enabled: boolean;
|
|
hasTodo: boolean;
|
|
todoLines: number;
|
|
hasRoadmap: boolean;
|
|
roadmapLines: number;
|
|
hasDocs: boolean;
|
|
docsFiles: number;
|
|
nudgeCount: number;
|
|
} {
|
|
const root = this.gitRoot();
|
|
if (!this.workflowChecked && root) this.workflowPolicy();
|
|
return {
|
|
enabled: this.opts.workflow?.enabled !== false,
|
|
hasTodo: this.workflowHasTodo,
|
|
todoLines: this.workflowTodoLines,
|
|
hasRoadmap: this.workflowHasRoadmap,
|
|
roadmapLines: this.workflowRoadmapLines,
|
|
hasDocs: this.workflowHasDocs,
|
|
docsFiles: this.workflowDocsFiles,
|
|
nudgeCount: this.workflowNudgeCount,
|
|
};
|
|
}
|
|
|
|
/** A subagent's finished run, folded into the session's spend and the /cost split. */
|
|
recordSubagentUsage(usage: { inputTokens: number; outputTokens: number }): void {
|
|
this.subagentInputTokens += usage.inputTokens;
|
|
this.subagentOutputTokens += usage.outputTokens;
|
|
}
|
|
|
|
replace(messages: ModelMessage[]): void {
|
|
this.messages.length = 0;
|
|
this.messages.push(...messages);
|
|
this.opts.onChange?.(this.messages);
|
|
}
|
|
|
|
abort(): void {
|
|
this.controller?.abort();
|
|
}
|
|
|
|
estimatedTokens(): number {
|
|
return estimateTokens(this.messages);
|
|
}
|
|
|
|
/** Where compaction kicks in, so the status bar can show how close it is. */
|
|
compactThreshold(): number {
|
|
return this.opts.compactThreshold ?? DEFAULT_COMPACT_THRESHOLD;
|
|
}
|
|
|
|
maxSpendPerTurn(): number | undefined {
|
|
return this.opts.maxSpendPerTurn;
|
|
}
|
|
|
|
canUndo(): boolean { return this.snapshots.canUndo(); }
|
|
canRedo(): boolean { return this.snapshots.canRedo(); }
|
|
|
|
/** Monotonically increments when a file is first touched in a turn — lets the `@` completer know its cache is stale. */
|
|
fileChangeSeq = 0;
|
|
|
|
async undo(): Promise<string> {
|
|
const snap = this.snapshots.popForUndo();
|
|
if (!snap) throw new Error('nothing to undo');
|
|
await this.restoreFiles(snap.beforeFiles);
|
|
// truncate messages to beforeLen; the tail is kept inside snap for redo
|
|
this.messages.length = snap.beforeLen;
|
|
this.opts.onChange?.(this.messages);
|
|
const n = snap.beforeFiles.size;
|
|
const filesNote = n === 0 ? 'no files to restore' : `${n} file(s) restored`;
|
|
const msgNote = snap.afterLen > snap.beforeLen ? `${snap.afterLen - snap.beforeLen} message(s) removed` : 'no messages to remove';
|
|
return `undone: ${filesNote}, ${msgNote} (bash effects, if any, were not snapshotted)`;
|
|
}
|
|
|
|
async redo(): Promise<string> {
|
|
const snap = this.snapshots.popForRedo();
|
|
if (!snap) throw new Error('nothing to redo');
|
|
await this.restoreFiles(snap.afterFiles);
|
|
// redo replays the tail that undo removed; stored in afterFiles? we also need messages tail.
|
|
// The messages tail is the slice that was removed on undo; reconstruct by re-inserting from stored span is not enough
|
|
// because snapshots hold beforeLen/afterLen but not the actual messages content.
|
|
// We store the removed tail inside the snapshot at push time as an extra field via (snap as any)._tail.
|
|
const tail = (snap as unknown as { _tail?: import('ai').ModelMessage[] })._tail;
|
|
if (tail && tail.length > 0) {
|
|
this.messages.push(...tail);
|
|
this.opts.onChange?.(this.messages);
|
|
}
|
|
const n = snap.afterFiles.size;
|
|
const filesNote = n === 0 ? 'no files to restore' : `${n} file(s) restored`;
|
|
return `redone: ${filesNote} (bash effects, if any, were not snapshotted)`;
|
|
}
|
|
|
|
private async restoreFiles(state: Map<string, FileState>): Promise<void> {
|
|
for (const [abs, st] of state) {
|
|
try {
|
|
if (!st.existed) {
|
|
if (await Bun.file(abs).exists()) await Bun.file(abs).delete();
|
|
} else {
|
|
await Bun.write(abs, st.content ?? '');
|
|
}
|
|
this.fileChangeSeq += 1;
|
|
} catch {
|
|
// best-effort per file; one failure should not stop the rest
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* What the most recent turn changed on disk, derived from the undo snapshot's
|
|
* before/after file states. Bash effects are not included, exactly as with
|
|
* /undo — a shell command's effects cannot be diffed from a snapshot.
|
|
*/
|
|
lastTurnSummary(): ChangeSummary | undefined {
|
|
const snap = this.snapshots.peek();
|
|
if (!snap) return undefined;
|
|
const added: string[] = [];
|
|
const modified: string[] = [];
|
|
const deleted: string[] = [];
|
|
for (const [abs, before] of snap.beforeFiles) {
|
|
const after = snap.afterFiles.get(abs);
|
|
if (!after) continue; // not captured after (write failed); skip
|
|
if (!before.existed && after.existed) added.push(abs);
|
|
else if (before.existed && !after.existed) deleted.push(abs);
|
|
else if (before.content !== after.content) modified.push(abs);
|
|
}
|
|
// Files created and listed in afterFiles but absent from beforeFiles are
|
|
// brand-new; the hook only records touched paths, so they always appear.
|
|
for (const [abs, after] of snap.afterFiles) {
|
|
if (!snap.beforeFiles.has(abs) && after.existed) added.push(abs);
|
|
}
|
|
if (added.length === 0 && modified.length === 0 && deleted.length === 0) return undefined;
|
|
return { added, modified, deleted };
|
|
}
|
|
|
|
/**
|
|
* The session's spend so far and the configured ceiling, for the UI's status
|
|
* and the refuse-the-next-turn check. Unpriced models report no spend: a
|
|
* ceiling cannot be enforced against a model we cannot price.
|
|
*/
|
|
spend(): { usd?: number; ceiling?: number; overWarn: boolean; overLimit: boolean } {
|
|
const ceiling = this.opts.maxSpendUsd;
|
|
const parent = costOf(this.opts.modelId ?? '', this.inputTokens, this.outputTokens);
|
|
const sub =
|
|
this.subagentInputTokens + this.subagentOutputTokens > 0
|
|
? costOf(this.opts.subagentModelId ?? this.opts.modelId ?? '', this.subagentInputTokens, this.subagentOutputTokens)
|
|
: 0;
|
|
// Spend is only knowable when every part is priced; an unpriced piece means
|
|
// the total is a lower bound, so the ceiling is not enforced against it.
|
|
const usd = parent === undefined || sub === undefined ? undefined : parent + sub;
|
|
if (ceiling === undefined || usd === undefined) {
|
|
return { ...(usd !== undefined ? { usd } : {}), ...(ceiling !== undefined ? { ceiling } : {}), overWarn: false, overLimit: false };
|
|
}
|
|
return { usd, ceiling, overWarn: usd >= ceiling * 0.8, overLimit: usd >= ceiling };
|
|
}
|
|
|
|
private mcpServerNamesForPrompt(): string[] | undefined {
|
|
const hasMeta = this.tools['mcp_list'] !== undefined;
|
|
if (!hasMeta) return undefined;
|
|
const marker = (this.tools as Record<string, unknown>)['__mcpServerNames'];
|
|
if (Array.isArray(marker) && marker.length > 0) return [...marker].sort() as string[];
|
|
// fallback: derive from direct if marker missing (tests that inject tools manually)
|
|
const direct = Object.keys(this.tools).filter((n) => n.startsWith('mcp__'));
|
|
if (direct.length === 0) return undefined;
|
|
const names = new Set<string>();
|
|
for (const n of direct) {
|
|
const m = /^mcp__([^_]+(?:_[^_]+)*)__/.exec(n);
|
|
if (m) names.add(m[1]!);
|
|
}
|
|
return names.size > 0 ? [...names].sort() : undefined;
|
|
}
|
|
|
|
private systemFor(): SdkInstructions {
|
|
const versionKey = [
|
|
`nb:${this.versions.notebook}`,
|
|
`mem:${this.versions.memory}`,
|
|
`sk:${this.versions.skills}`,
|
|
`pl:${this.versions.plugins}`,
|
|
`tl:${this.versions.tools}`,
|
|
`ws:${this.versions.workspace}`,
|
|
`wf:${this.versions.workflow ?? 0}`,
|
|
].join('|');
|
|
if (this.promptCache && this.promptCache.key === versionKey) {
|
|
this.cacheStats.hits += 1;
|
|
return this.promptCache.text;
|
|
}
|
|
this.cacheStats.misses += 1;
|
|
|
|
const mem = this.opts.memory;
|
|
const memoryBlock = mem ? mem.render() : '';
|
|
const workflowPolicy = this.workflowPolicy();
|
|
const text = systemPrompt({
|
|
cwd: this.opts.cwd ?? process.cwd(),
|
|
instructions: this.opts.instructions ?? [],
|
|
notebook: this.notebook.render(),
|
|
memory: memoryBlock,
|
|
skills: renderSkills(this.currentSkills),
|
|
agent: renderAgent(this.variant),
|
|
plugins: this.pluginHost?.appendix ?? '',
|
|
availableTools: this.activeTools(),
|
|
canAsk: this.opts.ask !== undefined && this.activeTools().includes('ask'),
|
|
...(this.mcpServerNamesForPrompt() ? { mcpServers: this.mcpServerNamesForPrompt() } : {}),
|
|
...(this.workspaceFiles && this.workspaceFiles.length > 0 ? { workspaceFiles: this.workspaceFiles } : {}),
|
|
...(workflowPolicy ? { workflowPolicy } : {}),
|
|
});
|
|
this.promptCache = { key: versionKey, text };
|
|
return this.withCacheBreak(text);
|
|
}
|
|
|
|
/**
|
|
* Provider-side prompt caching, when the provider rewards a stable prefix.
|
|
*
|
|
* The system prompt is split into a stable head (instructions, tools, policy,
|
|
* skills) and a volatile tail (notebook, memory — the parts a todo_write or a
|
|
* remember call changes mid-session). Anthropic supports `cache_control` on a
|
|
* system block, so the stable head is marked ephemeral and the volatile tail
|
|
* rides along without invalidating it. OpenAI's automatic prefix caching
|
|
* needs nothing here; this is a no-op unless cacheSystemPrefix is set, which
|
|
* cli.tsx only does for the anthropic provider.
|
|
*/
|
|
private withCacheBreak(text: string): SdkInstructions {
|
|
if (!this.opts.cacheSystemPrefix) return text;
|
|
// The split point is the stable head. The volatile suffix starts at the
|
|
// notebook block (`Your task list`) — everything before it is instructions
|
|
// and policy built from versions that rarely change.
|
|
const marker = '\nYour task list';
|
|
const idx = text.indexOf(marker);
|
|
if (idx <= 0) return text;
|
|
const stable = text.slice(0, idx);
|
|
const volatile = text.slice(idx);
|
|
return [
|
|
{
|
|
role: 'system',
|
|
content: stable,
|
|
providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } },
|
|
},
|
|
{ role: 'system', content: volatile },
|
|
];
|
|
}
|
|
|
|
/**
|
|
* How many times this exact call has already been made this turn.
|
|
*
|
|
* A model that repeats an identical call is not making progress: either it is
|
|
* ignoring the result or the result is not what it needed. Three is the point
|
|
* where that stops looking like a coincidence.
|
|
*/
|
|
private repeatCount(toolName: string, input: unknown): number {
|
|
const key = callKey(toolName, input);
|
|
const count = (this.seen.get(key) ?? 0) + 1;
|
|
this.seen.set(key, count);
|
|
return count;
|
|
}
|
|
|
|
/**
|
|
* Approval decisions, evaluated per call by the SDK.
|
|
*
|
|
* Order matters, and each step exists for a different reason:
|
|
*
|
|
* 1. A plugin guard refuses outright. `--yolo` cannot reach it, because a
|
|
* refusal is a policy decision rather than a permission question.
|
|
* 2. Permission rules decide allow / ask / deny, matched against the call's
|
|
* subject — the command, the path — not just the tool name.
|
|
* 3. An allowed call that has now repeated three times identically is asked
|
|
* about anyway. A rule saying `bash: allow` is a statement about which
|
|
* commands are safe, not permission to run one in a loop forever.
|
|
*
|
|
* `why` collects what the UI needs to explain the prompt, keyed by call, because
|
|
* the SDK's own approval request carries only the tool name and input.
|
|
*/
|
|
private toolApproval(notices: string[], why: Map<string, ApprovalContext>) {
|
|
return async ({ toolCall }: { toolCall: { toolName: string; input: unknown } }) => {
|
|
const { toolName, input } = toolCall;
|
|
|
|
const blocked = await (this.pluginHost ?? this.opts.plugins)?.guard({
|
|
toolName,
|
|
input,
|
|
cwd: this.opts.cwd ?? process.cwd(),
|
|
});
|
|
if (blocked) {
|
|
notices.push(blocked);
|
|
return { type: 'denied' as const, reason: blocked };
|
|
}
|
|
|
|
const { decision, pattern } = this.permissions.check(toolName, input);
|
|
if (decision === 'deny') {
|
|
const reason = pattern
|
|
? `Refused by the permission rule ${toolName}: "${pattern}" = deny.`
|
|
: `Refused by the permission rules: ${toolName} is denied.`;
|
|
notices.push(reason);
|
|
return { type: 'denied' as const, reason };
|
|
}
|
|
|
|
const repeats = this.repeatCount(toolName, input);
|
|
if (decision === 'allow' && repeats < REPEAT_LIMIT) return undefined;
|
|
|
|
why.set(callKey(toolName, input), {
|
|
...(pattern ? { matchedPattern: pattern } : {}),
|
|
suggestedPattern: this.permissions.suggest(toolName, input),
|
|
...(decision === 'allow' ? { repeated: true } : {}),
|
|
});
|
|
return 'user-approval' as const;
|
|
};
|
|
}
|
|
|
|
/** Replaces the history with a model-written summary. Backs the /compact command. */
|
|
async summarize(): Promise<{ before: number; after: number }> {
|
|
const before = this.messages.length;
|
|
if (before === 0) return { before, after: 0 };
|
|
|
|
const { text } = await generateText({
|
|
model: this.model,
|
|
system:
|
|
'Summarize this coding session for use as the sole context of a fresh session. ' +
|
|
'Keep: the user goal, files touched with paths, decisions made, commands run and their outcome, ' +
|
|
'and what remains to be done. Drop pleasantries and full file contents. Write it as notes, not prose.',
|
|
messages: this.messages,
|
|
maxRetries: this.opts.maxRetries ?? 3,
|
|
});
|
|
|
|
this.messages.length = 0;
|
|
this.messages.push({ role: 'user', content: `Summary of the session so far:\n\n${text}` });
|
|
this.opts.onChange?.(this.messages);
|
|
return { before, after: this.messages.length };
|
|
}
|
|
|
|
async *send(userText: string): AsyncGenerator<AgentEvent> {
|
|
// The ceiling is checked before the model is: a turn started past the limit
|
|
// would spend money the caller said not to. An unpriced model cannot be
|
|
// measured, so it is never refused here — the ceiling simply cannot see it.
|
|
const spend = this.spend();
|
|
if (spend.overLimit) {
|
|
yield {
|
|
type: 'error',
|
|
error: new Error(
|
|
`spend ceiling reached: ${formatUsd(spend.usd ?? 0)} of ${formatUsd(spend.ceiling ?? 0)} used. Raise maxSpendUsd or start a new session.`,
|
|
),
|
|
};
|
|
yield { type: 'done' };
|
|
return;
|
|
}
|
|
|
|
// snapshot boundary: remember messages length before this turn and arm file capture
|
|
this.turnBeforeLen = this.messages.length;
|
|
this.turnBeforeFiles = new Map<string, FileState>();
|
|
this.turnStartUsd = this.spend().usd;
|
|
this.turnCappedNotice = undefined;
|
|
this.todoWrittenThisTurn = false;
|
|
onBeforeWrite(async (abs: string) => {
|
|
if (this.turnBeforeFiles.has(abs)) return;
|
|
const exists = await Bun.file(abs).exists();
|
|
let content: string | null = null;
|
|
if (exists) {
|
|
try { content = await Bun.file(abs).text(); } catch { content = null; }
|
|
}
|
|
this.turnBeforeFiles.set(abs, { existed: exists, content });
|
|
this.fileChangeSeq += 1;
|
|
});
|
|
this.messages.push({ role: 'user', content: userText });
|
|
this.opts.onChange?.(this.messages);
|
|
this.controller = new AbortController();
|
|
const signal = this.controller.signal;
|
|
const threshold = this.compactThreshold();
|
|
// Per turn, not per step: a tool called once in each of three steps is the
|
|
// loop this guards against.
|
|
this.seen.clear();
|
|
this.staleItemsRepaired = false;
|
|
|
|
const outputs: Extract<AgentEvent, { type: 'tool-output' }>[] = [];
|
|
onBashOutput(({ toolCallId, chunk }) => {
|
|
outputs.push({ type: 'tool-output', id: toolCallId, chunk });
|
|
this.opts.onToolOutput?.(toolCallId, chunk);
|
|
});
|
|
|
|
let turnFailed = false;
|
|
try {
|
|
yield* this.run(signal, threshold, outputs);
|
|
} catch (e) {
|
|
turnFailed = true;
|
|
throw e;
|
|
} finally {
|
|
onBeforeWrite(undefined);
|
|
// finalize snapshot only for turns that actually ran (even if they errored after writing files,
|
|
// the file state is still worth snapshotting so undo can revert a half-failed turn)
|
|
try {
|
|
if (this.turnBeforeFiles.size > 0 || this.messages.length > this.turnBeforeLen) {
|
|
const afterFiles = new Map<string, FileState>();
|
|
for (const abs of this.turnBeforeFiles.keys()) {
|
|
const exists = await Bun.file(abs).exists();
|
|
let content: string | null = null;
|
|
if (exists) { try { content = await Bun.file(abs).text(); } catch { content = null; } }
|
|
afterFiles.set(abs, { existed: exists, content });
|
|
}
|
|
// tail for redo: the messages added by this turn
|
|
const tail = this.messages.slice(this.turnBeforeLen).map((m) => ({ ...m, content: typeof m.content === 'string' ? m.content : JSON.parse(JSON.stringify(m.content)) } as import('ai').ModelMessage));
|
|
const snap: import('./snapshot').TurnSnapshot & { _tail?: import('ai').ModelMessage[] } = {
|
|
beforeLen: this.turnBeforeLen,
|
|
afterLen: this.messages.length,
|
|
beforeFiles: new Map(this.turnBeforeFiles),
|
|
afterFiles,
|
|
};
|
|
(snap as unknown as { _tail?: import('ai').ModelMessage[] })._tail = tail;
|
|
// even failed turns push so undo can revert the file side; empty no-op turns are skipped above
|
|
if (!turnFailed || this.turnBeforeFiles.size > 0) this.snapshots.push(snap);
|
|
}
|
|
} catch {}
|
|
this.turnBeforeFiles = new Map<string, FileState>();
|
|
this.controller = undefined;
|
|
this.drainPendingHotReload();
|
|
// Files written this turn are now on disk; re-walk so the next prompt's
|
|
// workspace list shows them without a restart.
|
|
try { await this.refreshWorkspaceFiles(); } catch {}
|
|
// Keep the listener alive while a background process is running so the UI
|
|
// panel shows its output between turns. statusBackground always has the
|
|
// data either way (bg.tail); this just keeps the live panel up.
|
|
const { backgroundHandles } = await import('./tools');
|
|
if (backgroundHandles().length === 0) onBashOutput(undefined);
|
|
await (this.pluginHost ?? this.opts.plugins)?.afterTurn();
|
|
if (!this.opts.disableAutoLearn && this.messages.length >= 6) {
|
|
this.learnTurns += 1;
|
|
const delta = this.messages.length - this.lastLearnLen;
|
|
const throttled = this.learnTurns % 3 !== 0 && delta < 8;
|
|
if (!throttled) {
|
|
this.lastLearnLen = this.messages.length;
|
|
try {
|
|
await maybeLearn(this.messages, this.model, this.opts.learnerModel, this.opts.memory, () => this.spend(), this.opts.onNotice);
|
|
} catch {}
|
|
// periodic TTL prune so long session doesn't bloat
|
|
if (this.learnTurns % 6 === 0 && this.opts.memory) { try { await this.opts.memory.pruneExpired(); } catch {} }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rewrites the history so nothing points at provider-side storage, once per turn.
|
|
*
|
|
* The 404 repeats for every reference in the request, and a repair that could run
|
|
* twice would retry a request that cannot be made to work.
|
|
*/
|
|
private repairStaleItems(): boolean {
|
|
if (this.staleItemsRepaired) return false;
|
|
this.staleItemsRepaired = true;
|
|
this.replace(detachProviderItems(this.messages));
|
|
return true;
|
|
}
|
|
|
|
/** True once a step has pushed this turn's spend past its per-turn cap. */
|
|
private turnOverCap(): boolean {
|
|
const cap = this.opts.maxSpendPerTurn;
|
|
if (cap === undefined || cap <= 0) return false;
|
|
const usd = this.spend().usd;
|
|
const delta = usd !== undefined && this.turnStartUsd !== undefined ? usd - this.turnStartUsd : undefined;
|
|
return delta !== undefined && delta > cap;
|
|
}
|
|
|
|
private async *run(
|
|
signal: AbortSignal,
|
|
threshold: number,
|
|
outputs: Extract<AgentEvent, { type: 'tool-output' }>[],
|
|
): AsyncGenerator<AgentEvent> {
|
|
// Each iteration is one model run. A run ends either finished, or suspended
|
|
// on tool approvals, in which case we collect decisions and run again.
|
|
let compactionReported = false;
|
|
let compactionSpan: ModelMessage[] | undefined;
|
|
while (true) {
|
|
const pending: ApprovalRequest[] = [];
|
|
const compactions: Extract<AgentEvent, { type: 'compacted' }>[] = [];
|
|
const guardNotices: string[] = [];
|
|
const why = new Map<string, ApprovalContext>();
|
|
let sawError = false;
|
|
let delivered = false;
|
|
let staleRetry = false;
|
|
|
|
const result = streamText({
|
|
model: this.model,
|
|
system: this.systemFor(),
|
|
messages: this.messages,
|
|
tools: this.tools,
|
|
activeTools: this.activeTools(),
|
|
reasoning: sdkReasoning(this.variant.thinking),
|
|
toolApproval: this.toolApproval(guardNotices, why),
|
|
stopWhen: isStepCount(this.variant.maxSteps ?? this.opts.maxSteps ?? 50),
|
|
maxRetries: this.opts.maxRetries ?? 3,
|
|
abortSignal: signal,
|
|
prepareStep: ({ messages }) => {
|
|
// Rebuilt every step: a todo_write earlier in this same run must be
|
|
// visible to the steps that follow it, not only to the next turn.
|
|
const instructions = this.systemFor();
|
|
if (estimateTokens(messages) <= threshold) return { instructions };
|
|
const pruned = pruneToFit({ messages, threshold, estimate: estimateTokens });
|
|
// Capture what was dropped by reference identity — the lossless note is built after the stream.
|
|
if (!compactionReported && pruned.length < messages.length) {
|
|
compactionSpan = droppedSpan(messages, pruned);
|
|
}
|
|
// prepareStep cannot yield, so queue the notice and drain it in the loop.
|
|
if (!compactionReported) {
|
|
compactions.push({ type: 'compacted', before: messages.length, after: pruned.length });
|
|
compactionReported = true;
|
|
}
|
|
return { instructions, messages: pruned };
|
|
},
|
|
});
|
|
|
|
// Every promise-shaped accessor settles independently of the stream. Any one
|
|
// left without a rejection sink surfaces as an unhandled rejection on abort
|
|
// or API failure, which scribbles over the Ink render.
|
|
const sink = () => {};
|
|
void result.responseMessages.then(undefined, sink);
|
|
void result.usage.then(undefined, sink);
|
|
void result.steps.then(undefined, sink);
|
|
void result.finalStep.then(undefined, sink);
|
|
void result.text.then(undefined, sink);
|
|
void result.finishReason.then(undefined, sink);
|
|
|
|
try {
|
|
for await (const part of result.stream) {
|
|
while (compactions.length > 0) yield compactions.shift()!;
|
|
while (outputs.length > 0) yield outputs.shift()!;
|
|
while (guardNotices.length > 0) yield { type: 'notice', text: guardNotices.shift()! };
|
|
switch (part.type) {
|
|
case 'text-delta':
|
|
delivered = true;
|
|
yield { type: 'text', text: part.text };
|
|
break;
|
|
case 'reasoning-delta':
|
|
delivered = true;
|
|
yield { type: 'reasoning', text: part.text };
|
|
break;
|
|
case 'tool-input-start':
|
|
// Arrives before the arguments finish streaming, so the UI can name
|
|
// the tool while the model is still writing its input.
|
|
delivered = true;
|
|
yield { type: 'tool-start', id: part.id, name: part.toolName };
|
|
break;
|
|
case 'tool-call':
|
|
if (part.toolName === 'todo_write') this.todoWrittenThisTurn = true;
|
|
yield { type: 'tool-call', id: part.toolCallId, name: part.toolName, input: part.input };
|
|
break;
|
|
case 'tool-result':
|
|
yield { type: 'tool-result', id: part.toolCallId, name: part.toolName, output: part.output };
|
|
break;
|
|
case 'tool-error':
|
|
yield { type: 'tool-error', id: part.toolCallId, name: part.toolName, error: part.error };
|
|
break;
|
|
case 'tool-approval-request': {
|
|
// A guard denial is answered by the SDK itself and arrives flagged
|
|
// automatic; queueing it would prompt the user for a settled call.
|
|
if (part.isAutomatic) break;
|
|
const context = why.get(callKey(part.toolCall.toolName, part.toolCall.input));
|
|
pending.push({
|
|
approvalId: part.approvalId,
|
|
toolName: part.toolCall.toolName,
|
|
input: part.toolCall.input,
|
|
suggestedPattern: '*',
|
|
...context,
|
|
});
|
|
break;
|
|
}
|
|
case 'tool-approval-response':
|
|
if (!part.approved) yield { type: 'tool-denied', name: part.toolCall.toolName };
|
|
break;
|
|
case 'tool-output-denied':
|
|
yield { type: 'tool-denied', name: part.toolName };
|
|
break;
|
|
case 'abort':
|
|
yield { type: 'done' };
|
|
return;
|
|
case 'error':
|
|
// A stale item is rejected before generation starts, so nothing has
|
|
// been said yet and the request can be rebuilt. Once output is on
|
|
// screen it cannot be unsent, and a retry would repeat it.
|
|
if (!delivered && isStaleItemError(part.error) && this.repairStaleItems()) {
|
|
yield { type: 'notice', text: STALE_ITEM_NOTICE };
|
|
staleRetry = true;
|
|
break;
|
|
}
|
|
sawError = true;
|
|
yield { type: 'error', error: part.error };
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (signal.aborted) {
|
|
yield { type: 'done' };
|
|
return;
|
|
}
|
|
if (!delivered && isStaleItemError(error) && this.repairStaleItems()) {
|
|
yield { type: 'notice', text: STALE_ITEM_NOTICE };
|
|
continue;
|
|
}
|
|
yield { type: 'error', error };
|
|
return;
|
|
}
|
|
|
|
// The history was rewritten under this run, so its promise-shaped results
|
|
// describe a request that no longer stands. Run again rather than read them.
|
|
if (staleRetry) continue;
|
|
|
|
// A stream that ended in an error has no response messages or usage to
|
|
// await; touching them would throw NoOutputGeneratedError.
|
|
if (sawError) return;
|
|
|
|
// Project workflow: a turn that changed files without touching the task
|
|
// list gets one soft reminder. Keep it below the fold — a nag that
|
|
// repeats would train the model to ignore it.
|
|
const nudge = this.workflowNudge();
|
|
if (nudge) yield { type: 'notice', text: nudge };
|
|
|
|
while (compactions.length > 0) yield compactions.shift()!;
|
|
while (outputs.length > 0) yield outputs.shift()!;
|
|
while (guardNotices.length > 0) yield { type: 'notice', text: guardNotices.shift()! };
|
|
|
|
this.messages.push(...(await result.responseMessages));
|
|
this.opts.onChange?.(this.messages);
|
|
|
|
// Lossless compaction: summarize what the wire pruned so future turns keep it.
|
|
if (compactionSpan && compactionSpan.length > 0) {
|
|
const retained = await summarizeDiscarded(compactionSpan, this.model);
|
|
if (retained) {
|
|
this.messages.push({ role: 'user', content: `Note (retained from compacted history):\n${retained}` });
|
|
this.opts.onChange?.(this.messages);
|
|
}
|
|
compactionSpan = undefined;
|
|
}
|
|
|
|
if (pending.length === 0) {
|
|
const usage = await result.usage;
|
|
this.inputTokens += usage.inputTokens ?? 0;
|
|
this.outputTokens += usage.outputTokens ?? 0;
|
|
// Per-turn cap: a `deep` turn that ran away is refusable at a step
|
|
// boundary even when the session ceiling is far away.
|
|
if (this.turnOverCap()) {
|
|
if (!this.turnCappedNotice) {
|
|
const cap = this.opts.maxSpendPerTurn;
|
|
this.turnCappedNotice = `per-turn spend cap reached: this turn used more than ${formatUsd(cap ?? 0)}. Turn stopped.`;
|
|
yield { type: 'notice', text: this.turnCappedNotice };
|
|
}
|
|
yield { type: 'done', inputTokens: usage.inputTokens, outputTokens: usage.outputTokens };
|
|
return;
|
|
}
|
|
// Warn as the ceiling comes into view, once, so a long session is not
|
|
// surprised by a refusal it never saw coming.
|
|
const spend = this.spend();
|
|
if (spend.overWarn && !this.warnedSpend) {
|
|
this.warnedSpend = true;
|
|
yield {
|
|
type: 'notice',
|
|
text: `approaching spend ceiling: ${formatUsd(spend.usd ?? 0)} of ${formatUsd(spend.ceiling ?? 0)} used`,
|
|
};
|
|
}
|
|
yield { type: 'done', inputTokens: usage.inputTokens, outputTokens: usage.outputTokens };
|
|
return;
|
|
}
|
|
|
|
const responses: ToolApprovalResponse[] = [];
|
|
for (const req of pending) {
|
|
const decision = await this.opts.askApproval(req);
|
|
// `always` records the pattern the tool suggested, so approving
|
|
// `git status` whitelists `git *` rather than every command.
|
|
if (decision === 'always') this.permissions.grant(req.toolName, req.suggestedPattern);
|
|
responses.push({
|
|
type: 'tool-approval-response',
|
|
approvalId: req.approvalId,
|
|
approved: decision !== 'deny',
|
|
...(decision === 'deny' ? { reason: 'User denied this tool call.' } : {}),
|
|
});
|
|
}
|
|
this.messages.push({ role: 'tool', content: responses });
|
|
}
|
|
}
|
|
}
|