Files
shiro-neko/src/session.ts
T
Muhammad Zakir Ramadhan f8c3cc2d8e Ship the batch: undo, parallel subagents, lazy MCP, hot-reloaded skills; docs and CI/CD
Loop and ergonomics batch across Now/Next and Maintenance:
- /undo and /redo via pre-prompt file snapshots (snapshot.ts)
- task takes a tasks[] array and runs investigations concurrently (subagent.ts)
- lazy MCP tools: mcp_list/mcp_inspect/mcp_call meta-tools, eager opt-in (mcp.ts, config.ts)
- skill tool reads its list live so a mid-session install is callable next turn (skills.ts)
- tool-name lists (tool-kinds.ts) derived from a mutating() marker; gates previously ungated writes
- prune/session recovery path summarized, and step-back doom-loop primitive (step-back.ts)
- @file completion re-walks on a slow cooldown; estimateTokens and pricing labeled as estimates

Docs: README, CHANGELOG, docs/{mcp,architecture,development} updated to match.
CI/CD: bun install-store cache and concurrency gates on both workflows; release.yml now
composes file-based release notes via scripts/make-release-notes.ts and verifies every binary.
2026-09-17 17:49:19 +07:00

885 lines
37 KiB
TypeScript

import {
isStepCount,
generateText,
streamText,
APICallError,
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, digestOf, droppedBy, isPrunedSpanSummary, prunedSpanMessage, pruneToFit } from './prune';
import { restore, Snapshots, type TurnSnapshot } from './snapshot';
import { createStepBackTool, type LoopEntry } from './step-back';
import { createSkillTool, renderSkills, type Skill } from './skills';
import { disabledToolNames, onBashOutput, tools as builtinTools, type ToolSetName } from './tools';
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;
};
/** 'once' runs this call only; 'always' whitelists the suggested pattern for the session. */
export type ApprovalDecision = 'once' | 'always' | 'deny';
/** What an undo did, so the UI can say which files moved and which did not. */
export type UndoResult = {
snapshot: TurnSnapshot;
restored: string[];
removed: string[];
conversationTrimmed: boolean;
};
export type RedoResult = {
snapshot: TurnSnapshot;
/** False when files were asked for: only pre-images are ever captured. */
filesRestored: boolean;
what: 'both' | 'files' | 'conversation';
};
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;
/** 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;
/** Identical calls to an allowed tool before it is asked about anyway. Default 3. */
repeatLimit?: 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;
};
/**
* Length-based token estimate for deciding *when to prune*, not for billing.
* JSON char count / 4 approximates token count closely enough to gate compaction,
* but real billed tokens come from the SDK's reported usage (`inputTokens`), never
* from here. `/cost` and the budget ceiling use the SDK figure.
*/
const estimateTokens = (messages: ModelMessage[]) => Math.round(JSON.stringify(messages).length / 4);
/** 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;
/**
* Squashes a tool result into a few characters for the loop trace.
*
* The trace is fed back to the model verbatim, so a 30 KB read_file output would
* fill the reflection with noise. A short string keeps `step_back` honest about
* what happened without flooding the next context window.
*/
function summarizeToolResult(output: unknown): string {
if (typeof output === 'string') return output.length <= 80 ? output : `${output.slice(0, 80)}…(${output.length} chars)`;
try {
const json = JSON.stringify(output);
return json.length <= 80 ? json : `${json.slice(0, 80)}…`;
} catch {
return String(output);
}
}
/** Ceiling on an injected span summary, so the summary cannot defeat the compaction. */
const MAX_SPAN_SUMMARY_CHARS = 1_200;
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.';
type ApprovalContext = Pick<ApprovalRequest, 'matchedPattern' | 'suggestedPattern' | 'repeated'>;
export class Session {
readonly messages: ModelMessage[];
readonly tools: ToolSet;
readonly notebook: Notebook;
/** Pre-images of files this session's turns have changed, newest last. */
readonly snapshots: Snapshots;
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 readonly permissions: Permissions;
/** 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;
/** Tools this turn used that no snapshot can cover, reported when the turn ends. */
private readonly uncoveredTools = new Set<string>();
/** The snapshot the last undo removed, so `/redo` can put it back. */
private lastUndone: TurnSnapshot | undefined;
/** Every tool call this turn, input + outcome, for the loop-detection tool to reflect on. */
private readonly loopTrace: LoopEntry[] = [];
/** toolCallId -> { toolName, input }, so a result can be paired with its call. */
private readonly callInputs = new Map<string, { toolName: string; input: string }>();
constructor(private readonly opts: SessionOptions) {
this.messages = opts.messages ?? [];
this.notebook = new Notebook(opts.onNotebookChange);
this.notebook.restore(opts.notebook);
this.model = opts.model;
this.variant = opts.agent ?? DEFAULT_VARIANT;
this.snapshots = new Snapshots(opts.cwd ?? process.cwd());
const sessionTools = {
...this.notebook.tools(),
...(opts.memory ? opts.memory.tools() : {}),
// Always registered, even with no skills, so a mid-session install of the
// first skill is callable next turn without a session rebuild. The tool's
// description reads the live list and says "none" when it is empty.
skill: createSkillTool(() => this.opts.skills ?? []),
...(opts.ask ? { ask: createAskTool(opts.ask) } : {}),
step_back: createStepBackTool({
trace: () => this.loopTrace,
}),
};
this.tools = { ...builtinTools, ...sessionTools, ...(opts.plugins?.tools ?? {}), ...(opts.extraTools ?? {}) };
this.permissions = new Permissions({
...(opts.permissions ? { config: opts.permissions } : {}),
...(opts.yolo ? { yolo: true } : {}),
autoApprove: [
...(opts.autoApprove ?? []),
...(opts.plugins?.autoApprove ?? []),
// A session tool touches the agent's own state, not the workspace.
...Object.keys(sessionTools),
],
});
}
/**
* 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.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;
}
setAgent(variant: AgentVariant): void {
this.variant = variant;
}
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));
const all = Object.keys(this.tools).filter((name) => !withheld.has(name));
if (!this.variant.allowTools) return all;
return all.filter((name) => this.variant.allowTools!.includes(name));
}
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.opts.onChange?.(this.messages);
}
/** 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;
}
/**
* 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 systemFor(): string {
return systemPrompt({
cwd: this.opts.cwd ?? process.cwd(),
instructions: this.opts.instructions ?? [],
notebook: this.notebook.render(),
memory: this.opts.memory?.render() ?? '',
skills: renderSkills(this.opts.skills ?? []),
agent: renderAgent(this.variant),
plugins: this.opts.plugins?.appendix ?? '',
availableTools: this.activeTools(),
canAsk: this.opts.ask !== undefined && this.activeTools().includes('ask'),
});
}
/**
* 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;
}
/**
* Appends a completed tool call to the loop trace, pairing it with its input.
*
* The SDK streams `tool-result` without the input that produced it, so the input is
* kept alongside on the `tool-call` part. A `step_back` call needs this pairing to
* say *which* call produced *which* outcome.
*/
private recordTrace(toolCallId: string, result: string): void {
const call = this.callInputs.get(toolCallId);
this.callInputs.delete(toolCallId);
if (!call) return;
this.loopTrace.push({
step: this.loopTrace.length + 1,
toolName: call.toolName,
input: call.input,
result,
at: new Date().toISOString(),
});
}
/**
* 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;
// Before anything else, because the guard may deny the call and because a
// later hook must not be able to move the capture after the write.
const { covered } = await this.snapshots.captureFor(toolName, input);
if (!covered) this.uncoveredTools.add(toolName);
const blocked = await 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);
const limit = this.opts.repeatLimit ?? REPEAT_LIMIT;
if (decision === 'allow' && repeats < limit) return undefined;
if (decision === 'allow') {
// Repeated three times with a permission that says `allow`: the model is
// looping, not asking, and it should stop and look at the trace rather than
// burn another approval. This is the point the step_back tool exists for.
notices.push(
`You have called ${toolName} with the same input ${repeats + 1} times this turn. It is not making progress. ` +
`Use step_back to reflect on what changed between attempts, then try a different approach or stop.`,
);
}
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 };
}
/**
* Walks the last turn back: files, conversation, or both.
*
* The three-way split is the point. Restoring files without the conversation leaves
* the model believing edits are on disk that are not, so its next turn is built on a
* state that no longer exists — it re-reads a file expecting its own change and finds
* the original, which reads to the model as the change having been rejected. Restoring
* the conversation without the files is the mirror: the model forgets it made an edit
* that is still there. So the default is both, and the caller can narrow it.
*
* Returns undefined when there is nothing to undo, which the UI reports as such
* rather than as a failure.
*/
async undo(what: 'both' | 'files' | 'conversation' = 'both'): Promise<UndoResult | undefined> {
const snap = this.snapshots.pop();
if (!snap) return undefined;
const files = what === 'conversation' ? { restored: [], removed: [] } : await restore(snap, this.snapshots.cwdOf());
if (what !== 'files') this.trimTo(snap.messageCount);
this.lastUndone = snap;
return { snapshot: snap, ...files, conversationTrimmed: what !== 'files' };
}
/**
* Puts back what `undo` took, without a second snapshot.
*
* A redo cannot restore file content from the session, because the content that
* existed after the turn was never captured — only the pre-image was. So a redo of
* the files is declined honestly rather than approximated: the whole point of undo
* is that the user trusts what it says it did. The conversation is restored from the
* snapshot's own record, which is exact.
*/
redo(what: 'both' | 'files' | 'conversation' = 'conversation'): RedoResult | undefined {
const snap = this.lastUndone;
if (!snap) return undefined;
this.snapshots.push(snap);
this.lastUndone = undefined;
return { snapshot: snap, filesRestored: false, what };
}
undoable(): readonly TurnSnapshot[] {
return this.snapshots.list();
}
private trimTo(length: number): void {
if (this.messages.length <= length) return;
this.messages.length = Math.max(0, length);
this.opts.onChange?.(this.messages);
}
/**
* Closes the open snapshot and reports what the turn could not cover.
*
* Called before every `done`, not from `send`'s `finally`, because a notice that
* arrives after `done` is a notice the UI has already stopped listening for —
* `done` is what a consumer treats as the end of the turn and stops on.
*
* Two reasons to speak, and the second does not depend on the first: a turn with no
* snapshotted edits can still have run `bash` and changed the tree, which is exactly
* when the warning matters most.
*/
private *closingNotices(): Generator<AgentEvent> {
const snapshot = this.snapshots.commit();
const uncovered = [...this.uncoveredTools];
if (uncovered.length === 0) return;
const covered = snapshot ? `${snapshot.files.length} file(s) changed this turn and can be undone with /undo. ` : '';
yield {
type: 'notice',
text: `${covered}${uncovered.join(', ')} ran this turn and cannot be snapshotted, so any changes it made will not be undone.`,
};
}
/**
* Swaps the live skill list at a turn boundary.
*
* The `skill` tool and the system-prompt catalogue both read the list on each
* call, so replacing it here is atomic across the two and takes effect on the
* next turn with no session rebuild. The caller is responsible for only doing
* this between turns — a turn in flight already holds its rules.
*/
setSkills(skills: Skill[]): void {
this.opts.skills = skills;
}
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;
}
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.uncoveredTools.clear();
this.loopTrace.length = 0;
this.staleItemsRepaired = false;
// Opened before the model runs and closed after it stops, so every write the
// turn makes lands in one snapshot the user can walk back to.
this.snapshots.begin(userText, this.messages.length);
const outputs: Extract<AgentEvent, { type: 'tool-output' }>[] = [];
onBashOutput(({ toolCallId, chunk }) => {
outputs.push({ type: 'tool-output', id: toolCallId, chunk });
this.opts.onToolOutput?.(toolCallId, chunk);
});
try {
yield* this.run(signal, threshold, outputs);
} finally {
onBashOutput(undefined);
// Belt and braces: closingNotices runs before every `done`, but an aborted turn
// can return through a path that never reached it, and an uncommitted snapshot
// would then be silently dropped rather than kept.
this.snapshots.commit();
await this.opts.plugins?.afterTurn();
}
}
/**
* 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;
}
/**
* Prunes the canonical history once a run has landed.
*
* prepareStep only trims the wire copy for the next request; without this write-back
* the stored history keeps growing, the context meter pins at 100%, and every later
* turn re-prunes the same messages from scratch.
*/
private async compactCanonical(threshold: number): Promise<{ before: number; after: number } | null> {
const before = this.messages.length;
if (estimateTokens(this.messages) <= threshold) return null;
const pruned = pruneToFit({ messages: this.messages, threshold, estimate: estimateTokens });
if (pruned.length === before) return null;
const dropped = droppedBy(this.messages, pruned);
const summary = await this.summarizeSpan(dropped.filter((m) => !isPrunedSpanSummary(m)));
this.replace(this.withSummarizedSpan(summary, pruned, dropped));
return { before, after: this.messages.length };
}
/**
* Puts a summary of the discarded span at the head of the history it was dropped from.
*
* Without this the model is told which tool results to keep and nothing about what
* was dropped, so it states a decision it made forty messages ago as though it had
* never made it.
*
* The summary is bounded two ways because a summary that grows with the session
* defeats the point of compacting at all: the input is capped at the span's own
* digest, and the output is capped by instruction and by hard truncation. A failed
* or empty call falls back to the digest, which costs nothing and still carries
* which tool touched which path — the part a contradiction is usually built from.
*/
private async summarizeSpan(dropped: readonly ModelMessage[]): Promise<string | undefined> {
const digest = digestOf(dropped);
if (!digest) return undefined;
try {
const { text } = await generateText({
model: this.model,
system:
'These lines are the condensed record of an earlier part of a coding session that has been ' +
'compacted out of the conversation. Write at most 120 words of notes capturing decisions made, ' +
'files touched, commands run and their outcome, and anything still pending. State only what the ' +
'lines support; do not invent detail and do not address the reader.',
messages: [{ role: 'user', content: digest }],
maxRetries: this.opts.maxRetries ?? 3,
});
const trimmed = text.trim();
return trimmed ? trimmed.slice(0, MAX_SPAN_SUMMARY_CHARS) : undefined;
} catch {
// A summarizer that cannot run must not cost the turn its compaction: the
// digest is a worse record, not an absent one.
return undefined;
}
}
private withSummarizedSpan(summary: string | undefined, pruned: ModelMessage[], dropped: readonly ModelMessage[]): ModelMessage[] {
const worthSummarizing = dropped.filter((m) => !isPrunedSpanSummary(m));
if (worthSummarizing.length === 0) return pruned;
const prior = dropped.filter(isPrunedSpanSummary);
const spanMessage = prunedSpanMessage(summary, worthSummarizing);
if (!spanMessage) return pruned;
return [...prior, spanMessage, ...pruned];
}
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;
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 });
// 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':
this.callInputs.set(part.toolCallId, { toolName: part.toolName, input: JSON.stringify(part.input ?? null) });
yield { type: 'tool-call', id: part.toolCallId, name: part.toolName, input: part.input };
break;
case 'tool-result':
this.recordTrace(part.toolCallId, summarizeToolResult(part.output));
yield { type: 'tool-result', id: part.toolCallId, name: part.toolName, output: part.output };
break;
case 'tool-error':
this.recordTrace(part.toolCallId, `error: ${part.error instanceof Error ? part.error.message : String(part.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* this.closingNotices();
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* this.closingNotices();
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;
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));
// prepareStep already emits `compacted` at the same threshold crossing, and
// replace() fires onChange on the fold path; both would double up otherwise.
if (!(await this.compactCanonical(threshold))) this.opts.onChange?.(this.messages);
if (pending.length === 0) {
const usage = await result.usage;
this.inputTokens += usage.inputTokens ?? 0;
this.outputTokens += usage.outputTokens ?? 0;
// 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* this.closingNotices();
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 });
}
}
}